Annotate diffs in a floating Zellij revdiff pane; each flush is relayed into your Claude Code session

A Claude Code plugin that wires revdiff into a floating Zellij pane and pipes your diff annotations straight into your Claude Code session.
/revdiff opens the revdiff TUI over the current diff and gets out of the way. Annotate, press O, and the annotations appear in your session as a message — Claude answers questions in conversation and implements change requests. Press R to reload the diff and see what changed, annotate again, flush again. There is no round structure and no fixed number of passes: the pane is yours for as long as you want it, and each flush is just a message.
Claude doesn't wait on the pane or poll it, and doesn't spend a turn until a flush arrives.
revdiff on PATH, with --post-flush-command and the flush_output action (O)/plugin marketplace add <git-url-or-owner/repo-or-local-path>
/plugin install revdiff-relay@revdiff-relay
/revdiff auto-detect: working-copy changes, last commit, or branch vs main
/revdiff main review against a branch
/revdiff @- any ref syntax revdiff understands (git, jj, and hg supported)
/revdiff --staged staged changes only
Arguments are split on whitespace and passed through to revdiff.
In the pane:
| Key | Effect |
|---|---|
O | Flush annotations to Claude without closing the pane |
R | Reload the diff from the VCS, picking up Claude's edits |
i | Info popup |
O and R are revdiff's own keys and are rebindable in its config.
REVDIFF_POPUP_WIDTH / REVDIFF_POPUP_HEIGHT — floating pane size (default 90%)~/.config/revdiff/config, which applies to manual runs tooThe plugin is a mod: a hooks module, hooks/register.ts, that Claude Code loads alongside the session. A flush travels revdiff → scripts/flush.sh → FIFO → the hooks module → your session.
/revdiff and starts a task that sleeps until there's a review to read./revdiff points revdiff's --output at /tmp/revdiff-<session-id>/annotations, creates a FIFO beside it, hands the FIFO to that task and opens the floating pane. The pane is named after the session's title, or the directory and the start of the session ID when there's no title, so you can tell which conversation it belongs to.flush.sh after each O, in the Zellij server's environment. It exits silently on an empty annotation set; otherwise it writes the set plus a NUL terminator to the FIFO, bounded by a timeout so a dead reader can't freeze the TUI.cat on the FIFO as a child of Claude Code, reads one annotation set per flush and submits it prefixed Annotations from revdiff:. It's submitted as your own words, so Claude reads it without the frame Claude Code puts around plugin messages; the transcript still records that it came from the plugin. A flush that lands while Claude is busy waits for the session to go idle and then starts a turn of its own, in order. One reader runs per session: a new /revdiff replaces it, a plugin reload picks the open FIFO back up, and Claude Code ends it with the session.classic.* hook events are skipped for user-installed plugins wherever an organization manages Claude Code, so the plugin reads the session title from them but doesn't depend on them; under managed settings the pane gets the fallback name./clear, which starts a new session, run /revdiff again.R drops the annotations on lines the reload changed and keeps the others, so a comment that comes back around is one you left standing on purpose. The plugin does no diffing and keeps no snapshot, which is also why git, jj, and hg all behave identically.claude plugin test . runs hooks/register.test.ts. claude plugin validate .claude-plugin/plugin.json reports what the module hooks and calls. Run Claude Code with --plugin-dir . to load the plugin from the checkout; saving the module reloads it. Loading also writes the API's type declarations into .claude-plugin/types/ and a tsconfig.json that extends them, both ignored, so after the first load tsc -p . (in the dev shell) type-checks hooks/.
revdiff reports a post-flush-command failure on its own stdout, not in the TUI, and the pane runs with --close-on-exit, so that output dies with the pane. So flush.sh logs each flush and hand-off to flush.log beside the annotations, in /tmp/revdiff-<session-id>/. The reader logs to Claude Code's debug log (claude --debug, written to ~/.claude/debug/<session-id>.txt) under the plugin's name, as does a hook that Claude Code skips or refuses. If flushes seem to vanish, read those first.
hooks/register.ts 183 lines1import type { EngineInterface, HookStream, ProcessSpawnChunk, ProcessSpawnResult, Register } from 'claude-code'
2
3const COMMAND = 'revdiff'
4const LEAD_IN = 'Annotations from revdiff:'
5
6const GUIDANCE = `The user opened revdiff over the current diff in a floating Zellij pane. Each time they press O there, their annotations arrive in this session as a message beginning "${LEAD_IN}", on their own schedule: possibly interleaved with unrelated work, possibly never. Treat each one as the user handing you review comments: answer questions in conversation and make the changes they ask for. Don't wait on or poll for annotations.
7
8When the user reloads the diff, revdiff clears annotations on lines the reload changed and keeps the rest, so an annotation they send again is deliberately still standing.
9
10Annotations are \`## path/to/file:43 (+)\` headers followed by the comment text, where (+) is an added line, (-) a removed line, ( ) an unchanged context line, and no line number means a file-level comment.`
11
12// Prompts can't be submitted from command.run; the reader runs from session.start.
13// https://code.claude.com/docs/en/plugins/mods/api#start-a-turn-from-a-background-job
14let isStarted = false
15let pendingFifo: string | undefined
16let wakeLoop: (() => void) | undefined
17let reader: HookStream<ProcessSpawnChunk, ProcessSpawnResult> | undefined
18let sessionTitle: string | undefined
19
20async function nextFifo(): Promise<string> {
21 while (pendingFifo === undefined) await new Promise<void>(resolve => (wakeLoop = resolve))
22 const fifo = pendingFifo
23 pendingFifo = undefined
24 return fifo
25}
26
27function stopReader() {
28 pendingFifo = undefined
29 const stopping = reader
30 reader = undefined
31 void stopping?.return({ code: null, signal: 'SIGTERM' })
32}
33
34function requestReader(fifo: string) {
35 stopReader()
36 pendingFifo = fifo
37 wakeLoop?.()
38}
39
40function statePaths(sessionId: string) {
41 const dir = `/tmp/revdiff-${sessionId}`
42 return { dir, annotations: `${dir}/annotations`, fifo: `${dir}/flush.pipe` }
43}
44
45const shellQuote = (s: string) => `'${s.replaceAll("'", `'\\''`)}'`
46
47async function findOnPath($: EngineInterface, name: string) {
48 for (const dir of ((await $.env.get('PATH')) ?? '').split(':')) {
49 if (!dir) continue
50 const path = `${dir}/${name}`
51 const stat = await $.fs.stat(path).catch(() => undefined)
52 if (stat?.kind === 'file') return path
53 }
54 return undefined
55}
56
57function startReader($: EngineInterface, fifo: string) {
58 // Opened read-write so cat never sees EOF between flushes.
59 const child = $.process.spawn({ argv: ['sh', '-c', 'exec cat <>"$1"', 'sh', fifo] })
60 reader = child
61
62 void (async () => {
63 let pending = ''
64 try {
65 for await (const { stream, text } of child) {
66 // return() doesn't cancel a pending read.
67 if (reader !== child) break
68 if (stream === 'stderr') {
69 $.ui.log(`reader: ${text.trim()}`, { to: 'debug' })
70 continue
71 }
72 pending += text
73 for (let end = pending.indexOf('\0'); end !== -1; end = pending.indexOf('\0')) {
74 const record = pending.slice(0, end)
75 pending = pending.slice(end + 1)
76 if (record.trim()) await $.prompt.submit({ text: `${LEAD_IN}\n\n${record}`, asUser: true })
77 }
78 }
79 } catch (err) {
80 $.ui.log(`reader stopped: ${String(err)}`, { to: 'debug' })
81 }
82 if (reader === child) reader = undefined
83 })()
84}
85
86export const register: Register = on => {
87 on('session.start', async ($, e, next) => {
88 if (!e.isInteractive) return next(e)
89 await $.command.register({
90 name: COMMAND,
91 description: 'Annotate the current diff in a floating revdiff pane',
92 argumentHint: '[ref] [ref2] [--staged] [--only=<file>]',
93 })
94 const started = await next(e)
95 isStarted = true
96 void (async () => {
97 for (;;) startReader($, await nextFifo())
98 })()
99
100 // A reload kills the reader but not the pane; resume an existing FIFO.
101 const { fifo } = statePaths(await $.session.id())
102 const stat = await $.fs.stat(fifo).catch(() => undefined)
103 if (stat?.kind === 'other') requestReader(fifo)
104
105 return started
106 })
107
108 // A pane belongs to its session; /clear and /resume end it without a reload.
109 on('session.end', async (_$, e, next) => {
110 stopReader()
111 sessionTitle = undefined
112 return next(e)
113 })
114
115 // Title only: classic.* hooks are skipped under managed settings.
116 // https://code.claude.com/docs/en/plugins/mods/admin#know-what-happens-by-default
117 on('classic.SessionStart', async (_$, e, next) => {
118 sessionTitle = e.session_title
119 return next(e)
120 })
121
122 on('classic.UserPromptSubmit', async (_$, e, next) => {
123 if (e.session_title) sessionTitle = e.session_title
124 return next(e)
125 })
126
127 on('command.run', { command: COMMAND }, async ($, e) => {
128 if (!isStarted) return { text: 'The session has not started yet.' }
129 if (!(await $.env.get('ZELLIJ'))) return { text: 'Not inside a Zellij session.' }
130
131 // The pane runs in the Zellij server's environment, whose PATH may differ.
132 const revdiff = await findOnPath($, 'revdiff')
133 if (!revdiff) return { text: 'revdiff not found on PATH.' }
134
135 const sessionId = await $.session.id()
136 const { dir, annotations, fifo } = statePaths(sessionId)
137
138 stopReader()
139 await $.process.run(['mkdir', '-p', dir])
140 await $.process.run(['rm', '-f', annotations, fifo])
141 await $.process.run(['mkfifo', fifo])
142 requestReader(fifo)
143
144 const flush = `${$.plugin.root}/scripts/flush.sh`
145 const postFlush = `${shellQuote(flush)} --annotations ${shellQuote(annotations)} --fifo ${shellQuote(fifo)}`
146 let cmd = [
147 revdiff,
148 `--output=${annotations}`,
149 `--post-flush-command=${postFlush}`,
150 ...e.args.split(/\s+/).filter(Boolean),
151 ]
152
153 const editors: string[] = []
154 const editor = await $.env.get('EDITOR')
155 const visual = await $.env.get('VISUAL')
156 if (editor) editors.push(`EDITOR=${editor}`)
157 if (visual) editors.push(`VISUAL=${visual}`)
158 if (editors.length > 0) cmd = ['/usr/bin/env', ...editors, ...cmd]
159
160 const cwd = await $.session.cwd()
161 const owner = sessionTitle
162 ? sessionTitle.length > 60 ? `${sessionTitle.slice(0, 59)}…` : sessionTitle
163 : `${cwd.split('/').pop()} · ${sessionId.slice(0, 8)}`
164 const opened = await $.process.run([
165 'zellij', 'run', '--floating', '--close-on-exit',
166 '--width', (await $.env.get('REVDIFF_POPUP_WIDTH')) ?? '90%',
167 '--height', (await $.env.get('REVDIFF_POPUP_HEIGHT')) ?? '90%',
168 '--x', '5%', '--y', '5%',
169 '--name', `revdiff: ${owner}`, '--cwd', cwd,
170 '--', ...cmd,
171 ])
172 if (opened.exitCode !== 0) {
173 stopReader()
174 return { text: `zellij could not open the pane: ${opened.stderr.trim()}` }
175 }
176
177 return {
178 text: 'revdiff is open. Press O in the pane to send annotations here, R to reload the diff.',
179 context: [GUIDANCE],
180 }
181 })
182}
183