Opening local and SSH files in Neovim from Kitty hyperlinks
How I route Kitty file hyperlinks to local or SSH-session Neovim, reuse RPC sockets, and keep remote files from opening on my Mac.
I wanted filenames in Kitty output to open in the existing Neovim editor on the machine that owns the file, including over SSH.
Note
I originally wrote this post about local clicks on macOS. This October 2026 update adds Mac-to-Linux routing, checked with Kitty 0.49.2, local Neovim 0.12.5, and zmx 0.8.1. The URL stays the same; the SSH path now needs its own connection and editor identity.
The Problem
Local file links initially opened in VS Code or Finder. Routing them to Neovim helped, but starting a new editor for every click still missed the editor already sitting beside my shell.
The SSH version failed differently. Clicking /home/fran/... from my Linux server sometimes opened that path in Neovim on my Mac. The file link was remote, but my router returned False when Kitty could not see an SSH kitten in the window’s foreground processes. Kitty then used my local open-actions.conf.
That distinction matters with zmx: the foreground process in the Kitty window can be zmx attach sca, while SSH runs inside the persistent session. An empty window.ssh_kitten_cmdline() does not prove that the terminal is local.
What Changed
I kept the local open actions and added a remote router ahead of them. Plain click opens the appropriate Neovim. Ctrl+Shift-click opens Kitty’s native remote-file menu for remote links.
Hazel Duvall’s click-to-open workflow covers the same local building blocks: hyperlinked terminal output, Kitty open actions, and an existing Neovim editor. My version uses Neovim RPC sockets and adds SSH-session routing.
Emit file hyperlinks
eza, ripgrep, and fd can emit OSC 8 hyperlinks:
eza --hyperlink=auto .
rg --hyperlink-format=default -n 'pattern' .
fd --hyperlink=auto .
I use TTY-aware shell wrappers so those flags only apply when stdout is a terminal. Pipes and redirected output stay plain text. The explicit =auto matters: a bare eza hyperlink flag can consume the next argument.
Keep local files on the local path
Kitty open actions match file URLs and launch my helper. These are the local rules:
# ~/.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 launch option passes KITTY_PID to the background helper. Without it, the helper could not reliably identify the originating Kitty process.
The helper probes the appropriate RPC socket, then uses nvim --server ... --remote-tab-silent to open a tab. For a line jump, it uses --remote-expr and fnameescape() instead. I had observed Neovim 0.12.2 treating +42 as a filename when passed through --remote-tab-silent; the wrapper keeps the explicit expression path.
When no local editor is listening, my helper uses Kitty remote control to launch a visible Neovim tab. My Kitty configuration enables allow_remote_control yes and listen_on unix:/tmp/kitty; the helper connects through KITTY_LISTEN_ON. Without a remote-control endpoint, the helper logs no Kitty remote-control endpoint and exits with status 1. Running Neovim directly from a background open action would leave an editor without an interactive terminal.
Explicit local hostname aliases need normalization too. Kitty compares hostnames literally: mamini differs from mamini.local. My router recognizes both, plus localhost and 127.0.0.1, and removes the hostname before handing the URL to local open actions.
Separate plain click from the remote menu
The current mouse bindings are:
# ~/.config/kitty/kitty.conf
allow_hyperlinks yes
underline_hyperlinks hover
show_hyperlink_targets ctrl
mouse_map left click ungrabbed kitten open_link.py link selection prompt
mouse_map ctrl+shift+left release grabbed,ungrabbed kitten open_link.py --menu link
open_link.py is my custom kitten, not a built-in Kitty command. It installs a URL handler for the duration of that click and restores the previous handler in finally. Putting link before selection also prevents an old selection from blocking the hyperlink.
Menu mode still needs connection discovery. Simply bypassing my router left Kitty unable to find SSH behind zmx attach. I now pass the resolved connection to Kitty’s native remote_file kitten, which displays its Edit, Open, Save, and Cancel choices. That path does not invoke my Neovim helper.
Give remote editors a session-scoped socket
Local Neovim uses kitty-<kitty-pid>.sock. Remote Neovim uses kitty-<kitty-pid>-<originating-window-id>.sock, under its own cache directory. The window ID separates editors belonging to independent SSH windows inside one Kitty process.
The SSH kitten forwards the originating IDs through this configuration:
# ~/.config/kitty/ssh.conf
env KITTY_PID=_kitty_copy_env_var_
env KITTY_WINDOW_ID=_kitty_copy_env_var_
My init.lua registers the corresponding socket before loading plugins. A visible fallback editor receives NVIM_KITTY_SESSION explicitly, so it retains the original window’s identity even though it runs in a new tab.
The registration code checks whether Neovim already owns the socket before probing it. I wrote about the self-probe deadlock separately. Both registration and the helper bound their liveness probes rather than waiting indefinitely for a frozen editor.
The complete implementation is in my dotfiles: socket registration, local/remote helper, click router, and SSH probe kitten. The helper and Neovim configuration must exist on both machines; the graphical Kitty application only runs on the Mac.
Discover the connection without assuming a foreground SSH kitten
I first reuse a visible SSH process’s connection options, including its identity, port, and ControlPath. If that process is hidden, the router can use the URL hostname, such as file://sca/home/fran/file.py, or the remote working-directory report.
The working-directory report lives on window.screen.last_reported_cwd. On Kitty 0.49.2 it arrives as bytes, for example kitty-shell-cwd://sca/home/fran/.dotfiles. The child’s OS-level CWD was /Users/fran/.dotfiles: it described the local zmx attach process. Reading that field alone lost the remote host.
A hostless file:///home/fran/file.py therefore needs remote window context. A URL with an explicit local hostname stays local. For a hostless link, the router shows an error when a plain SSH process is visible in Kitty’s foreground process list and neither SSH-kitten tracking nor remote CWD context is available.
A fresh ssh sca also failed authentication during my live check. Kitty’s existing OpenSSH ControlMaster worked. The fallback now uses this Kitty process’s native control-socket template, preserving its short runtime-directory path on macOS. If the destination has no usable master, SSH still needs to authenticate normally.
Once connected, ssh_edit.py probes only the named session socket. It does not scan for another editor. Exit status 3 from my helper means no matching editor is available, so the kitten opens a visible SSH tab. Authentication failures remain errors. The probe uses -T; the visible editor uses -tt.
Test the loader that actually runs the code
I initially tested the router with Python’s normal module loader. Those tests passed while the live click handler still failed.
Kitty 0.49.2’s custom-kitten loader executes the script without defining __file__. My Path(__file__) lookup for ssh_edit.py raised NameError; Kitty’s URL handling could then fall through to the local path.
I replaced that lookup with the relative kitten name ssh_edit.py, which Kitty resolves from its configuration directory. The tests now load scripts through Kitty’s import_kitten_main_module, so they reproduce that execution environment.
Results
The 19 Kitty/Neovim tests pass, including remote-session isolation, line jumps, ControlMaster reuse, local hostname aliases, and native-menu routing through a zmx window. The shell-integration check and Python lint also pass.
I exercised the real Kitty click handler programmatically against live windows and SSH. The remote editor reported host sca, the requested file, and line 2; a later request reused it. The local editor reported mamini.local. Opening the native menu displayed its remote-file choices without changing the editor. These checks exercised routing, not automated physical mouse events.
For a manual check, run this in a remote terminal after installing the setup:
printf 'first line\nsecond line\n' > /tmp/kitty-click-test.txt
printf '\033]8;;file://%s/tmp/kitty-click-test.txt#L2\033\\click-test:2\033]8;;\033\\\n' "$(hostname)"
Plain-click should open the remote file at line 2. Ctrl+Shift-click should show Kitty’s native menu. A local file link in a local window should still open local Neovim.
Limits
This is a window-scoped editor route, not a general remote filesystem. Local editors still share one socket per Kitty process. Remote sockets add the originating window ID, but reattached terminal sessions can retain stale environment IDs. The first editor owning a session socket receives its clicks; opening a tab does not focus a different terminal pane.
The zmx fallback does not solve the SSH kitten’s multi-client handshake problem. I verified a single attached client. Connection bootstrap, reattachment, and hyperlink routing are separate concerns.
Warning
If zmx hides SSH and a file link has neither an explicit hostname nor remote CWD context, my router returns False. Kitty can then apply local open actions to the remote path. I have no fail-closed guard for this case.
The fallback can derive an SSH destination from terminal metadata. I only click remote file links from trusted output. Nested SSH sessions, missing host aliases, and connections with custom options hidden behind a persistent session need separate verification.
I also rely on Kitty’s internal Python API and control-socket naming. The custom-kitten documentation explicitly warns that those APIs are not stable. Testing through the real loader catches one class of mismatch; it does not guarantee compatibility with future Kitty versions.
References
- Kitty. terminal running the local click handlers.
- Kitty open actions. file URL rules and action variables.
- Kitty launch. environment copying and visible tabs.
- Kitty mouse actions. click bindings and selection ordering.
- Kitty SSH kitten. environment forwarding and connection sharing.
- Kitty remote-file kitten. native download/edit menu.
- Kitty custom kittens. result handlers and internal-API caveats.
- Kitty 0.49.2 custom-kitten loader. execution globals that omit
__file__. - Kitty 0.49.2 window handling. URL callbacks and native remote-file dispatch.
- Kitty 0.49.2 connection sharing. ControlMaster options and short macOS runtime path.
- OpenSSH ControlMaster. connection multiplexing and ControlPath.
- Neovim remote API. RPC socket registration and native remote commands.
- zmx. persistent terminal sessions.
- eza, ripgrep, and fd. terminal hyperlink producers.
- Hazel Duvall’s click-to-open workflow. prior art for the local editor workflow.
- Dotfiles implementation. deployment notes and the linked router, helper, registration, and SSH probe files.
- Kitty configuration. remote-control settings required for the local fallback.
- TTY-aware hyperlink wrappers and eza’s optional-value flag. the shell-output side of this setup.
- Neovim self-probe deadlock. socket ownership and bounded probes.
- SSH kitten inside multi-client zmx sessions. a separate bootstrap limitation.
This post was written with AI assistance.