Application-specific terminals

I’ve been using Neovim off and on for a number of years (and prior to that that, I was using vim). One of the things that really annoyed me about using it as my daily driver was that I needed to either use it directly in a terminal or use some third-party GUI. Working directly in a terminal can be nice, but being able to Cmd+Tab to my editor is a critical part of my workflow. If I need to dig through terminal tabs to find my text editor, I get frustrated. Third-party GUIs can be nice, but they generally provide very little value over a TUI (other than being able to Cmd+Tab to it) and the experience can be lacking sometimes.

A solution to this problem is to use an “application-specific terminal”, which is analogous to the idea of a site-specific browser (or more recently, an installable progressive web app). In order to make this work, you need:

  1. A terminal emulator that is very flexible about how it starts up (either via command line switches or via configuration).
  2. Some way to register an application with your desktop environment.

Linux

In many Linux distributions, this is a fairly straightforward proposition: simply create a .desktop entry. When I was running Linux for my daily driver (I used arch, btw), I used a desktop entry like this for running Mutt in a standalone terminal window (at the time, I was using kitty):

[Desktop Entry]
Type=Application
Name=Mutt
GenericName=Mail client
Comment=A fast, terminal based mail client.
Exec=kitty -o "map=ctrl+shift+t no-op" --session=/home/cweagans/.config/kitty/mutt.session --class=mutt
Icon=/home/cweagans/.config/mutt/mutt-256.png
Categories=System;Mail;
Terminal=false
StartupNotify=true
StartupWMClass=mutt

The StartupWMClass is the one that really does most of the heavy lifting here. It tells your desktop environment which application “owns” all of the windows spawned by this desktop entry. Kitty also had a command line flag to allow setting the window class, which needs to match the StartupWMClass param in your .desktop file.

The other part of this setup is a mutt.session, a startup session that is loaded by Kitty on startup to set the window title and tell Kitty to launch mutt. The contents of that session file were fairly straightforward:

# Set the window title to "Mutt"
title Mutt
# Start a mutt process
launch mutt
# Focus the new window
focus

I’ve since moved on from using Kitty and from running Linux as my daily driver and haven’t gotten around to replicating this setup until very recently.

macOS

Regrettably, macOS was a bit more of a challenge, so I broke it down into smaller problems. First, I need to get my terminal emulator to do what I want. I use wezterm, so I have the full power of Lua available to me to determine what to do on startup. For my purposes, I want a group of common settings + mouse/key bindings in all wezterm instances, and then if it’s not an application-specific terminal, I want to set up keybindings for managing splits and tabs.

My configuration uses a pair of environment variables, WEZTERM_IS_AST and WEZTERM_AST_COMMAND, to determine if this is an application-specific terminal or not. I can test this without worrying about integrating with the macOS desktop environment by manually starting a wezterm instance with the environment variables set:

WEZTERM_IS_AST=true WEZTERM_AST_COMMAND="vim" wezterm start

To validate that this worked as I intended, I should be able to see that a vim process is running and that I can’t create new tabs or splits.

Integrating with macOS was a little more tricky, but that was only because the information needed was scattered across quite a few sections of documentation. If you’ve used macOS for any length of time, you may know that macOS applications are actually directories organized in a particular way. We need three things to construct a minimal app bundle:

  1. An Info.plist
  2. An executable
  3. An icon (optional)

We can start by stubbing out the directories we need. In this example, I’m creating Neovim.app and it will live in ~/Applications (which is indexed by Spotlight by default for easy access):

mkdir -p ~/Applications/Neovim.app/Contents/{MacOS,Resources}

This will create a tree structure like so:

Neovim.app
└── Contents
    ├── MacOS
    └── Resources

