Opening terminal paths in an existing Neovim pane with Kitty hyperlinks
How I wired kitty, OSC 8 hyperlinks, and Neovim remote control so plain clicks on ls and rg output open files in the nvim pane already running in the same window.
I wanted to click a filename in ls output and have it open in the Neovim pane already sitting next to my shell, not in VS Code, not in Finder, and not in a new nvim instance.
Desired behavior
This setup is on macOS. I tested it with kitty 0.47.1, nvim 0.12.2 (Homebrew), eza 0.23.4, ripgrep 15.1.0, and fd 10.4.2. Clicks on paths in terminal output were routing to the wrong place. Unmatched file URLs go through the system open command. My .md files opened in VS Code. Directories opened in Finder. Even when kitty did route clicks to nvim, they spawned a separate instance instead of reusing the one in my split layout.
I also wanted a plain left-click. Ctrl+Shift+click works out of the box in kitty, but modifier keys were not what I was after.
The fix uses OSC 8 terminal hyperlinks. Tools like eza, ripgrep, and fd can emit them. A click on a file:// URL can run arbitrary actions via open-actions.conf.
Hazel Duvall wrote up a similar kitty workflow around the same idea: hyperlinked rg/fd/eza output and open-actions.conf to open files in an existing Neovim tab instead of a new instance. I found that post while researching; my version uses a per-KITTY_PID RPC socket rather than a per-tab pipe.
Implementation
The setup has four pieces: emit hyperlinks from shell tools, tell kitty how to handle clicks, register a per-kitty RPC socket in nvim, and route clicks through a small wrapper script.
1. Emit hyperlinks from shell tools
In ~/.commonrc, I wrapped ls, rg, and fd to add hyperlinks only when output goes to a TTY:
# ~/.commonrc
ls() {
if [[ -t 1 ]]; then command eza --hyperlink "$@"
else command eza "$@"; fi
}
rg() {
if [[ -t 1 ]]; then command rg --hyperlink-format=default "$@"
else command rg "$@"; fi
}
fd() {
if [[ -t 1 ]]; then command fd --hyperlink=auto "$@"
else command fd "$@"; fi
}
eza --hyperlink wraps paths in invisible OSC 8 metadata. kitty reads that metadata on click. The paths do not need to look underlined for clicks to work.
2. Route clicks through open-actions.conf and kitty.conf
~/.config/kitty/open-actions.conf maps file:// URLs to a wrapper script. Three rules cover line jumps, known extensions, and a text/* catch-all. kitty expands environment variables in action lines, so $HOME works; no hardcoded /Users/... path needed.
# ~/.config/kitty/open-actions.conf
protocol file
fragment_matches [0-9]+
action launch --type=background --cwd=current --copy-env -- $HOME/.local/bin/kitty-open-nvim +${FRAGMENT} ${FILE_PATH}
protocol file
ext md,markdown,txt,json,yaml,yml,toml,js,ts,tsx,jsx,py,go,rs,rb,sh,zsh,bash,lua,vim,xml,html,css,scss,sql,conf,lock
action launch --type=background --cwd=current --copy-env -- $HOME/.local/bin/kitty-open-nvim ${FILE_PATH}
protocol file
mime text/*
action launch --type=background --cwd=current --copy-env -- $HOME/.local/bin/kitty-open-nvim ${FILE_PATH}
The --copy-env flag mattered. I assumed background launches inherited KITTY_PID; they do not without --copy-env. Without it, KITTY_PID was empty in the background process. The wrapper could not find the nvim socket, and clicks fell back to spawning a new editor or routing to the macOS default app.
I added lock to the extension list because kitty detects MIME types by extension, not file contents. mise.lock has no system MIME mapping, so it missed the text/* rule and opened in VS Code.
A supplement file at ~/.config/kitty/mime.types maps a few extensions kitty’s database missed:
text/source jsx tsx xml md markdown toml
Plain left-click also required kitty mouse and underline settings in ~/.config/kitty/kitty.conf:
# ~/.config/kitty/kitty.conf
allow_hyperlinks yes
underline_hyperlinks hover
show_hyperlink_targets ctrl
mouse_map left click ungrabbed mouse_handle_click link selection prompt
launch --location=hsplit --cwd=$HOME
copy_on_select yes was causing clicks to hit a stale selection before the link handler ran. Putting link first fixed that.
underline_hyperlinks always underlined every path permanently. Visually busy in a directory listing. hover keeps output clean and shows the underline only when the mouse is over a clickable path. Clicks still work either way.
The launch --location=hsplit line starts each kitty window with nvim in a horizontal split next to the shell.
3. Register a per-kitty RPC socket in nvim
Each kitty process gets its own socket at ~/.cache/nvim/kitty-${KITTY_PID}.sock. In ~/.config/nvim/init.lua:
-- ~/.config/nvim/init.lua
if vim.env.KITTY_PID then
local function register_kitty_socket()
local sock = vim.fn.stdpath("cache") .. "/kitty-" .. vim.env.KITTY_PID .. ".sock"
if vim.uv.fs_stat(sock) then return end
pcall(vim.fn.serverstart, sock)
end
vim.api.nvim_create_autocmd("VimEnter", { once = true, callback = register_kitty_socket })
vim.defer_fn(register_kitty_socket, 0)
end
nvim only registers the socket when KITTY_PID is set, which kitty’s shell integration provides to child processes.
4. The wrapper script
~/.local/bin/kitty-open-nvim resolves the socket for the current kitty instance and opens the file remotely. When KITTY_PID is missing, it walks the parent process chain to find kitty. If exactly one kitty-*.sock exists, it uses that; with multiple sockets it needs KITTY_PID from --copy-env. The core remote calls are:
# ~/.local/bin/kitty-open-nvim (excerpt)
sock="${HOME}/.cache/nvim/kitty-${KITTY_PID}.sock"
nvim_bin="/opt/homebrew/bin/nvim" # Homebrew path on macOS
exec "$nvim_bin" --server "$sock" --remote-tab-silent "${args[@]}"
For rg -n line jumps (file:///path#L42), native --remote-tab-silent +42 file is broken in nvim 0.12.2; it opens a buffer literally named +42. The wrapper uses --remote-expr instead:
exec "$nvim_bin" --server "$sock" \
--remote-expr "execute('tabedit +${line} ' . fnameescape('${esc_file}'))"
A log at ~/.cache/nvim/kitty-open.log helped debug the KITTY_PID issue. Empty KITTY_PID in every log line pointed directly at the missing --copy-env.
Verification
After the changes:
- Plain left-click on a filename in
lsopens a new tab in the existing nvim pane rg -nresults open at the correct linemise.lockand other extensionless text files route to nvim instead of VS Code- Directory names still open in Finder. I have no rule for
inode/directory, and I did not want backgroundcdhacks - Paths look normal in terminal output; underline appears on hover only
Caveats
I tried flatten.nvim first. It failed because nvim’s default RPC socket lives at stdpath("run"), which differs per instance. A fixed per-kitty socket path was simpler and more predictable.
I also evaluated neovim-remote (nvr) before settling on native nvim remote. nvr is a Python CLI that talks to nvim over a named RPC socket, the same mechanism vim users relied on before nvim shipped a built-in client. On nvim 0.12.2, the native flags cover what this workflow needs:
# Open a file in a new tab on an existing server — works on 0.12.2
nvim --server "$sock" --remote-tab-silent /path/to/file
# Jump to a line — broken with +line on 0.12.2 (opens a buffer named "+42")
nvim --server "$sock" --remote-tab-silent +42 /path/to/file
nvr would not fix the +line bug. It ultimately shells out to the same remote API. For plain file opens, nvr --servername "$sock" -t file and nvim --server "$sock" --remote-tab-silent file do the same thing. I skipped nvr to avoid an extra dependency (uv tool install neovim-remote) and kept the wrapper as a thin bash script around native nvim.
The line-jump case still needs --remote-expr regardless of whether nvr or native nvim is the caller:
nvim --server "$sock" \
--remote-expr "execute('tabedit +42 ' . fnameescape('/path/to/file'))"
I did not install nvr on this machine. If I were on an older nvim without --remote-tab-silent, nvr would be the reasonable fallback.
If I run multiple kitty instances (separate processes), each with its own nvim, the per-KITTY_PID socket scheme isolates them. A single global socket would be simpler but would break with more than one nvim. Multiple OS windows opened inside one kitty instance share the same KITTY_PID, so this scheme does not isolate those; I only have one nvim per instance today.
References
- Click-To-Open In Neovim With Kitty, Hazel Duvall. Prior art on hyperlinked terminal tools and reusing an existing Neovim instance
- kitty, Glossary.
KITTY_PIDis per kitty process;WINDOWIDis per OS window - kitty, Shell integration. Exports
KITTY_PIDto child shells - kitty #5602. Multiple OS windows in one instance share
KITTY_PID - kitty, Scripting the mouse click. OSC 8 hyperlinks,
open-actions.confformat, and special variables - kitty, launch action.
--copy-env,--type=background, and--location=hsplit - kitty, allow_hyperlinks. Enable OSC 8 link handling
- kitty, underline_hyperlinks.
hovervsalwaysvsnever - kitty, mouse actions.
mouse_mapandmouse_handle_click - Neovim, Remote API.
--server,--remote-tab-silent,--remote-expr - Neovim, stdpath(). Default RPC socket location under
run - eza.
--hyperlinkflag - ripgrep.
--hyperlink-format - ripgrep user guide. Hyperlink output details
- fd.
--hyperlinkflag - flatten.nvim. Tried first; per-instance RPC socket mismatch
- neovim-remote. Considered; native nvim remote made it unnecessary
This post was written with AI assistance.