on shell and editor integration

There's a common sentiment of using *nix as an IDE, in contrast to using a more traditional IDE like Visual Studio, VSCode, JetBrains, etc. The argument often comes down to whether you want a single tool to act as the IDE, or if you want the IDE to be made up of smaller pieces that work together.

I fall pretty heavily in the “*nix as an IDE” camp with the exception that I want my shells to be well integrated into my editors, making them act as one. In this post, I talk some about the utility of having this kind of integration and also about how to achieve this in Vim, from enabling the feature to actual uses.

The philosophy of shell-editor integration

I'm a big fan of :terminal. It reminds me a lot of when I used Emacs for everything and how integrated my entire environment was. It's also very reminiscent of Plan 9 software. Underlying all of them is the idea that the terminal and the text editor should be heavily integrated.

Vim's :terminal is close to Emacs' M-x term and is much less powerful than Plan 9's Acme/Sam and Emacs' M-x shell. In the former cases, the shell is merely co-located with the editor but you are unable to change text earlier in the buffer. This makes sense, as they are essentially TTY emulators. The latter break away from this distinction and see the shell more as a utility than as the end goal. With these, you can change parts of the buffer after they've been created.

A common thing I would do with Emacs (and Acme) is to run a command (e.g. find . -type f -maxdepth 0), go through the output and manually remove any things I forgot to filter out with my command (e.g. remove any files with a ~ at the end, visually and manually), and then prefix each line with another command (e.g. git add). Finally, I could yank/copy those newly created commands, go to the end of the buffer, and paste to run them. Although it's not the most efficient or reproducible (like find -type f -maxdepth 0 -not -name '*~' -exec git add {} +), it was very natural to progressively build everything up, and gave more trust in exactly what was being run.

Another example: if I had lots of image files in nested directories in one format and I wanted them in a single separate directory in another, I could first find all the files (find -type f -name '*.jpg'), start a macro (C-x (), do all my transformations on one line (path/to/file.jpg –> convert path/to/file.jpg output/file.png), end the macro (C-x )), and then repeat it for a number of lines (C-x eeeee), and finally yanking/pasting at the end of the buffer to run.

The same sorts of workflows work in Plan 9, however instead of yanking/pasting, you can directly execute the commands by highlighting with the middle mouse button, and instead of using macros, you would use structural regexps.

The closest version of that workflow with :terminal or M-x term is to yank the output of those commands into a temporary buffer before editing and running them, making it a lot more of a manual and deliberate process.

In summary, of the methods I know to do this integration (specifically with Vim, Emacs, and Acme), this table shows whether the history is editable and what the lifetimes of the shell and editor are.

Method Editable Shell Editor
Shell w/ vim No Always Sometimes
Vim w/ :sh No Always Sometimes
Vim w/ :terminal No Always Always
Emacs w/ M-x term No Always Always
Emacs w/ M-x shell Yes Always Always
Acme w/ win Yes Always Always

Although Vim's :terminal isn't the best on the list, it is what I'm currently using and it can be a little nontrivial to use, so the rest of this article focuses on it.

Compiling Vim with terminal support

The :terminal feature is relatively recent and must be enabled when vim is compiled. Some old versions of Vim don't have the feature available at all, and some versions from package managers also don't.

To check, you can look at the output of vim --version and look for either +terminal or -terminal:

goodmachine$ vim --version | grep -o .terminal
+terminal
badmachine$ vim --version | grep -o .terminal
-terminal
oldmachine$ vim --version | grep -o .terminal
# no output

You can compile Vim manually with:

$ cd $(mktemp -d)
$ git clone https://github.com/vim/vim.git $PWD
$ git checkout v8.2.0694
$ ./configure --enable-terminal --prefix=$PWD/install
$ make
$ make install
$ ./install/bin/vim --version | grep -o .terminal

Using :terminal

Running :terminal will create a new buffer that is functionally identical to any other TTY (like the ones created by tmux, screen, or Emacs' M-x term). By default, it will run whatever your $SHELL is, though you can change that with :terminal another-cmd here.

:terminal has 2 modes, an “insert” mode where you can type commands (this is the initial one when you create a terminal buffer) and a “normal” mode where you can navigate the buffer and see the scrollback. To go from insert->normal, use <C-w>N (capital N) and to go from normal->insert, use i. While in insert mode, to send a literal <C-w> (e.g. to delete the previous word in the current command), you use <C-w>. (dot/period). You can switch between windows with <C-w>j and <C-w>k like normal.

A nice thing about :terminal being a TTY emulator is that you can run other TTY-needing commands inside. For instance, I often find that I need to edit another file quickly while in the terminal, and for that I can rely on my muscle memory to just run vim that-other-file and have it work.

At first, the use of capital N bothered me because it seemed really out of reach for such a common thing to do. That was before realizing that, because N is also the command to go to the previous search, a really useful thing to do is to run a command (e.g. make), go into normal mode (<C-w>N), search for the command (/\$ make), then go back to the previous one (N). Now you're at the top of the output of the last command run, so you can look through it for any error messages. Next time, after running the command again, you only need to <C-w>NN to go back to the top of the output.

One problem I ran into that I haven't found a good fix for yet is that I would like to hit a button (e.g. <F2>) and have a command automatically run inside of my terminal. Most solutions I found online either seemed like way too much code or simply didn't work at all. It seems like it should be some combination of term_list() and call term_sendkeys(). The closest I've gotten is :call term_sendkeys(get(term_list(), 0), "echo hello\n") but this has a weird problem where it waits for me to do something before it actually sends the command (e.g. I run that and then have to hit j to actually have it run the command in the terminal buffer).