SLOPSHOPPER

pr-comments

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

newpanebandcommandtoastprompt
★ 1v?MITupdated 2026-10-02aguiddir/claude-config/plugins/pr-comments
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pr-comments
│ ┃ pr-review ✕ › fix the failing auth test and add an audit log call │ ┃ Plus aucun fil non résolu. Esc pour fermer. │ ⏺ 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 │ │ › /pr-review │ ⎿ pr-comments: Aucun fil de review non résolu sur tes PR, celles q │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · pr-review
Plus aucun fil non résolu. Esc pour fermer.
README

claude-config

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.

Just the desktop notifications

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.

Just the replay

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.

Just the CI watch

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.

Just the PR comments

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:

KeyDoes
n / pnext / previous thread
tnext PR, when several have threads
xmark the thread, for f
amark every thread, or none once they all are
fputs 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
rwrites a reply, posted to GitHub on Enter; Esc gives it up and keeps the pane, as the field takes every letter meanwhile
vresolves the thread on GitHub
oopens the thread in the browser
q / Esccloses 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

Just the explain-diff

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

What's inside

PathInstalled asWhat 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.jsonmerged into ~/.claude/settings.jsonAuto mode, status line, Opus effort, theme
plugins.txtclaude plugin installMarketplaces and plugins
plugins/notify/plugin notify@claude-configDesktop 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-configBand 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-configBand 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:

  • codebase-memory-mcp: code knowledge-graph MCP server, from the release tarball after a SHA-256 check, registered for Claude Code only.
  • jq in ~/.local/bin, from the release binary after a SHA-256 check, when it is not already installed. The notify plugin needs it.
  • Playwright agent CLI with its skills, when npm is available.

Sources

Source 3 files
hooks/register.tsx 453 lines
1import { 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}
453
hooks/threads.ts 172 lines
1import 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()
172
types/index.d.ts 57 lines
1export 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