Main page Rss feed

OSC7 in Neovim: third time's a charm

TL;DR

My personal history with OSC 7

For all the reasons I mentionned in my first article about OSC7 and Neovim, running all of your shells in Neovim's terminal is super nice. But as I mentionned in the same article, the shell's current directory not being synchronized with Neovim's was a bummer.

From 2017 to 2021, I've been solving this synchronization problem with a disgusting mix of python, shell and vimscript that ran each time I changed directory in the shell. In 2021, I got fed up with the jankyness of my solution and wrote a Neovim patch to get synchronization working directly in Neovim's terminal through the use of OSC 7 escape sequences.

Working through the various iterations of my patch, I got increasingly uncomfortable with how narrow it was and ended up drifting toward a generic autocommand named TermOSC that would enable handling any OSC event.

I eventually became happy enough with the patch that I started daily-driving my fork, opened a PR and wrote an article about it. I wasn't aware of it at the time, but as outlined in this comment, Neovim autocommands run asynchronously, so my perfectly-valid-for-OSC7 patch wasn't actually usable for OSC events that required more synchronization with the terminal's output. I recommended not merging my PR, but also said that I would not work on getting synchronous OSC support working in Neovim, as my patch was sufficient for my needs.

And so my PR died. I kept using my increasingly stale fork until a year later, when my hero @gpanders decided to revive it, renaming the TermOSC autocommand to TermRequest and performing a few fixes, and got it merged in less than a week 🤯. This TermRequest event still has the major synchronization issue my TermOSC event had, but I'm glad nonetheless: the PR getting merged means that I can run Neovim nightly again.

This means that, if you run Neovim nightly, you can now synchronize your shell's current working dir and Neovim's current working dir with just a tiny bit of shell and lua scripting!

Scripting OSC7 support

In your init.lua, add the following:

vim.api.nvim_create_autocmd({ 'TermRequest' }, {
  callback = function(e)
    if string.sub(vim.v.termrequest, 1, 4) == "\x1b]7;" then
      local dir = string.gsub(vim.v.termrequest, "\x1b]7;file://[^/]*", "")
      if vim.fn.isdirectory(dir) == 0 then
        return
      end
      vim.api.nvim_buf_set_var(e.buf, "last_osc7_payload", dir)
      if vim.o.autochdir and vim.api.nvim_get_current_buf() == e.buf then
        vim.cmd.cd(dir)
      end
    end
  end
})
vim.api.nvim_create_autocmd({ 'bufenter', 'winenter', 'dirchanged' }, {
  callback = function(e)
    if vim.b.last_osc7_payload ~= nil
      and vim.fn.isdirectory(vim.b.last_osc7_payload) == 1
    then
      vim.cmd.cd(vim.b.last_osc7_payload)
    end
  end
})

You may want to remove the check for vim.o.autochdir depending on your preferences. Then, just make sure your shell is emitting OSC 7 sequences. For bash, add the following to your .bashrc:

function print_osc7() {
  printf "\033]7;file://$HOSTNAME/$PWD\033\\"
}
PROMPT_COMMAND="print_osc7${PROMPT_COMMAND:+;$PROMPT_COMMAND}"

For zsh, add the following to your .zshrc:

function print_osc7() {
    if [ "$ZSH_SUBSHELL" -eq 0 ] ; then
        printf "\033]7;file://$HOST/$PWD\033\\"
    fi
}
autoload -Uz add-zsh-hook
add-zsh-hook -Uz chpwd print_osc7
print_osc7

For fish, add the following to your fish config:

function print_osc7 --on-variable=PWD
  printf "\033]7;file://$HOSTNAME/$PWD\033\\"
end

Appendix: representing bytes across layers

I received a question by email:

Your PROMPT_COMMAND has this printf "\033]7;file://$HOSTNAME/$PWD\033\\" which starts with \033]7; in your lua script you check for "\x1b]7;" which well is different than the first one. I even went a step further and printed the entire vim.v.termrequest and that starts with ^]]7 which is once again different. Initially I thought that was a problem and couldn't for the love of god figure out what's going on. Do you have any insights for a newbie as to what all of this means?

To which I replied:

Basically, it's all the same value, except with different representations. The OSC7 protocol says that an OSC7 sequence starts with the bytes 0x1B 0x5D 0x37 0x3B. The problem is, 0x1B is not a printable character in the ASCII table, it's the byte that stands for the "Escape" character. So we have to use tricks to represent this byte in all the different layers we use. Fun facts: I hope that clears things up! If you're interested in learning more about terminal escape sequences, I can recommend reading gpanders' State of the Terminal, it's pretty good!