SLOPSHOPPER

nv

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

newcommandprocess
★ 8v0.1.0no licenseupdated 2026-10-06ryanthedev/dot-config/claude/mods/nv
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · nv
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /nv ⎿ nv: Not running inside a herdr pane, so there is nowhere to open nvim. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

dot-config

.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

  • powerline10k installed with zsh

-zoxide -fzf -tmux -tpm -tmux session wizard (should probably roll my own)

Source 2 files
hooks/register.ts 106 lines
1import 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}
106
hooks/lib.ts 69 lines
1// 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