Skip to content

Instantly share code, notes, and snippets.

@Problematic
Created July 21, 2026 22:39
Show Gist options
  • Select an option

  • Save Problematic/e1c36d2a9956db3c89d783f793c91a8c to your computer and use it in GitHub Desktop.

Select an option

Save Problematic/e1c36d2a9956db3c89d783f793c91a8c to your computer and use it in GitHub Desktop.
Double-click a path in tmux to open it in the adjacent Neovim pane (per-pane RPC socket + click-word cleanup)

Double-click a path in tmux → open it in the adjacent Neovim pane

Double-click any path in a tmux pane (a git status line, an rg hit, a stack-trace frame, a ToolName(path) tool-call line, …) and it opens in the Neovim instance running in another pane of the same window, jumping to the right line/col when the token carries a :line[:col] suffix.

It works by giving each nvim a per-pane RPC socket, then having a tmux double-click binding hand the clicked word to a small script that cleans it up and sends :edit to that socket.

Pieces

File Goes where Purpose
nvim-open ~/.local/bin/nvim-open (chmod +x) Cleans the clicked token and forwards it to the pane's nvim via --remote-send.
tmux.conf.snippet append to your tmux config word-separators + DoubleClick1Pane binding.
nvim-serverstart.lua your nvim config (e.g. lua/config/options.lua) Starts the per-pane socket nvim-open connects to.

Install

# 1. the script
install -Dm755 nvim-open ~/.local/bin/nvim-open

# 2. tmux: append the snippet, then reload
cat tmux.conf.snippet >> ~/.tmux.conf   # or ~/.config/tmux/tmux.conf.local
tmux source ~/.tmux.conf

# 3. nvim: paste nvim-serverstart.lua into your config, restart nvim

Requires bash, tmux, and a reasonably recent nvim (RPC --server / --remote-send). ~/.local/bin must be on your PATH.

How it resolves the clicked word

