Main page Rss feed

OSC 7 in Neovim's Terminal

The problem

Neovim's terminal emulator is pretty neat. It allows using a regular Neovim buffer as a terminal, letting you feed keys to the underlying application while in insert mode (terminal mode, actually!) and making all of your regular Neovim bindings available while in normal mode.

This is very powerful: if you are a Neovim user, Neovim can pretty much replace your terminal multiplexer. If you need Screen's or Tmux's session management on top of that, programs like dtach or abduco can do that for you.

Using Neovim as a terminal multiplexer means being able to use all the keyboard shortcuts you are used to. Looking for something in your command's output? No need to re-run while piping to grep, use / or ? instead. Want to copy the output to your clipboard? Don't reach for your mouse, use "+y!

Unfortunately, not all keybindings work seamlessly in terminal buffers. gF in particular can be very unreliable. When the cursor is over a file path, gF is supposed to open the file. This works very well for absolute paths, but when the path is relative, path resolution will be performed according to Neovim's current working directory. You can see where this is going: when an application running in a terminal buffer prints a file path relative to its current working directory, Neovim will only be able to find the file if its current working directory is the same as the application's!

This is very annoying when the program running in the terminal buffer is a shell, where the current directory changes frequently and thus prevents gF from working on the output of ls, grep and find . until you :cd Neovim to the right directory.

The solution

OSC 7 is a terminal control sequence that lets applications running in a terminal inform the terminal of the application's current working directory. The sequence itself is very simple:

\033]7;file://HOSTNAME/PATH\033\\

When an application prints this sequence, terminals supporting it will understand that the application is running on HOSTNAME, in directory PATH. Neovim's terminal did not support this sequence, but I was able to change that in less than a hundred lines of code, tests included, thanks to libvterm doing all of the heavy lifting.

With my patch, Neovim is now able to set the directory of terminal buffers when applications emit OSC 7 sequences. And with the 'autochdir' option, it will automatically move to that directory when the terminal buffer is focused!

In order to take advantage of this, you just need to stick set autochdir in your init.vim and then to append the following snippet of code to your .zshrc:

if [[ ! -v chpwd_functions ]]; then
    chpwd_functions=()
fi
function emit_osc7() {
    printf "\033]7;file://$HOST/$PWD\033\\"
}
chpwd_functions+=emit_osc7

You will then be able to use gF on the paths printed by grep -ri and find . in Neovim's terminal buffers, just like in this demo:

A demo showing the use of Neovim's gF keyboard shortcut in the Neovim terminal.

Appendix: Prior attempts

I first tried to solve this problem sometime in 2017. My first attempt was a disgusting mix of Vimscript, Zsh and Python which eventually ended up in a plugin. This plugin worked by leveraging Zsh's chpwd_function, but this time to run a Python script that would connect to the parent Neovim instance through pynvim in order to call the VimScript functions that would set the cwd (and do a bit more than that, as the cwd has to be re-set every time you change buffers!).

While this worked fine for more than three years, I've always been uneasy with how hackish everything was. In 2021, my job required me to start using Python tools that broke my Python environment in ways I did not understand. In particular, pynvim sometimes became unavailable.

This led me to create a new plugin, which I named syncwd. Syncwd only relies on Zsh's chpwd_function and on having an existing Neovim binary in $PATH, which fits my requirements much better. I'm also quite proud of the way I used Neovim as a client to connect to the parent Neovim instance (so proud, in fact, that I re-used this approach in another plugin, ileum, that makes Zsh execute any command starting with a colon in the parent neovim instance). This was a bit tricky to get right but the result is quite short and fits on less than fifty lines! Here's the Zsh part:

if [ -n "$NVIM_LISTEN_ADDRESS" ]; then
  if [[ ! -v chpwd_functions ]]; then
    chpwd_functions=()
  fi

  SYNCWD_PLUGIN_DIR="$(dirname "$0")"
  function syncwd () {
    wd="${(qqq)PWD}"
    nvim -u NONE -i NONE --headless \
      --cmd "source $SYNCWD_PLUGIN_DIR/syncwd.vim" \
      -c ":call Syncwd('$NVIM_LISTEN_ADDRESS', '$wd', $$)"
  }

  chpwd_functions+=syncwd
fi

And the Vimscript one:

function Syncwd(addr, pwd, pid)
  let channel = sockconnect('pipe', a:addr, { 'rpc': v:true })
  let has_pid = '{k,v -> has_key(getbufinfo(nvim_win_get_buf(v))[0].variables, "terminal_job_pid") }'
  let wins_with_pid = 'filter(nvim_list_wins(), ' . has_pid . ')'
  let pid_matches = '{k,v -> nvim_buf_get_var(nvim_win_get_buf(v), "terminal_job_pid") == ' . a:pid . '}'
  let wins_matching_pid = 'filter(' . wins_with_pid . ',' . pid_matches . ')'
  let wins = rpcrequest(channel, 'nvim_eval', wins_matching_pid)
  for win in wins
    try
      call rpcrequest(channel, 'nvim_command', ':lua vim.api.nvim_win_call(' . win .
            \ ', function() vim.cmd("lcd " .. ' . a:pwd . ') end)')
    catch
      echo v:exception
    endtry
  endfor
  qall!
endfunction

Overall, this was still a big hack and I am glad I can replace it with a standard such as OSC 7. Now that I got a taste of OSC goodness, I think I will also try to add support for Semantic markers for prompts which would allow me to get completely rid of my shelley plugin.