Main page Rss feed More recent article on the same subject

Neovim's TermOSC for OSC7 integration.

If you don't know OSC7 or why it would be useful in Neovim, check out this article I wrote a year before this one.

In neovim/neovim#22159, I made Neovim emit TermOSC autocommand events when OSC sequences are emitted by a job running in a terminal buffer. I tried to implement an equivalent to Vim's autoshelldir in the same pull request, but couldn't find a good design.

One of my problems with Vim's autoshelldir is that it's wrong. In Vim, when the terminal receives an OSC7 sequence, the current working directory of the window will change to the directory pointed to by the OSC7 sequence. This is not what you want! If you change the buffer of that window, the current working directory of the window will have nothing to do with the new buffer's. Worse, when you enter the terminal buffer from another window, the current working directory will not be synced.

What you really want is to have the current working directory stick to the buffer, not the window. Unfortunately, Vim and Neovim do not have a concept of buffer-local current working directory. :cd changes the global working directory (the whole instance's), :tcd changes the tab's and :lcd changes the window's.

I looked into changing this some time ago. It would be really, really hard. When Neo/Vim needs to derive the concept of a current working directory (e.g. for its autochdir feature), it derives it from the buffer's full path (buf->b_ffname). This means that maintaining a separate buffer-local working directory would require checking each use of the buffer's full path in order to determine whether we're using the path in order to get a working directory or in order to perform file operations. I am not ready for this kind of madness.

Fortunately, there's a way out. Several, in fact. We can't have a global concept of buffer-local working directories, but is that really what we need? No, we only really need buffer-local working directories for terminal buffers. Which do not have a filesystem-backed file, and so no file operations. So we could change the full path of terminal buffers and have features like autochdir work seamlessly! And in fact, this is exactly what I did in my previous attempt at getting OSC7 into neovim!

Unfortunately (or rather, fortunately: I like my more recent PR's design much better), that previous PR did not get merged, as @justinmk remarked that it was a whole lot of new C code and trying to do it in Lua might make more sense. So I made another PR, which makes Neovim emit TermOSC autocommand events when terminal buffers receive OSC sequences. Writing an autoshelldir equivalent then just becomes a matter of writing an event handler or two to handle these events.

The first approach I tried was to match exactly Vim's autoshelldir and it ended up looking like this:

vim.api.nvim_create_autocmd({ 'termosc' }, {
  callback = function(e)
    if vim.v.event.command == 7 then
      local dir = string.gsub(vim.v.event.payload, "file://[^/]*", "")
      if vim.fn.isdirectory(dir) == 1 then
        vim.cmd.lcd(dir)
      else
        vim.cmd.echomsg([["Warning: received OSC7 for non-existing dir " .. string(v:event)]])
      end
    end
  end
})

Notice the use of :lcd, which changes the window's current working directory and not the buffer's, exactly like in Vim.

My second approach was to use nvim_buf_set_name to change the buffer's name and have autochdir work seamlessly:

vim.api.nvim_create_autocmd({ 'termosc' }, {
  callback = function(e)
    if vim.v.event.command == 7 then
      local dir = string.gsub(vim.v.event.payload, "file://[^/]*/", "")
      if vim.fn.isdirectory(dir) == 0 then
        vim.cmd.echomsg([["Warning: received OSC7 for non-existing dir " .. string(v:event)]])
        return
      end
      local current_name = vim.api.nvim_buf_get_name(e.buf)
      local pid = vim.api.nvim_buf_get_var(e.buf, "terminal_job_pid")
      local job = string.gsub(current_name, "term://.*//" .. pid .. ":", "")
      local new_name = "term://" .. dir .. "//" .. pid .. ":" .. job
      vim.api.nvim_buf_set_name(e.buf, new_name)
    end
  end
})

Unfortunately, this requires patching the way autochdir works. And this has a second issue: nvim_buf_set_name uses rename_buffer which clobbers b_sfname, which is the "display name" of buffers set with :file, thus overwriting custom names set by users.

So we get to the last solution I came up with, which is to emulate autochdir in Lua when entering terminal buffers:

vim.api.nvim_create_autocmd({ 'termosc' }, {
  callback = function(e)
    if vim.v.event.command == 7 then
      local dir = string.gsub(vim.v.event.payload, "file://[^/]*", "")
      if vim.fn.isdirectory(dir) == 0 then
        vim.cmd.echomsg([["Warning: received OSC7 for non-existing dir " .. string(v:event)]])
        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
})

Overall, none of these solutions feel perfect, but the last one gets me pretty close to where I want to be, lets me stop running a patched Neovim and lets me drop the hacky plugins I had to write.

I'm not certain an autoshelldir-like feature should be merged into Neovim anymore. As demonstrated here, the TermOSC autocommand event is flexible enough to implement everything in userland. However, if it had to be implemented, in Neovim, I think something based on solution 2 might be the right choice: change autochdir to be able to look at terminal paths, extend nvim_buf_set_name to take a dictionnary that would enable not overwritting buf->sfname and you'll have something that feels pretty native to users.

Appendix: getting your shell to emit OSC7 sequences

As a reminder, in order to get the TermOSC event to be triggered, you need to get your shell to emit such sequences. This is fairly easy to do:

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() {
  printf "\033]7;file://$HOST/$PWD\033\\"
}
autoload -Uz add-zsh-hook
add-zsh-hook -Uz chpwd 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