Inside Neovim.app/Contents, we need to create an Info.plist that describes the application to macOS:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>CFBundleDevelopmentRegion</key>
	<string>en</string>
	<key>CFBundleDisplayName</key>
	<string>Neovim</string>
	<key>CFBundleExecutable</key>
	<string>Neovim</string>
	<key>CFBundleIconFile</key>
	<string>AppIcon.icns</string>
	<key>CFBundleIdentifier</key>
	<string>org.neovim.Neovim</string>
	<key>CFBundleInfoDictionaryVersion</key>
	<string>6.0</string>
	<key>CFBundleName</key>
	<string>Neovim</string>
	<key>CFBundlePackageType</key>
	<string>APPL</string>
	<key>CFBundleShortVersionString</key>
	<string>1.0</string>
	<key>LSMinimumSystemVersion</key>
	<string>10.11.0</string>
	<key>LSUIElement</key>
	<false/>
	<key>NSAppTransportSecurity</key>
	<dict>
		<key>NSAllowsArbitraryLoads</key>
		<true/>
	</dict>
	<key>NSHumanReadableCopyright</key>
	<string>©2023 Neovim Contributors</string>
	<key>LSApplicationCategoryType</key>
	<string>public.app-category.developer-tools</string>
	<key>LSEnvironment</key>
	<dict>
		<key>WEZTERM_IS_AST</key>
		<string>true</string>
		<key>WEZTERM_AST_COMMAND</key>
		<string>nvim</string>
	</dict>
        <key>com.apple.security.automation.apple-events</key>
        <true/>
        <key>com.apple.security.device.audio-input</key>
        <true/>
        <key>com.apple.security.device.bluetooth</key>
        <true/>
        <key>com.apple.security.device.camera</key>
        <true/>
        <key>com.apple.security.personal-information.addressbook</key>
        <true/>
        <key>com.apple.security.personal-information.calendars</key>
        <true/>
        <key>com.apple.security.personal-information.location</key>
        <true/>
        <key>com.apple.security.personal-information.photos-library</key>
        <true/>
</dict>
</plist>

Somewhat annoyingly, this file is aggressively cached by macOS, so if you make changes to it, you’ll need to tell LaunchServices to reload the configuration:

/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister ~/Applications/Neovim.app

Next up is the executable. This is configured by the CFBundleExecutable parameter above. In my setup, I’ve simply copied the wezterm-gui executable that wezterm ships with:

cp /Applications/WezTerm.app/Contents/MacOS/wezterm-gui ~/Applications/Neovim.app/Contents/MacOS/Neovim

To make this work, you’ll need to re-sign the binary (the entitlements added in the Info.plist were also necessary). Don’t worry, you don’t need a code signing identity: the codesign tool that ships with macOS has the idea of an ad-hoc signature, which doesn’t provide any cryptographic proof that the binary is genuine, but it does satisfy Gatekeeper. To do this, run:

codesign --force --deep -s - ~/Applications/Neovim.app

Finally, you need an app icon. In macOS, these are a bit special, so you can use something like makeicns (available via Homebrew) to generate the file you need. Once you’ve created it, copy it to ~/Applications/Neovim.app/Contents/Resources/AppIcon.icns`.

The final tree structure should look like this:

Neovim.app
└── Contents
    ├── Info.plist
    ├── MacOS
    │   └── Neovim
    └── Resources
        └── AppIcon.icns

Now you should be able to launch Neovim just like any other application and have it start in its own dedicated terminal window.

BIG CAVEAT: In order for this to work, launchd needs to know about your $PATH settings. If you’ve set your $PATH in your zsh profile or similar, your new app bundles can only be launched from the terminal (e.g. open ~/Applications/Neovim.app). To make it so you can launch your app bundles from Finder or Spotlight, you need to tell launchd about your PATH settings like so

launchctl setenv PATH $PATH
sudo launchctl config user path $PATH

Once you’ve done that, you’ll need to restart Finder and Spotlight (killall Finder and killall Spotlight respectively).

Automated version

Because I was continuously regenerating app bundles while writing this post, I wrote a script that will spit out an app bundle as described above. I use sd for my scripts, but this should work for anyone with minor changes. You can find my script on Github.