A pane to work through the review threads of your PRs and the current branch's: hand some to Claude, reply, resolve

My Claude Code setup, tuned for Opus 5.5 / Sonnet 5.5.
git clone git@github.com:aguiddir/claude-config.git ~/PycharmProjects/claude-config
cd ~/PycharmProjects/claude-config
./install.sh --dry-run # see what would change
./install.sh # apply; safe to re-run
Needs claude, python3 and git. npm is optional (Playwright CLI). Skip parts with --skip-plugins, --skip-mcp, --skip-playwright.
A notification when Claude finishes, fails, asks for a permission or asks a question. In herdr it names the workspace and the agent session, and stays quiet for the pane you are looking at. Each session keeps a single notification, replaced in place, that leaves the screen after a few seconds. Linux only (D-Bus notifications, tested on GNOME); needs jq.
/plugin marketplace add aguiddir/claude-config
/plugin install notify@claude-config
Titles are in French: edit the case in plugins/notify/scripts/notify.sh to change them.
After a turn that edited files, a band above the prompt offers to replay them: press r in an empty prompt, or type /replay any time. The edits show one diff at a time above the prompt; n and p step, q closes, the mouse wheel scrolls a long diff. Only Edit and Write calls are recorded, not changes made through Bash. Needs Claude Code 2.1.287 or later (mods).
/plugin marketplace add aguiddir/claude-config
/plugin install replay@claude-config
Messages are in French: edit the strings in plugins/replay/hooks/register.tsx to change them.
For Vidal repos built on jenkins.vidal.net. A band above the prompt follows the Jenkins build of the current branch, or of its PR (PR-<n>) when the branch has no job of its own: a progress bar from Jenkins' estimated duration, the stage running, each stage's state, and a link to the build. When the build ends, a toast gives its result. Once the build's own SonarQube stage has run, the band shows the quality gate of the branch or PR with the conditions that failed, read 30 seconds after the end so that it is this build's analysis, and a toast says when it fails. A finished build stays on the band for 15 minutes after it ends or after /ci, whichever is later, so /ci shows the build it finds however old. When the build fails or its gate is red, f in an empty prompt (until the next prompt you send; after that, ctrl+x tab then f, or the band's button) puts the failure in the prompt box, to read, edit and send yourself: the failed stage, the console's error lines and the gate's broken conditions. Sending that prompt attaches the last 150 lines of the console, out of the box.
It reads the repo and branch of the directory Claude Code runs in and polls Jenkins every 10 seconds, anonymously, from the Vidal network. /ci 231, /ci #231 or /ci <PR URL> follows that PR's job whatever the branch, /ci <path> follows another repo, /ci alone goes back to the session's directory; each answers with the build it found. Needs gh (to find the PR) and, for the quality gate, the SonarQube MCP server connected in Claude Code. Needs Claude Code 2.1.287 or later.
/plugin marketplace add aguiddir/claude-config
/plugin install ci-watch@claude-config
The Jenkins host and job folder (team.software/github) are set at the top of plugins/ci-watch/hooks/jenkins.ts.
A band above the prompt lists the open PRs of the repo that have unresolved review threads, in two groups, so it works from main too: those to work on (yours, and the current branch's whoever wrote it) and those you review (asked or already reviewed): 💬 à traiter : #89 (9) · en relecture : #73 (10). c in an empty prompt (while new threads have come in and no prompt has been sent since; otherwise /pr-review, or /pr-review 89 for one PR) opens them in a pane, one thread at a time: the code it is about, as a diff, and its whole conversation, badged ↩ répondu when the PR's author has the last word and obsolète when it is about older code. In the pane:
| Key | Does |
|---|---|
n / p | next / previous thread |
t | next PR, when several have threads |
x | mark the thread, for f |
a | mark every thread, or none once they all are |
f | puts the marked threads (or the one shown) in the prompt box, to read, edit and send to Claude; once sent they are badged → Claude. When the PR is not on the current branch, the prompt tells Claude to check it out first (or use a worktree if changes are in progress). On your own PR it asks for one --fixup commit per commit corrected, then a push; on anyone else's, a local fix with no commit and no push |
r | writes a reply, posted to GitHub on Enter; Esc gives it up and keeps the pane, as the field takes every letter meanwhile |
v | resolves the thread on GitHub |
o | opens the thread in the browser |
q / Esc | closes the pane |
The keys work while the pane holds the keyboard; if it opened without it, ctrl+x tab or a click gives it. Replies and resolutions go through gh with your account, with no confirmation beyond the key. GitHub is asked every minute for the list of PRs (1 point of its 5000 an hour), and a PR's threads only when it changed, after a write to it, or every ten minutes in case someone else resolved one. Needs gh, logged in, and Claude Code 2.1.287 or later.
/plugin marketplace add aguiddir/claude-config
/plugin install pr-comments@claude-config
Reading a diff is the slowest way to understand what an agent did. /explain-diff (or "explique-moi ce que tu as changé") publishes a private web page about the session's edits, a commit range (/explain-diff A^..B), a PR (/explain-diff #42) or a module (/explain-diff src/billing): what changed in one sentence, a before/after diagram of each changed flow, the files grouped by intent, the decisions made and the options not taken, the boundaries crossed (API, schema, config, security), and the open points. It runs no check and gives no verdict: the reader judges. A change that alters no behaviour (a rename, a config value, a bump) stays in the terminal, whatever its size. The page is in French, written at about 80% of ASD-STE100. After Karpathy's post on the output formats that are fastest to understand. Needs the Artifact tool (claude.ai login).
/plugin marketplace add aguiddir/claude-config
/plugin install explain-diff@claude-config
| Path | Installed as | What it does |
|---|---|---|
claude/CLAUDE.md | ~/.claude/CLAUDE.md (symlink) | Global instructions: autonomy, long sessions, pre-review format |
claude/rules/git-commit.md | ~/.claude/rules/ (symlink) | Commit message rules: why over what, fixup commits for review changes |
claude/statusline-command.sh | ~/.claude/ (symlink) | 3-line status line: repo/git/PR, context/cost, rate limits |
claude/settings.json | merged into ~/.claude/settings.json | Auto mode, status line, Opus effort, theme |
plugins.txt | claude plugin install | Marketplaces and plugins |
plugins/notify/ | plugin notify@claude-config | Desktop notification (D-Bus) per hook event: done, error, permission, question |
plugins/replay/ | plugin replay@claude-config | /replay (or r after a turn) steps through the last turn's Edit/Write calls as diffs above the prompt (a mod) |
plugins/ci-watch/ | plugin ci-watch@claude-config | Band above the prompt following the current branch's Jenkins build stage by stage, with its Sonar quality gate (a mod) |
plugins/pr-comments/ | plugin pr-comments@claude-config | Band and pane for the PR's unresolved review threads: send some to Claude, reply, resolve (a mod) |
plugins/explain-diff/ | plugin explain-diff@claude-config | /explain-diff: a private web page to understand a change before merging it (before/after diagrams, files by intent, decisions) |
Symlinked files take effect as soon as you edit them here. settings.json is merged instead of linked because Claude Code rewrites it (/config, /model); existing keys and permission rules are kept, and the previous file is saved as .bak.<date>.
The installer also sets up:
~/.local/bin, from the release binary after a SHA-256 check, when it is not already installed. The notify plugin needs it.npm is available.hooks/register.tsx 453 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Pr, Thread } from '../types'
5import { DETAIL, LIST, MINE, REPLY, RESOLVE, REQUESTED, REVIEWED, cleanTitle, excerpt, hunkTail, reviewHeader, reviewPrompt, shortWhere, toEntries, toThreads, where } from './threads'
6
7const PANE = 'pr-review'
8const prs = atom({ plugin: 'pr-comments', key: 'prs' } as const, [])
9const branch = atom({ plugin: 'pr-comments', key: 'branch' } as const, '')
10const shownPr = atom({ plugin: 'pr-comments', key: 'shownPr' } as const, null)
11const sent = atom({ plugin: 'pr-comments', key: 'sent' } as const, [])
12const draft = atom({ plugin: 'pr-comments', key: 'draft' } as const, null)
13const at = atom({ plugin: 'pr-comments', key: 'at' } as const, 0)
14const marked = atom({ plugin: 'pr-comments', key: 'marked' } as const, [])
15const reply = atom({ plugin: 'pr-comments', key: 'reply' } as const, null)
16const busy = atom({ plugin: 'pr-comments', key: 'busy' } as const, null)
17const armed = atom({ plugin: 'pr-comments', key: 'armed' } as const, false)
18// GitHub allows 5000 GraphQL points an hour: the list costs 1, and so does
19// each changed PR's threads.
20const POLL_MS = 60_000
21// ponytail: every tenth poll asks all threads again, in case a thread
22// resolved by someone else leaves the PR's updatedAt as it was.
23const REFRESH_EVERY = 10
24
25const exec = async ($: EngineInterface, argv: string[]) =>
26 $.process.run(argv, { cwd: await $.session.cwd() }).catch(() => undefined)
27
28const run = async ($: EngineInterface, argv: string[]) => {
29 const r = await exec($, argv)
30 return r && r.exitCode === 0 ? r.stdout.trim() : undefined
31}
32
33// What gh says outside a GitHub repo, as opposed to a failed call (network,
34// auth), after which the band is kept. git's own words are translated,
35// gh's are not.
36const NO_REPO = /failed to run git|none of the git remotes/
37
38// Each PR's threads as last asked, by number, with the updatedAt they were
39// asked at; a module's own, so a reload asks them all once.
40const details = new Map<number, { updatedAt: string; threads: Thread[] }>()
41// PRs this session wrote to, asked again at the next poll; written counts
42// each PR's writes, so an ask clears the mark only if no write landed
43// while it ran (its answer may predate that write).
44const stale = new Set<number>()
45const written = new Map<number, number>()
46let polls = 0
47
48const poll = async ($: EngineInterface) => {
49 const head = (await run($, ['git', 'rev-parse', '--abbrev-ref', 'HEAD'])) ?? ''
50 const r = await exec($, [
51 'gh', 'api', 'graphql', '-F', `mine=${MINE}`, '-F', `requested=${REQUESTED}`, '-F', `reviewed=${REVIEWED}`,
52 '-F', 'owner={owner}', '-F', 'repo={repo}', '-f', `branch=${head}`, '-f', `query=${LIST}`,
53 ])
54 if (!r) return
55 if (r.exitCode !== 0) return NO_REPO.test(r.stderr) ? update($, prs, () => []) : undefined
56 let entries
57 try {
58 entries = toEntries(JSON.parse(r.stdout))
59 } catch {
60 // Not JSON: kept as it was, like a blip.
61 return
62 }
63 const isRefresh = polls++ % REFRESH_EVERY === 0
64 for (const e of entries) {
65 const known = details.get(e.number)
66 if (known && known.updatedAt === e.updatedAt && !stale.has(e.number) && !isRefresh) continue
67 const writesBefore = written.get(e.number)
68 const out = await run($, ['gh', 'api', 'graphql', '-F', 'owner={owner}', '-F', 'repo={repo}', '-F', `n=${e.number}`, '-f', `query=${DETAIL}`])
69 try {
70 const asked = out === undefined ? undefined : toThreads(JSON.parse(out))
71 // A failed ask keeps what was known, marked to be asked again next
72 // poll, a refresh's too.
73 if (asked) {
74 details.set(e.number, { updatedAt: e.updatedAt, threads: asked })
75 if (written.get(e.number) === writesBefore) stale.delete(e.number)
76 } else stale.add(e.number)
77 } catch {
78 // Not JSON: as a failed ask.
79 stale.add(e.number)
80 }
81 }
82 // A PR whose threads could not be asked keeps those on screen, as after a
83 // reload, which empties `details` but not the state: a failed ask never
84 // takes a PR off the band, nor makes its threads look new.
85 const before = await read($, prs)
86 const shown = new Map(before.map(p => [p.number, p.threads]))
87 const found: Pr[] = entries
88 .map(({ updatedAt, ...e }) => ({ ...e, threads: details.get(e.number)?.threads ?? shown.get(e.number) ?? [] }))
89 .filter(p => p.threads.length > 0)
90 const seen = new Set(before.flatMap(p => p.threads.map(t => t.id)))
91 await update($, branch, () => head)
92 await update($, prs, () => found)
93 // A thread not seen before arms `c` until the next prompt is sent, so a
94 // message starting with c is only caught right after threads come in.
95 if (found.some(p => p.threads.some(t => !seen.has(t.id)))) await update($, armed, () => true)
96}
97
98// One poll at a time; one asked for meanwhile (a write's reload) runs once
99// the current one ends, so it reads what the write did.
100let isPolling = false
101let isAskedAgain = false
102let timer: Timer | undefined
103const tick = async ($: EngineInterface) => {
104 if (isPolling) {
105 isAskedAgain = true
106 return
107 }
108 isPolling = true
109 do {
110 isAskedAgain = false
111 await poll($).catch(() => undefined)
112 } while (isAskedAgain)
113 isPolling = false
114}
115
116// The PR the pane shows: the one picked, else the first (the current
117// branch's when it has threads), as PRs come and go.
118const pane = async ($: EngineInterface): Promise<Pr | undefined> => {
119 const list = await read($, prs)
120 const picked = await read($, shownPr)
121 return list.find(p => p.number === picked) ?? list[0]
122}
123
124const threads = async ($: EngineInterface) => (await pane($))?.threads ?? []
125
126// The thread the pane shows, its index kept in range as threads resolve.
127const current = async ($: EngineInterface): Promise<Thread | undefined> => {
128 const list = await threads($)
129 return list[Math.min(await read($, at), list.length - 1)]
130}
131
132const openPane = async ($: EngineInterface) => {
133 if (!(await pane($))) return false
134 await $.ui.open({ id: PANE, title: 'Review', focus: true, closeOnEscape: true, rows: 32 })
135 return true
136}
137
138const showPr = async ($: EngineInterface, number: number) => {
139 await update($, shownPr, () => number)
140 await update($, at, () => 0)
141 await update($, marked, () => [])
142 await update($, reply, () => null)
143}
144
145// The next PR in the list, round to the first.
146const nextPr = async ($: EngineInterface) => {
147 const list = await read($, prs)
148 const p = await pane($)
149 // By number: each read of the state is a copy of its own.
150 if (list.length > 1 && p) await showPr($, list[(list.findIndex(x => x.number === p.number) + 1) % list.length]!.number)
151}
152
153const go = async ($: EngineInterface, d: number) => {
154 const last = (await threads($)).length - 1
155 await update($, reply, () => null)
156 await update($, at, n => Math.max(0, Math.min(last, n + d)))
157}
158
159const toggleMark = async ($: EngineInterface) => {
160 const t = await current($)
161 if (t) await update($, marked, ids => (ids.includes(t.id) ? ids.filter(id => id !== t.id) : [...ids, t.id]))
162}
163
164// Every thread marked, or none once they all are.
165const toggleAll = async ($: EngineInterface) => {
166 const ids = (await threads($)).map(t => t.id)
167 await update($, marked, m => (ids.every(id => m.includes(id)) ? [] : ids))
168}
169
170// The marked threads, or the one shown, as a prompt in the box to read and
171// send; they are badged sent only once that prompt is.
172const fix = async ($: EngineInterface) => {
173 const p = await pane($)
174 const ids = await read($, marked)
175 const shown = await current($)
176 const chosen = ids.length ? (p?.threads ?? []).filter(t => ids.includes(t.id)) : shown ? [shown] : []
177 if (!p || chosen.length === 0) return
178 await update($, draft, () => ({ header: reviewHeader(p, chosen.length), ids: chosen.map(t => t.id) }))
179 await update($, marked, () => [])
180 await update($, reply, () => null)
181 await $.ui.close({ id: PANE })
182 await $.prompt.fill({ text: reviewPrompt(p, chosen, await read($, branch)) })
183}
184
185const startReply = async ($: EngineInterface) => {
186 await update($, reply, () => '')
187 // The field draws autoFocus too: a refused move still leaves it usable.
188 await $.ui.focus({ requestId: PANE, key: 'reply' }).catch(() => undefined)
189}
190
191// Runs one GitHub write with the pane showing it, then reloads the threads,
192// the written PR's asked again whatever its updatedAt says. Marked once
193// the write is done, and counted, so that no ask begun before it clears
194// the mark with an answer that predates it.
195const write = async ($: EngineInterface, label: string, argv: string[], done: string) => {
196 const p = await pane($)
197 await update($, busy, () => label)
198 const out = await run($, argv)
199 await update($, busy, () => null)
200 if (out === undefined) return $.ui.toast(`✗ ${label} : échec (gh)`)
201 if (p) {
202 stale.add(p.number)
203 written.set(p.number, (written.get(p.number) ?? 0) + 1)
204 }
205 $.ui.toast(done)
206 await tick($)
207}
208
209// Enter on an empty field gives up the reply, as Esc does.
210const postReply = async ($: EngineInterface, body: string) => {
211 const t = await current($)
212 await update($, reply, () => null)
213 if (!t || !body.trim()) return
214 await write($, 'publication de la réponse', ['gh', 'api', 'graphql', '-f', `query=${REPLY}`, '-f', `id=${t.id}`, '-f', `body=${body.trim()}`], `↩ réponse publiée sur ${where(t)}`)
215}
216
217const resolve = async ($: EngineInterface) => {
218 const t = await current($)
219 if (t) await write($, 'résolution du fil', ['gh', 'api', 'graphql', '-f', `query=${RESOLVE}`, '-f', `id=${t.id}`], `✓ fil résolu : ${where(t)}`)
220}
221
222// Markdown and Code take 10000 characters, tab and newline the only controls.
223const clean = (text: string) => text.replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '').slice(0, 10_000)
224
225// `fermer` while replying gives up the reply, as Esc does; else it closes.
226// The plugin's own close skips its own ui.close hook, hence here.
227const closeOrCancel = async ($: EngineInterface) => {
228 if ((await read($, reply)) === null) return $.ui.close({ id: PANE })
229 await update($, reply, () => null)
230}
231
232const openWeb = async ($: EngineInterface) => {
233 const t = await current($)
234 if (t) await run($, ['xdg-open', t.url])
235}
236
237const count = (p: Pr) => `#${p.number} (${p.threads.length})`
238
239type TextElement = ReturnType<EngineInterface['ui']['resolve']>['Text']
240
241// `à traiter #89 (9) · en relecture #73 (10)`, an empty group left out, the
242// PR the pane shows (when given) marked.
243const prGroups = (Text: TextElement, list: readonly Pr[], shown?: number) => {
244 const group = (label: string, ps: readonly Pr[], color: string) =>
245 ps.length === 0
246 ? []
247 : [
248 <Text dimColor>{label} </Text>,
249 ...ps.map((p, k) => (
250 <Text color={color} bold={p.number === shown}>
251 {k > 0 ? ' ' : ''}
252 {p.number === shown ? '▸' : ''}
253 {count(p)}
254 </Text>
255 )),
256 ]
257 const todo = group('à traiter', list.filter(p => !p.isReview), 'yellow')
258 const review = group('en relecture', list.filter(p => p.isReview), 'cyan')
259 return (
260 <Text>
261 {todo}
262 {todo.length > 0 && review.length > 0 ? <Text dimColor> · </Text> : ''}
263 {review}
264 </Text>
265 )
266}
267
268export const register: Register = on => {
269 on('session.start', async ($, e, next) => {
270 const r = await next(e)
271 void tick($)
272 timer?.cancel()
273 timer = $.clock.every(POLL_MS, () => void tick($))
274 // `/review` is the built-in /code-review's; a refused name still leaves
275 // the band and `c`.
276 await $.command
277 .register({ name: 'pr-review', description: 'Open the review threads of your PRs and those you review (or PR <number>) in a pane', argumentHint: '[number]' })
278 .catch(() => $.ui.toast('pr-comments : /pr-review indisponible, utilise c'))
279 return r
280 })
281
282 on('command.run', { command: 'pr-review' }, async ($, e) => {
283 const number = Number(e.args.trim().replace(/^#/, ''))
284 if (number) {
285 if (!(await read($, prs)).some(p => p.number === number))
286 return { text: `PR #${number} : aucun fil non résolu, ou ni à toi, ni en relecture, ni sur la branche courante.` }
287 await showPr($, number)
288 }
289 return { text: (await openPane($)) ? 'Review ouverte.' : 'Aucun fil de review non résolu sur tes PR, celles que tu relis ou la branche courante.' }
290 })
291
292 // A letter typed at the prompt never presses a band Button: `c`, in an
293 // empty prompt while armed, opens the pane; otherwise /pr-review.
294 // Opened once the keystroke is done: the pane only gets the keyboard over
295 // an empty prompt, and during the edit the prompt is still taking it.
296 on('prompt.edit', async ($, e, next) => {
297 if (e.text !== '' || e.inputText.toLowerCase() !== 'c' || !(await read($, armed)) || !(await pane($))) return next(e)
298 $.clock.after(0, () => void openPane($))
299 return { text: '', cursor: 0 }
300 })
301
302 // Esc while replying gives up the reply and keeps the pane: the close is
303 // refused, and the pane asks back the keyboard Esc handed over.
304 on('ui.close', { id: PANE }, async ($, e, next) => {
305 if (e.origin.kind !== 'person' || (await read($, reply)) === null) return next(e)
306 await update($, reply, () => null)
307 $.clock.after(0, () => void openPane($))
308 return { value: undefined }
309 })
310
311 // The prepared prompt sent, edited or not, badges its threads; the box
312 // emptied or retyped does not. Any submit spends the draft, and disarms `c`.
313 on('prompt.submit', async ($, e, next) => {
314 await update($, armed, () => false)
315 const d = await read($, draft)
316 if (d) {
317 await update($, draft, () => null)
318 if (e.text.includes(d.header)) await update($, sent, ids => [...ids, ...d.ids])
319 }
320 return next(e)
321 })
322
323 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
324 const below = await next(e)
325 const list = await read($, prs)
326 if (e.props.hasSurvey || list.length === 0) return below
327 const { Box, Text, Button } = $.ui.resolve(e)
328 const isArmed = await read($, armed)
329 return (
330 <Box flexDirection="column">
331 {below}
332 <Box>
333 <Text wrap="truncate-end">
334 <Text color="yellow">💬 </Text>
335 {prGroups(Text, list)}
336 {' '}
337 </Text>
338 {/* The hotkey only while `c` is caught; else a click, or /pr-review. */}
339 {isArmed ? (
340 <Button key="open" label="ouvrir" hotkey="c" plain onPress={() => void openPane($)} />
341 ) : (
342 <Button key="open" label="ouvrir" plain onPress={() => void openPane($)} />
343 )}
344 {!isArmed && <Text dimColor> ou /pr-review</Text>}
345 </Box>
346 </Box>
347 )
348 })
349
350 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
351 const { Box, Text, Button, Code, Markdown, Link } = $.ui.resolve(e)
352 // Mobile draws no text field: no reply there, the rest works.
353 const Input = e.surface === 'mobile' ? undefined : $.ui.resolve(e).Input
354 const all = await read($, prs)
355 const p = await pane($)
356 const list = p?.threads ?? []
357 if (!p || list.length === 0) return <Text dimColor>Plus aucun fil non résolu. Esc pour fermer.</Text>
358 const i = Math.min(await read($, at), list.length - 1)
359 const t = list[i]!
360 const head = await read($, branch)
361 const sentIds = await read($, sent)
362 const marks = await read($, marked)
363 const replyText = await read($, reply)
364 const running = await read($, busy)
365 const hunk = hunkTail(t.hunk)
366 // A thread's state at a glance, each in its own colour.
367 const badges = (x: Thread) => [
368 x.isAnswered && <Text color="green"> ↩ répondu</Text>,
369 x.isOutdated && <Text color="yellow" dimColor> obsolète</Text>,
370 sentIds.includes(x.id) && <Text color="magenta"> → Claude</Text>,
371 ]
372
373 return (
374 <Box flexDirection="column">
375 <Text wrap="truncate-end">
376 <Text bold>PR #{p.number}</Text> {cleanTitle(p.title)}
377 </Text>
378 <Text wrap="truncate-end" dimColor>
379 {/* With several PRs, the group line below says which group. */}
380 {all.length > 1 ? '' : p.isReview ? 'en relecture · ' : 'à traiter · '}
381 {p.branch === head ? 'branche courante' : `branche ${p.branch}`}
382 {p.isMine ? '' : ` · PR de @${p.author}`}
383 </Text>
384 {all.length > 1 && <Text wrap="truncate-end">{prGroups(Text, all, p.number)}</Text>}
385 <Box marginTop={1} flexDirection="column">
386 {list.map((x, n) => (
387 <Text wrap="truncate-end" dimColor={n !== i && x.isAnswered}>
388 <Text color={n === i ? 'cyan' : undefined} bold={n === i}>
389 {n === i ? '▸' : ' '}
390 {marks.includes(x.id) ? '◉' : ' '}
391 {String(n + 1).padStart(2)} {shortWhere(x, list)}
392 </Text>
393 {badges(x)}
394 <Text dimColor>
395 {' '}
396 {excerpt(x)}
397 </Text>
398 </Text>
399 ))}
400 </Box>
401 <Text dimColor>{'─'.repeat(Math.max(10, e.props.bodyColumns - 2))}</Text>
402 <Box>
403 <Text bold>
404 fil {i + 1}/{list.length}
405 </Text>
406 <Text dimColor wrap="truncate-start"> {where(t)} </Text>
407 <Link href={t.url} label="↗ GitHub" />
408 </Box>
409 {hunk ? <Code format="diff" source={clean(hunk)} path={t.path} /> : null}
410 {t.comments.map(c => (
411 <Box flexDirection="column" marginTop={1}>
412 <Text color="yellow">@{c.author}</Text>
413 <Markdown text={clean(c.body)} />
414 </Box>
415 ))}
416 {replyText !== null && Input && (
417 <Box marginTop={1}>
418 <Input key="reply" label="Réponse : " value={replyText} submitLabel="publier" autoFocus onSubmit={(v: string) => void postReply($, v)} />
419 </Box>
420 )}
421 {replyText !== null && (
422 <Text dimColor>Entrée publie · Esc annule la réponse · les touches n, p… tapent dans le champ</Text>
423 )}
424 {running ? <Text color="cyan">… {running}</Text> : null}
425 {!e.props.isFocused && (
426 <Text color="yellow">Le panneau n'a pas le clavier : ctrl+x tab ou un clic, et les touches marchent.</Text>
427 )}
428 <Box marginTop={1} flexWrap="wrap">
429 <Button key="prev" label="préc." hotkey="p" plain onPress={() => void go($, -1)} />
430 <Text> </Text>
431 <Button key="next" label="suiv." hotkey="n" plain onPress={() => void go($, 1)} />
432 <Text> </Text>
433 {all.length > 1 && <Button key="pr" label="PR suiv." hotkey="t" plain onPress={() => void nextPr($)} />}
434 {all.length > 1 && <Text> </Text>}
435 <Button key="mark" label={marks.includes(t.id) ? 'démarquer' : 'marquer'} hotkey="x" plain onPress={() => void toggleMark($)} />
436 <Text> </Text>
437 <Button key="all" label={marks.length === list.length ? 'tout démarquer' : 'tout marquer'} hotkey="a" plain onPress={() => void toggleAll($)} />
438 <Text> </Text>
439 <Button key="fix" label={marks.length ? `corriger (${marks.length})` : 'corriger'} hotkey="f" plain onPress={() => void fix($)} />
440 <Text> </Text>
441 <Button key="answer" label="répondre" hotkey="r" plain onPress={() => void startReply($)} />
442 <Text> </Text>
443 <Button key="resolve" label="résoudre" hotkey="v" plain onPress={() => void resolve($)} />
444 <Text> </Text>
445 <Button key="web" label="web" hotkey="o" plain onPress={() => void openWeb($)} />
446 <Text> </Text>
447 <Button key="close" label="fermer" hotkey="q" plain role="dismiss" onPress={() => void closeOrCancel($)} />
448 </Box>
449 </Box>
450 )
451 })
452}
453hooks/threads.ts 172 lines1import type { Pr, Thread } from '../types'
2
3// Every poll, one light request lists the PRs to show: the user's open PRs
4// in the repo and the current branch's whoever wrote it (to work on), and
5// the PRs the user reviews, asked or already reviewed, as GitHub drops the
6// request once a review is in. A PR's threads are then asked only when it
7// changed (DETAIL): the threads with their comments cost about 30 times
8// the list. `{owner}` and `{repo}` are filled in by `gh api` in -F fields only.
9export const LIST = `query($mine: String!, $requested: String!, $reviewed: String!, $owner: String!, $repo: String!, $branch: String!) {
10 viewer { login }
11 mine: search(query: $mine, type: ISSUE, first: 20) { nodes { ...pr } }
12 requested: search(query: $requested, type: ISSUE, first: 20) { nodes { ...pr } }
13 reviewed: search(query: $reviewed, type: ISSUE, first: 20) { nodes { ...pr } }
14 repository(owner: $owner, name: $repo) { pullRequests(headRefName: $branch, states: OPEN, first: 1) { nodes { ...pr } } } }
15fragment pr on PullRequest { number title url headRefName updatedAt author { login } }`
16export const MINE = 'repo:{owner}/{repo} is:pr is:open author:@me'
17export const REQUESTED = 'repo:{owner}/{repo} is:pr is:open -author:@me review-requested:@me'
18export const REVIEWED = 'repo:{owner}/{repo} is:pr is:open -author:@me reviewed-by:@me'
19
20export const DETAIL = `query($owner: String!, $repo: String!, $n: Int!) {
21 repository(owner: $owner, name: $repo) { pullRequest(number: $n) { author { login }
22 reviewThreads(first: 100) { nodes { id isResolved isOutdated path line
23 comments(first: 50) { nodes { author { login } body url diffHunk } } } } } } }`
24
25export const REPLY = `mutation($id: ID!, $body: String!) {
26 addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $id, body: $body }) { comment { id } } }`
27
28export const RESOLVE = `mutation($id: ID!) { resolveReviewThread(input: { threadId: $id }) { thread { id } } }`
29
30type EntryJson = { number?: number; title: string; url: string; headRefName: string; updatedAt: string; author: { login: string } | null }
31type ListJson = {
32 data?: {
33 viewer?: { login: string }
34 mine?: { nodes: EntryJson[] }
35 requested?: { nodes: EntryJson[] }
36 reviewed?: { nodes: EntryJson[] }
37 repository?: { pullRequests: { nodes: EntryJson[] } } | null
38 }
39}
40
41// A PR to show, before its threads are known.
42export type Entry = Omit<Pr, 'threads'> & { updatedAt: string }
43
44// Each PR once: those to work on first (the current branch's, then the
45// user's), then those under review.
46export const toEntries = (json: ListJson): Entry[] => {
47 const d = json.data
48 const seen = new Set<number>()
49 const take = (nodes: EntryJson[], isReview: boolean) =>
50 nodes
51 .filter((p): p is EntryJson & { number: number } => typeof p.number === 'number' && !seen.has(p.number) && !!seen.add(p.number))
52 .map(p => ({
53 number: p.number,
54 isReview,
55 // A deleted account has no author, and is nobody's.
56 isMine: !!p.author && p.author.login === d?.viewer?.login,
57 author: p.author?.login ?? 'ghost',
58 title: p.title,
59 url: p.url,
60 branch: p.headRefName,
61 updatedAt: p.updatedAt,
62 }))
63 return [
64 ...take([...(d?.repository?.pullRequests.nodes ?? []), ...(d?.mine?.nodes ?? [])], false),
65 ...take([...(d?.requested?.nodes ?? []), ...(d?.reviewed?.nodes ?? [])], true),
66 ]
67}
68
69type CommentJson = { author: { login: string } | null; body: string; url: string; diffHunk: string }
70type DetailJson = {
71 data?: { repository?: { pullRequest?: { author: { login: string } | null; reviewThreads: { nodes: {
72 id: string; isResolved: boolean; isOutdated: boolean; path: string; line: number | null
73 comments: { nodes: CommentJson[] }
74 }[] } } | null } | null }
75}
76
77// A PR's unresolved threads; undefined when GitHub gave no PR.
78export const toThreads = (json: DetailJson): Thread[] | undefined => {
79 const pr = json.data?.repository?.pullRequest
80 if (!pr) return undefined
81 return pr.reviewThreads.nodes
82 .filter(t => !t.isResolved && t.comments.nodes.length > 0)
83 .map(t => {
84 // A deleted account has no author.
85 const comments = t.comments.nodes.map(c => ({ author: c.author?.login ?? 'ghost', body: c.body.trim() }))
86 const first = t.comments.nodes[0]!
87 return {
88 id: t.id,
89 path: t.path,
90 line: t.line,
91 isOutdated: t.isOutdated,
92 url: first.url,
93 hunk: first.diffHunk,
94 comments,
95 isAnswered: comments.length > 1 && comments.at(-1)!.author === pr.author?.login,
96 }
97 })
98}
99
100export const where = (t: Thread) => `${t.path}${t.line ? `:${t.line}` : ''}`
101
102// GitHub's hunk runs from the top of the change down to the commented line,
103// so its header counts lines it no longer holds; Code refuses such a diff.
104// The last `lines` are kept, the comment's surroundings, under a header
105// counted again from them.
106// ponytail: fixed 12 lines; scroll the whole hunk if that proves too short.
107export const hunkTail = (hunk: string, lines = 12) => {
108 const [head = '', ...body] = hunk.split('\n')
109 const m = head.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/)
110 if (!m) return undefined
111 const rows = body.filter(l => /^[ +-]/.test(l))
112 const cut = Math.max(0, rows.length - lines)
113 const count = (part: string[], sign: string) => part.filter(l => l[0] === ' ' || l[0] === sign).length
114 const kept = rows.slice(cut)
115 const oldStart = Number(m[1]) + count(rows.slice(0, cut), '-')
116 const newStart = Number(m[2]) + count(rows.slice(0, cut), '+')
117 return [`@@ -${oldStart},${count(kept, '-')} +${newStart},${count(kept, '+')} @@${m[3]}`, ...kept].join('\n')
118}
119
120// The prompt's first line, also how the submit hook knows the prompt is
121// still the one `f` prepared.
122export const reviewHeader = (pr: Pr, count: number) =>
123 `${count > 1 ? `${count} fils` : 'Un fil'} de review de la PR #${pr.number} (${pr.url}) :`
124
125// What `f` puts in the box: each thread where it sits, its code and its
126// whole conversation, so Claude also sees the replies already made. When
127// the session is on another branch, Claude is told to go to the PR's first.
128export const reviewPrompt = (pr: Pr, threads: readonly Thread[], branch: string) =>
129 [
130 reviewHeader(pr, threads.length),
131 ...(branch === pr.branch
132 ? []
133 : [
134 `Ces fils portent sur la branche \`${pr.branch}\`, pas sur la branche courante (\`${branch}\`) : ` +
135 `passe dessus avant de corriger (\`gh pr checkout ${pr.number}\`, ou un worktree s'il y a des modifications en cours).`,
136 ]),
137 ...threads.map(t =>
138 [
139 `${where(t)}${t.isOutdated ? ' (sur une version antérieure du code)' : ''}`,
140 ...t.comments.map(c => `@${c.author} : ${c.body}`),
141 ].join('\n'),
142 ),
143 threads.length > 1 ? 'Traite chacun : corrige le code, ou dis-moi pourquoi tu ne le ferais pas.' : 'Corrige le code, ou dis-moi pourquoi tu ne le ferais pas.',
144 // Commits and pushes on the user's own PRs only: another's branch is its
145 // author's to push.
146 pr.isMine
147 ? 'Commite chaque correction en `git commit --fixup=<sha>` du commit qu\'elle corrige, puis pousse la branche.'
148 : `C'est la PR de @${pr.author} : corrige en local, sans commiter ni pousser, et dis-moi ce que tu as changé.`,
149 ].join('\n\n')
150
151// A thread's file by its name alone, with its folder when another thread of
152// the list has a file of the same name.
153export const shortWhere = (t: Thread, list: readonly Thread[]) => {
154 const parts = t.path.split('/')
155 const name = parts.at(-1)!
156 const isTwin = list.some(x => x.path !== t.path && x.path.split('/').at(-1) === name)
157 return `${isTwin && parts.length > 1 ? `${parts.at(-2)}/` : ''}${name}${t.line ? `:${t.line}` : ''}`
158}
159
160// What the thread is about: the first comment's first line, its markdown
161// marks dropped, cut to `max` characters.
162export const excerpt = (t: Thread, max = 90) => {
163 const line = (t.comments[0]?.body ?? '').split('\n').find(l => l.trim()) ?? ''
164 // A quote's or heading's leading mark, emphasis and code marks; `<` and
165 // `>` inside the text stay (`List<String>`).
166 const plain = line.replace(/^\s*[>#]+\s*/, '').replace(/[*_`]+/g, '').replace(/\s+/g, ' ').trim()
167 return plain.length > max ? `${plain.slice(0, max - 1)}…` : plain
168}
169
170// The PR's title without its trailing tags (`[DEPLOY_PR][poso75]`).
171export const cleanTitle = (title: string) => title.replace(/(\s*\[[^\]]*\])+\s*$/, '').trim()
172types/index.d.ts 57 lines1export type Comment = { author: string; body: string }
2
3// An unresolved review thread; line is null on a file-level comment or when
4// GitHub can no longer place it. hunk: the diff GitHub shows above it.
5export type Thread = {
6 id: string
7 path: string
8 line: number | null
9 isOutdated: boolean
10 url: string
11 hunk: string
12 comments: Comment[]
13 // The last word is the PR author's: likely answered, not yet resolved.
14 isAnswered: boolean
15}
16
17// An open PR with its unresolved threads; branch is its head branch,
18// isReview says the user reviews it rather than works on it, and isMine
19// that the user wrote it: Claude commits and pushes only on those.
20export type Pr = {
21 number: number
22 isReview: boolean
23 isMine: boolean
24 author: string
25 title: string
26 url: string
27 branch: string
28 threads: Thread[]
29}
30
31declare module 'claude-code' {
32 interface PluginState {
33 'pr-comments': {
34 // The PRs with unresolved threads, those to work on (the current
35 // branch's, the user's) before those under review; the session's branch.
36 prs: Pr[]
37 branch: string
38 // The PR the pane shows, by its number.
39 shownPr: number | null
40 // Ids of the threads handed to Claude, badged in the pane.
41 sent: string[]
42 // The prompt `f` put in the box, by its first line, and its threads,
43 // marked sent once that prompt is.
44 draft: { header: string; ids: string[] } | null
45 // The pane: the thread shown, those marked for `f`, the reply being
46 // written (null when none) and what is running (null when idle).
47 at: number
48 marked: string[]
49 reply: string | null
50 busy: string | null
51 // Whether `c` typed in an empty prompt is caught: from threads not
52 // seen before to the next prompt sent.
53 armed: boolean
54 }
55 }
56}
57