/nv opens a file in nvim in a herdr split beside Claude; /yank opens Claude's last reply there

.config/i3
.config/nvim
Figuring out what works for me
Used ideas from people like ThePrimeagen and others.
Things needed -nerd fonts -zsh -oh-my-zsh
-zoxide -fzf -tmux -tpm -tmux session wizard (should probably roll my own)
hooks/register.ts 106 lines1import type { CommandRunInput, EngineInterface, Register } from 'claude-code'
2
3import { launchLine, parseTarget, pickReply, privateDir, remoteOpenExpr, sockPath, type Target } from './lib'
4
5// Only the person at this Mac may open panes on it: a phone (bridge) or
6// another agent running /nv would split a terminal nobody is looking at.
7const LOCAL_ORIGINS = new Set(['composer', 'unclassified'])
8
9const refusal = (e: CommandRunInput): string | undefined =>
10 LOCAL_ORIGINS.has(e.origin.kind)
11 ? undefined
12 : `/${e.command} only runs from the Mac terminal (this came from ${e.origin.kind}).`
13
14const run = async ($: EngineInterface, argv: readonly string[]) => {
15 try {
16 return await $.process.run(argv)
17 } catch (err) {
18 return { exitCode: -1, stdout: '', stderr: String(err) }
19 }
20}
21
22// `pane split` answers the new pane first; `pane neighbor` answers the whole
23// layout, so its neighbor sits under its own key.
24const tempDir = async ($: EngineInterface) =>
25 privateDir(await $.env.get('TMPDIR'), (await $.env.get('HOME')) ?? '')
26
27const paneIdOf = (json: string): string | undefined => /"pane_id":"([^"]+)"/.exec(json)?.[1]
28const neighborIdOf = (json: string): string | undefined => /"neighbor_pane_id":"([^"]+)"/.exec(json)?.[1]
29
30/**
31 * Opens `target` (or just an empty editor) in this Claude pane's nvim,
32 * starting one in a new herdr split to the right when none is listening.
33 * Answers the line the command shows.
34 */
35const openInNvim = async ($: EngineInterface, target: Target | undefined, label: string) => {
36 const claudePane = await $.env.get('HERDR_PANE_ID')
37 if (!claudePane) return 'Not running inside a herdr pane, so there is nowhere to open nvim.'
38 const sock = sockPath(await tempDir($), claudePane)
39
40 const alive = await run($, ['nvim', '--server', sock, '--remote-expr', '1'])
41 if (alive.exitCode === 0) {
42 if (target) {
43 const opened = await run($, ['nvim', '--server', sock, '--remote-expr', remoteOpenExpr(target)])
44 if (opened.exitCode !== 0) return `nvim refused to open ${label}: ${opened.stderr.trim()}`
45 }
46 // Focus it only when it is the pane right of Claude, the one /nv made.
47 const nvimPane = (await run($, ['nvim', '--server', sock, '--remote-expr', '$HERDR_PANE_ID'])).stdout.trim()
48 const right = neighborIdOf((await run($, ['herdr', 'pane', 'neighbor', '--pane', claudePane, '--direction', 'right'])).stdout)
49 if (nvimPane && nvimPane === right) {
50 await run($, ['herdr', 'pane', 'focus', '--pane', claudePane, '--direction', 'right'])
51 }
52 return `Opened ${label} in nvim.`
53 }
54
55 // No nvim answers: clear a socket a dead nvim left behind, or --listen fails.
56 await run($, ['rm', '-f', sock])
57 await run($, ['mkdir', '-p', '-m', '700', sock.slice(0, sock.lastIndexOf('/'))])
58 const cwd = await $.session.cwd()
59 const split = await run($, ['herdr', 'pane', 'split', claudePane, '--direction', 'right', '--focus', '--cwd', cwd])
60 const nvimPane = paneIdOf(split.stdout)
61 if (split.exitCode !== 0 || !nvimPane) return `herdr could not split the pane: ${split.stderr.trim() || split.stdout.trim()}`
62 // `pane run` types into the pane's shell; the split is brand new, so that
63 // shell is idle, and launchLine quotes every argument.
64 const started = await run($, ['herdr', 'pane', 'run', nvimPane, launchLine(sock, target)])
65 if (started.exitCode !== 0) return `herdr could not start nvim: ${started.stderr.trim()}`
66 return `Opened ${label} in nvim in a new split.`
67}
68
69export const register: Register = on => {
70 on('session.start', async ($, e, next) => {
71 await $.command.register({
72 name: 'nv',
73 description: 'Open a file in nvim in a herdr split beside Claude (/nv path[:line])',
74 })
75 await $.command.register({
76 name: 'yank',
77 description: "Open Claude's last reply in the nvim split to copy from it (/yank [n] for n replies back)",
78 })
79 return next(e)
80 })
81
82 on('command.run', { command: 'nv' }, async ($, e) => {
83 const refused = refusal(e)
84 if (refused) return { text: refused }
85 const home = (await $.env.get('HOME')) ?? ''
86 const target = parseTarget(e.args, await $.session.cwd(), home)
87 return { text: await openInNvim($, target, target ? target.path : 'nvim') }
88 })
89
90 on('command.run', { command: 'yank' }, async ($, e) => {
91 const refused = refusal(e)
92 if (refused) return { text: refused }
93 const arg = e.args.trim()
94 if (arg !== '' && !/^\d+$/.test(arg)) return { text: 'Usage: /yank [n], where n counts replies back (1 is the last).' }
95 const back = arg === '' ? 1 : Number(arg)
96
97 const reply = pickReply(await $.session.messages(), back)
98 if (reply === undefined) return { text: `There is no reply ${back} back to yank.` }
99
100 const path = `${await tempDir($)}/claude-yank/${await $.clock.now()}.md`
101 await $.fs.write(path, reply)
102 const label = back === 1 ? 'the last reply' : `the reply ${back} back`
103 return { text: await openInNvim($, { path }, label) }
104 })
105}
106hooks/lib.ts 69 lines1// Pure helpers for /nv and /yank, kept apart from `$` so tests can call them.
2
3export type Target = { path: string; line?: number }
4
5/**
6 * The private temp folder: macOS's per-user $TMPDIR (mode 0700) rather than
7 * the shared /tmp, where another account could plant a socket or a symlink.
8 */
9export const privateDir = (tmpdir: string | undefined, home: string): string =>
10 tmpdir ? tmpdir.replace(/\/+$/, '') : `${home}/.cache/claude-nv`
11
12/** One nvim per Claude pane, so two Claude sessions never share an editor. */
13export const sockPath = (dir: string, paneId: string): string =>
14 `${dir}/claude-nv-${paneId.replace(/[^A-Za-z0-9]/g, '_')}.sock`
15
16/** POSIX single-quoting, for the one string herdr types into a fresh shell. */
17export const shellQuote = (s: string): string => `'${s.replaceAll("'", `'\\''`)}'`
18
19/** A Vim single-quoted string literal: the only escape is '' for '. */
20export const vimString = (s: string): string => `'${s.replaceAll("'", "''")}'`
21
22/**
23 * Reads `/nv` args as one path (spaces allowed), an optional `:line` suffix,
24 * and optional surrounding quotes. Relative paths resolve against `cwd`.
25 */
26export const parseTarget = (args: string, cwd: string, home: string): Target | undefined => {
27 let raw = args.trim()
28 if (raw === '' || raw.includes('\n')) return undefined
29 let line: number | undefined
30 const withLine = /^(.*):(\d+)$/.exec(raw)
31 if (withLine) {
32 raw = withLine[1] ?? ''
33 line = Number(withLine[2])
34 }
35 const quoted = /^(['"])(.*)\1$/.exec(raw)
36 if (quoted) raw = quoted[2] ?? ''
37 if (raw === '') return undefined
38 const path =
39 raw.startsWith('/') ? raw
40 : raw === '~' ? home
41 : raw.startsWith('~/') ? `${home}/${raw.slice(2)}`
42 : `${cwd}/${raw}`
43 return { path, line }
44}
45
46/** The text of the nth-newest assistant message that has any text. */
47export const pickReply = (
48 messages: readonly { role: string; text: string }[],
49 back: number,
50): string | undefined => {
51 const replies = messages.filter(m => m.role === 'assistant' && m.text.trim() !== '')
52 return replies.at(-Math.max(1, back))?.text
53}
54
55/** The Vim expression that opens a target in a running nvim, reusing its tab if open. */
56export const remoteOpenExpr = (t: Target): string => {
57 const cmds = [`'tab drop ' . fnameescape(${vimString(t.path)})`]
58 if (t.line !== undefined) cmds.push(vimString(String(t.line)))
59 return `execute([${cmds.join(', ')}])`
60}
61
62/** The shell line typed into a fresh herdr split to start a listening nvim. */
63export const launchLine = (sock: string, t: Target | undefined): string => {
64 const argv = ['nvim', '--listen', sock]
65 if (t?.line !== undefined) argv.push(`+${t.line}`)
66 if (t) argv.push('--', t.path)
67 return argv.map(shellQuote).join(' ')
68}
69