word-separators is set to whitespace only, so a double-click captures the whole token (kebab-case, scoped pkgs, and the :line:col suffix stay intact). nvim-open then:

  1. Strips enclosing punctuation — leading ([{<"' ` and trailing )]},;>."' `. A leading . / ~ / / is preserved (.env, ~/x, ./foo still work).
  2. Splits a trailing :line or :line:col suffix (rg/grep/gcc/stack-trace style) to position the cursor.
  3. Unwraps a wrapper(path) token — a click on Update(~/.config/foo) or a Java frame Foo.bar(File.java:42) targets the inner path. Real paths that contain ( still win: the plain token is tried first, and only a miss falls through to the unwrapped candidate.
  4. Resolves relative paths against the clicked pane's cwd (NVIM_OPEN_CWD), not nvim's.
  5. With NVIM_OPEN_REQUIRE_EXISTING=1 (set by the tmux binding) a click is a no-op unless the word resolves to a real file/dir, so double-clicking ordinary words does nothing. Run nvim-open <path> by hand (flag unset) and it'll happily open a not-yet-created file.

It finds an nvim pane with a live socket in the current window only (never pulls you across windows/sessions), sends <C-\><C-n>:silent! edit …, then focuses that pane. If no reusable nvim exists in the window, it just opens nvim in place.

Env vars

Var Set by Effect
NVIM_OPEN_CWD tmux binding Base dir for relative paths (the clicked pane's cwd).
NVIM_OPEN_REQUIRE_EXISTING tmux binding Only act on words that resolve to a real path.
NVIM_OPEN_FOCUS_DELAY optional Seconds before re-asserting focus on the nvim pane (default 0.25).
#!/usr/bin/env bash
# Open a (possibly relative) path in the nvim running in an adjacent tmux pane.
set -euo pipefail
file="${1:-}"
# Base directory for resolving relative paths: the *caller's* cwd -- i.e. the
# shell the click (or command) came from, not nvim's. A tmux mouse binding
# can't set this process's own cwd to the clicked pane, so the binding exports
# NVIM_OPEN_CWD='#{pane_current_path}'. Typed interactively, NVIM_OPEN_CWD is
# unset and $PWD is already the shell's cwd.
base="${NVIM_OPEN_CWD:-$PWD}"
# Strip enclosing punctuation that clings to paths in prose / logs / stack
# traces but is never part of the name. Leading openers ("(src/foo.ts)" -> drop
# "(") and trailing closers/terminators ("at foo.ts:42:10)." -> drop ")" ".").
# A leading "." / "~" / "/" is meaningful (./foo, ~/x, .env) so it's preserved.
# Sets TRIMMED. Called up front on the whole token, and again inside
# resolve_token on the path portion once a :line:col suffix is split off (so an
# unwrapped "X(path):1:2" -> "path)" has its dangling ")" removed too).
trim() {
local s="$1" leading="[([{<\"\`']" trailing="[]),;}>.\"\`']"
while [[ "$s" == $leading* ]]; do s="${s#?}"; done
while [[ "$s" == *$trailing ]]; do s="${s%?}"; done
TRIMMED="$s"
}
trim "$file"; file="$TRIMMED"
# Resolve one raw token to R_FILE / R_LINE / R_COL:
# - split a trailing :line or :line:col suffix (grep/rg/gcc/stack-trace style)
# off the path so we can position the cursor. A trailing ":" (rg's
# "path:line:") is tolerated; a non-numeric suffix (a real "foo:bar.ts") is
# left untouched.
# - re-trim the path portion (an unwrapped candidate can now end in a closer).
# - expand a leading ~ / ~/ (it arrives literal inside a variable, and the
# base branch would otherwise turn "~/x" into "$base/~/x"). Do NOT eval: the
# value is untrusted click text. (~user is left alone; rare in this workflow.)
# - resolve relative paths against the caller's cwd (the clicked shell).
resolve_token() {
local f="$1"
R_LINE="" R_COL=""
if [[ "$f" =~ ^(.+):([0-9]+):([0-9]+):?$ ]]; then
f="${BASH_REMATCH[1]}"; R_LINE="${BASH_REMATCH[2]}"; R_COL="${BASH_REMATCH[3]}"
elif [[ "$f" =~ ^(.+):([0-9]+):?$ ]]; then
f="${BASH_REMATCH[1]}"; R_LINE="${BASH_REMATCH[2]}"
fi
trim "$f"; f="$TRIMMED"
case "$f" in
"~") f="$HOME" ;;
"~/"*) f="$HOME/${f#"~/"}" ;;
esac
[[ "$f" = /* ]] || f="$base/$f"
R_FILE="$f"
}
# Candidate tokens, most-specific first. Beyond the plain trimmed word we also
# try the text after a leading "wrapper(" -- so a click on a tool-call / log
# line rendered as "Update(~/.local/bin/nvim-open)" targets the inner path.
# Real paths that themselves contain "(" still win: the plain token is tried
# first and, under REQUIRE_EXISTING, only a miss falls through to the unwrapped
# candidate.
cands=("$file")
[[ "$file" == *"("* ]] && cands+=("${file##*(}")
# From a mouse click (NVIM_OPEN_REQUIRE_EXISTING=1, set by the tmux binding) we
# only act on words that actually resolve to a real file/dir -- double-clicking
# an ordinary word shouldn't open an empty buffer. Typed interactively the flag
# is unset, so the first candidate is taken as-is (may be a not-yet-created file).
file="" line="" col=""
for c in "${cands[@]}"; do
resolve_token "$c"
file="$R_FILE" line="$R_LINE" col="$R_COL"
[[ -z "${NVIM_OPEN_REQUIRE_EXISTING:-}" || -e "$file" ]] && break
done
if [[ -n "${NVIM_OPEN_REQUIRE_EXISTING:-}" && ! -e "$file" ]]; then
exit 0
fi
# Find an nvim pane WITH A LIVE SOCKET in the CURRENT WINDOW only.
# (Older nvim instances predate the serverstart snippet and have no socket, so
# we validate rather than take the first. Scoped to the current window so we
# never pull you across windows or sessions -- just between adjacent panes.)
pane="" sock=""
while IFS= read -r p; do
cand="/tmp/nvim-${p#%}.sock"
if [[ -S "$cand" ]]; then
pane="$p" sock="$cand"
break
fi
done < <(
tmux list-panes -F '#{pane_id} #{pane_current_command}' \
| awk -F'\t' '$2=="nvim"{print $1}'
)
# No reusable nvim in this window: open nvim here, at the parsed line/col.
if [[ -z "$pane" ]]; then
if [[ -n "$line" ]]; then
exec nvim "+call cursor(${line}, ${col:-1})" -- "$file"
fi
exec nvim -- "$file"
fi
# A bare --remote is swallowed if nvim is in insert/terminal mode, and some
# ftplugins (e.g. markdown) throw during BufRead and abort a plain :edit.
# So escape to normal mode first and use `silent! edit`, then jump to line/col.
esc="${file// /\\ }" # escape spaces for the : command line
keys="<C-\\><C-n>:silent! edit ${esc}<CR>"
if [[ -n "$line" ]]; then
keys+=":call cursor(${line}, ${col:-1})<CR>"
fi
nvim --server "$sock" --remote-send "$keys"
# Focus the nvim pane (current window only -- never cross windows/sessions).
# An immediate select-pane gets reverted: as the invoking interactive shell
# returns to its prompt, tmux (with focus-events on) reasserts focus on the
# pane you typed in, overwriting our switch -- so #{pane_active} briefly reads
# nvim, then keystrokes still go to the original pane. Re-issue the switch
# deferred + backgrounded so it lands AFTER the shell settles and wins.
tmux select-pane -t "$pane"
tmux run-shell -b -d "${NVIM_OPEN_FOCUS_DELAY:-0.25}" "tmux select-pane -t '$pane'" 2>/dev/null || true
-- Put this in your Neovim config (e.g. lua/config/options.lua for LazyVim).
-- It starts a per-tmux-pane RPC socket that `nvim-open` connects to via
-- `nvim --server <sock> --remote-send`. One socket per pane so clicks in one
-- pane target the nvim running beside them; falls back to PID outside tmux.
-- The `if vim.env.NVIM == nil` guard avoids starting a server inside nested
-- `:terminal` nvim instances.
if vim.env.NVIM == nil then
local id = vim.env.TMUX_PANE or tostring(vim.fn.getpid())
local sock = ("/tmp/nvim-%s.sock"):format(id:gsub("%%", ""))
pcall(vim.fn.serverstart, sock)
end
# --- open a clicked path in the adjacent nvim pane ---------------------------
# double-click a path to open it in the adjacent nvim pane (via ~/.local/bin/nvim-open)
# word-separators: default is " -_@", which splits kebab-case / snake_case
# names, scoped pkgs, AND the ":line:col" suffix. Use whitespace only so the
# whole "src/foo-bar.ts:42:10" token is captured; nvim-open parses off the
# trailing :line[:col] and positions the cursor.
# NVIM_OPEN_CWD passes the *clicked* pane's cwd so relative paths resolve
# against the shell you clicked in, not nvim's cwd.
# NVIM_OPEN_REQUIRE_EXISTING makes a click a no-op unless the word resolves to
# a real file/dir, so double-clicking ordinary words does nothing.
set -g word-separators " "
bind -n DoubleClick1Pane run-shell "NVIM_OPEN_CWD='#{pane_current_path}' NVIM_OPEN_REQUIRE_EXISTING=1 $HOME/.local/bin/nvim-open '#{mouse_word}'"
# mouse must be on for the double-click binding to fire
set -g mouse on
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment