While Claude works, the side panel lists what you can do meanwhile on GitHub: pull requests waiting for your review (with the diff and a quick pre-review)…

A Claude Code mod for the minutes Claude spends working. After two minutes of a turn, the status line shows what is waiting for you on GitHub; /wait opens it in the side panel:
Everything comes from GitHub through the gh CLI, for the account it is signed in to. Work untouched for 90 days and archived repositories are left out. The status line clears when Claude finishes.
Needs the GitHub CLI, signed in (gh auth login), and a Claude Code build with mods. Any terminal, on macOS, Linux or Windows. No keys to set up: GitHub calls go through gh's own sign-in, and Pre-review through your Claude Code login.
git clone https://github.com/xyzbuilds/while-you-wait.git
claude --plugin-dir ./while-you-wait
To load it in every session, add the folder's full path to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json (folders separated by :):
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/Users/you/mods/while-you-wait" } }
claude plugin validate .
claude plugin test .
In /config:
| Setting | Default |
|---|---|
| Minutes before the hint | 2 |
| Open the side panel by itself | off |
| This repository only | off |
/wait opens the panel; /wait refresh fetches again first. The panel refreshes every 10 minutes.
MIT License · © 2026 XYZ Makes LLC
hooks/register.tsx 344 lines1import { atom, read } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Detail, Queue } from '../types'
5import { agoText, graphqlArgs, hintOf, newTalk, parseQueue, PRE_REVIEW, preReviewPrompt, splitDiff, STATE_TEXT } from './github'
6
7const EMPTY: Queue = { reviews: [], mine: [], issues: [], fetchedAt: 0 }
8const queueAtom = atom({ plugin: 'while-you-wait', key: 'queue' } as const, EMPTY)
9const detailAtom = atom({ plugin: 'while-you-wait', key: 'detail' } as const, null)
10const seenAtom = atom({ plugin: 'while-you-wait', key: 'seen' } as const, {})
11
12const PANE = 'while-you-wait'
13const REFRESH_MS = 10 * 60_000
14const STALE_MS = 5 * 60_000
15
16let isActive = false
17let login = ''
18let repo: string | undefined
19let isWorking = false
20let isHinting = false
21let hintTimer: { cancel: () => void } | undefined
22let ticker: { cancel: () => void } | undefined
23let lastDiff = { url: '', text: '' }
24let config = { nudgeMs: 2 * 60_000, openPanel: false, onlyThisRepo: false }
25
26type Chosen = { url: string; title: string; meta: string }
27
28// ---- GitHub ------------------------------------------------------------
29
30async function gh($: EngineInterface, args: string[], timeoutMs = 30_000) {
31 return $.process.run(['gh', ...args], { timeoutMs, cwd: await $.session.root() }).catch(() => undefined)
32}
33
34async function setQueue($: EngineInterface, queue: Queue): Promise<void> {
35 await $.state.set({ plugin: 'while-you-wait', key: 'queue' }, queue)
36}
37
38async function getSeen($: EngineInterface): Promise<Record<string, number>> {
39 return (await $.state.get({ plugin: 'while-you-wait', key: 'seen' })).value ?? {}
40}
41
42async function setSeen($: EngineInterface, seen: Record<string, number>): Promise<void> {
43 await $.state.set({ plugin: 'while-you-wait', key: 'seen' }, seen)
44 await $.store.set('seen', seen)
45}
46
47async function refresh($: EngineInterface): Promise<void> {
48 if (!isActive) return
49 const now = await $.clock.now()
50 if (!login) {
51 const who = await gh($, ['api', 'user', '--jq', '.login'], 15_000)
52 if (!who) return setQueue($, { ...EMPTY, fetchedAt: now, problem: 'Needs the GitHub CLI: install gh, then run gh auth login.' })
53 if (who.exitCode !== 0) return setQueue($, { ...EMPTY, fetchedAt: now, problem: 'Sign in to GitHub first: gh auth login' })
54 login = who.stdout.trim()
55 }
56 if (config.onlyThisRepo && repo === undefined) {
57 const here = await gh($, ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'], 15_000)
58 repo = here?.exitCode === 0 ? here.stdout.trim() : ''
59 }
60 const found = await $.process.run(graphqlArgs(login, now, repo || undefined), { timeoutMs: 30_000 }).catch(() => undefined)
61 if (!found || found.exitCode !== 0) {
62 const held = (await $.state.get({ plugin: 'while-you-wait', key: 'queue' })).value ?? EMPTY
63 return setQueue($, { ...held, problem: `GitHub didn't answer: ${(found?.stderr ?? 'no reply').trim().split('\n')[0]}` })
64 }
65 const queue = parseQueue(found.stdout, now)
66 const seen = await getSeen($)
67 const fresh = queue.mine.filter(pr => seen[pr.url] === undefined)
68 if (fresh.length) await setSeen($, { ...seen, ...Object.fromEntries(fresh.map(pr => [pr.url, pr.talk])) })
69 await setQueue($, queue)
70 if (isHinting) await showHint($)
71}
72
73async function refreshIfStale($: EngineInterface): Promise<void> {
74 const queue = (await $.state.get({ plugin: 'while-you-wait', key: 'queue' })).value ?? EMPTY
75 if ((await $.clock.now()) - queue.fetchedAt > STALE_MS) await refresh($)
76}
77
78// ---- the hint ----------------------------------------------------------
79
80async function showHint($: EngineInterface): Promise<void> {
81 const queue = (await $.state.get({ plugin: 'while-you-wait', key: 'queue' })).value ?? EMPTY
82 const hint = hintOf(queue, await getSeen($))
83 $.ui.status(hint)
84 if (hint && config.openPanel) await openPane($)
85}
86
87async function whileWorking($: EngineInterface): Promise<void> {
88 if (!isWorking) return
89 isHinting = true
90 await refreshIfStale($)
91 if (isHinting) await showHint($)
92}
93
94// ---- actions -----------------------------------------------------------
95
96async function openPane($: EngineInterface): Promise<void> {
97 await $.ui.open({ id: PANE, title: 'While you wait' })
98}
99
100async function openUrl($: EngineInterface, url: string): Promise<void> {
101 const queue = (await $.state.get({ plugin: 'while-you-wait', key: 'queue' })).value ?? EMPTY
102 const pr = queue.mine.find(p => p.url === url)
103 if (pr) await setSeen($, { ...(await getSeen($)), [url]: pr.talk })
104 // macOS, then Linux, then Windows
105 for (const argv of [['open', url], ['xdg-open', url], ['cmd', '/c', 'start', '', url]]) {
106 const opened = await $.process.run(argv).catch(() => undefined)
107 if (opened?.exitCode === 0) break
108 }
109 if (isHinting) await showHint($)
110}
111
112async function setDetail($: EngineInterface, detail: Detail | null): Promise<void> {
113 await $.state.set({ plugin: 'while-you-wait', key: 'detail' }, detail)
114}
115
116async function diffText($: EngineInterface, url: string): Promise<string | undefined> {
117 if (lastDiff.url === url) return lastDiff.text
118 const ran = await gh($, ['pr', 'diff', url], 60_000)
119 if (!ran || ran.exitCode !== 0) return undefined
120 lastDiff = { url, text: ran.stdout }
121 return ran.stdout
122}
123
124async function showDiff($: EngineInterface, pick: Chosen): Promise<void> {
125 await setDetail($, { ...pick, files: [], more: 0, busy: 'diff' })
126 const text = await diffText($, pick.url)
127 if (text === undefined) return setDetail($, { ...pick, files: [], more: 0, problem: "Couldn't load the diff. Open it on GitHub instead." })
128 await setDetail($, { ...pick, ...splitDiff(text) })
129}
130
131async function preReview($: EngineInterface): Promise<void> {
132 const detail = (await $.state.get({ plugin: 'while-you-wait', key: 'detail' })).value
133 if (!detail) return
134 await setDetail($, { ...detail, busy: 'summary' })
135 const text = await diffText($, detail.url)
136 const result = text === undefined ? undefined : await $.model.complete({ model: 'haiku', system: PRE_REVIEW, prompt: preReviewPrompt(detail.title, text), maxTokens: 700, timeoutMs: 60_000 }).catch(() => undefined)
137 const summary = result?.isAnswered ? result.text.trim() : "The pre-review didn't come back. Try again in a minute."
138 const now = (await $.state.get({ plugin: 'while-you-wait', key: 'detail' })).value
139 if (now?.url === detail.url) await setDetail($, { ...now, busy: undefined, summary })
140}
141
142// ---- drawing -----------------------------------------------------------
143
144type View = Parameters<EngineInterface['ui']['resolve']>[0]
145
146function cardView($: EngineInterface, e: View, key: string, title: string, meta: string, url: string, extra: { label: string; run: () => Promise<void> }[]) {
147 const { Box, Text, Button, Link } = $.ui.resolve(e)
148 const isPhone = e.surface === 'mobile'
149 return (
150 <Box key={key} flexDirection="column" marginBottom={1} paddingLeft={2}>
151 <Text bold wrap="wrap">
152 {title}
153 </Text>
154 <Box paddingLeft={2}>
155 <Text dimColor wrap="wrap">
156 {meta}
157 </Text>
158 </Box>
159 <Box flexDirection="row" gap={1} paddingLeft={2} flexWrap="wrap">
160 {extra.map(action => (
161 <Button key={`${key}-${action.label}`} label={action.label} onPress={() => action.run()} />
162 ))}
163 {isPhone ? <Link href={url} label="Open on GitHub" /> : <Button key={`${key}-open`} label="Open" onPress={() => openUrl($, url)} />}
164 </Box>
165 </Box>
166 )
167}
168
169function heading($: EngineInterface, e: View, title: string, count: number, color: string, isFirst: boolean, rule: string) {
170 const { Box, Text } = $.ui.resolve(e)
171 return (
172 <Box key={`h-${title}`} flexDirection="column">
173 {!isFirst && <Text dimColor>{rule}</Text>}
174 <Box flexDirection="row" gap={1} marginBottom={1} marginTop={isFirst ? 0 : 1}>
175 <Text bold color={color}>
176 {title}
177 </Text>
178 <Text dimColor>{count}</Text>
179 </Box>
180 </Box>
181 )
182}
183
184function detailView($: EngineInterface, e: View, detail: Detail, rule: string) {
185 const { Box, Text, Button, Code, Link } = $.ui.resolve(e)
186 return (
187 <Box flexDirection="column" paddingX={1}>
188 <Box flexDirection="row" gap={1}>
189 <Button key="back" label="← Back" onPress={() => setDetail($, null)} />
190 {e.surface === 'mobile' ? <Link href={detail.url} label="Open on GitHub" /> : <Button key="detail-open" label="Open" onPress={() => openUrl($, detail.url)} />}
191 {!detail.summary && !detail.busy && !detail.problem && <Button key="pre-review" label="Pre-review" variant="primary" onPress={() => preReview($)} />}
192 </Box>
193 <Text dimColor>{rule}</Text>
194 <Text bold wrap="wrap">
195 {detail.title}
196 </Text>
197 <Text dimColor wrap="wrap">
198 {detail.meta}
199 </Text>
200 {detail.busy === 'diff' && <Text dimColor>Loading the diff…</Text>}
201 {detail.busy === 'summary' && <Text dimColor>Reading the diff for a pre-review…</Text>}
202 {detail.problem && <Text color="red">{detail.problem}</Text>}
203 {detail.summary && (
204 <Box flexDirection="column" marginTop={1}>
205 <Box flexDirection="row" gap={1}>
206 <Text bold color="magenta">PRE-REVIEW</Text>
207 <Text dimColor>Haiku, first pass</Text>
208 </Box>
209 <Text wrap="wrap">{detail.summary}</Text>
210 </Box>
211 )}
212 {detail.files.length > 0 && (
213 <Box flexDirection="column" marginTop={1}>
214 <Text dimColor>{rule}</Text>
215 <Box flexDirection="row" gap={1} marginBottom={1}>
216 <Text bold color="blue">FILES</Text>
217 <Text dimColor>{detail.files.length + detail.more}</Text>
218 </Box>
219 {detail.files.map((file, i) => (
220 <Box key={`f-${i}`} flexDirection="column" marginBottom={1}>
221 <Box flexDirection="row" gap={1}>
222 <Text bold wrap="truncate-middle">
223 {file.path}
224 </Text>
225 <Text color="green">+{file.added}</Text>
226 <Text color="red">−{file.removed}</Text>
227 </Box>
228 {file.hunks ? <Code source={file.hunks} format="diff" path={file.path} /> : <Text dimColor>No text changes (binary or renamed).</Text>}
229 {file.isCut && <Text dimColor>The rest of this file is on GitHub.</Text>}
230 </Box>
231 ))}
232 {detail.more > 0 && <Text dimColor>{detail.more} more files on GitHub.</Text>}
233 </Box>
234 )}
235 </Box>
236 )
237}
238
239// ---- the mod -----------------------------------------------------------
240
241export const register: Register = (on, options) => {
242 config = {
243 nudgeMs: (typeof options.nudgeAfterMinutes === 'number' && options.nudgeAfterMinutes >= 0 ? options.nudgeAfterMinutes : 2) * 60_000,
244 openPanel: options.openPanel === true,
245 onlyThisRepo: options.onlyThisRepo === true,
246 }
247
248 on('session.start', async ($, e, next) => {
249 const started = await next(e)
250 isActive = e.isInteractive
251 if (!isActive) return started
252 const seen = (await $.store.get('seen')) as Record<string, number> | undefined
253 if (seen) await $.state.set({ plugin: 'while-you-wait', key: 'seen' }, seen)
254 await $.command.register({ name: 'wait', description: 'While you wait: pull requests to review, yours that need you, issues assigned (/wait refresh)', argumentHint: '[refresh]', immediate: true })
255 ticker?.cancel()
256 ticker = $.clock.every(REFRESH_MS, () => void refresh($).catch(() => undefined))
257 $.clock.after(3_000, () => void refresh($).catch(() => undefined))
258 return started
259 })
260
261 on('turn.start', async ($, e, next) => {
262 if (isActive) {
263 isWorking = true
264 hintTimer?.cancel()
265 hintTimer = $.clock.after(config.nudgeMs, () => void whileWorking($).catch(() => undefined))
266 }
267 return next(e)
268 })
269
270 on('turn.complete', async ($, e, next) => {
271 const done = await next(e)
272 if (isActive && e.agentId === undefined) {
273 isWorking = false
274 isHinting = false
275 hintTimer?.cancel()
276 $.ui.status(undefined)
277 }
278 return done
279 })
280
281 on('command.run', { command: 'wait' }, async ($, e) => {
282 if (e.args.trim() === 'refresh' || (await $.clock.now()) - ((await $.state.get({ plugin: 'while-you-wait', key: 'queue' })).value?.fetchedAt ?? 0) > STALE_MS) await refresh($)
283 await openPane($)
284 return { text: 'While you wait opened in the side panel.' }
285 })
286
287 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
288 const queue = await read($, queueAtom)
289 const detail = await read($, detailAtom)
290 const seen = await read($, seenAtom)
291 const now = await $.clock.now()
292 const rule = '─'.repeat(Math.max(20, e.props.bodyColumns - 2))
293 if (detail) return detailView($, e, detail, rule)
294
295 const { Box, Text, Button } = $.ui.resolve(e)
296 const hint = hintOf(queue, seen)
297 const sections: ReturnType<typeof cardView>[] = []
298 if (queue.reviews.length) {
299 sections.push(heading($, e, 'REVIEW REQUESTED', queue.reviews.length, 'yellow', sections.length === 0, rule))
300 for (const pr of queue.reviews) {
301 const meta = `${pr.repo}#${pr.number} · by ${pr.author} · +${pr.additions} −${pr.deletions} in ${pr.files} files · ${agoText(pr.updatedAt, now)}${pr.isDraft ? ' · draft' : ''}`
302 const pick = { url: pr.url, title: pr.title, meta }
303 sections.push(cardView($, e, `r-${pr.url}`, pr.title, meta, pr.url, [{ label: 'Diff', run: () => showDiff($, pick) }]))
304 }
305 }
306 if (queue.mine.length) {
307 sections.push(heading($, e, 'YOUR PULL REQUESTS', queue.mine.length, 'magenta', sections.length === 0, rule))
308 for (const pr of queue.mine) {
309 const fresh = newTalk(pr, seen)
310 const meta = `${pr.repo}#${pr.number} · ${STATE_TEXT[pr.state]}${fresh ? ` · ${fresh} new comment${fresh === 1 ? '' : 's'}` : ''} · ${agoText(pr.updatedAt, now)}`
311 sections.push(cardView($, e, `m-${pr.url}`, pr.title, meta, pr.url, []))
312 }
313 }
314 if (queue.issues.length) {
315 sections.push(heading($, e, 'ASSIGNED ISSUES', queue.issues.length, 'blue', sections.length === 0, rule))
316 for (const issue of queue.issues) {
317 sections.push(cardView($, e, `i-${issue.url}`, issue.title, `${issue.repo}#${issue.number} · ${agoText(issue.updatedAt, now)}`, issue.url, []))
318 }
319 }
320
321 return (
322 <Box flexDirection="column" paddingX={1}>
323 <Box flexDirection="row" justifyContent="space-between">
324 <Text bold>👀 While you wait</Text>
325 <Text dimColor>{queue.fetchedAt ? `updated ${agoText(new Date(queue.fetchedAt).toISOString(), now)}` : 'loading…'}</Text>
326 </Box>
327 <Text dimColor>{rule}</Text>
328 {queue.problem && <Text color="red">{queue.problem}</Text>}
329 {!hint && !queue.problem && queue.fetchedAt > 0 && (
330 <Box flexDirection="column" alignItems="center" marginY={1}>
331 <Text dimColor>Nothing waiting for you on GitHub.</Text>
332 </Box>
333 )}
334 {sections}
335 <Text dimColor>{rule}</Text>
336 <Box flexDirection="row" gap={1}>
337 <Button key="refresh" label="Refresh" onPress={() => refresh($)} />
338 <Text dimColor>{config.onlyThisRepo ? `this repository only${repo ? ` (${repo})` : ''}` : 'all your repositories'}</Text>
339 </Box>
340 </Box>
341 )
342 })
343}
344hooks/github.ts 182 lines1import type { Assigned, DiffFile, Mine, MineState, Queue, Review } from '../types'
2
3export const DAYS = 90
4export const MAX_FILES = 25
5export const MAX_FILE_CHARS = 9000
6
7export const QUERY = `query($review: String!, $mine: String!, $issues: String!) {
8 review: search(query: $review, type: ISSUE, first: 20) { nodes { ... on PullRequest { number title url isDraft updatedAt additions deletions changedFiles author { login } repository { nameWithOwner } } } }
9 mine: search(query: $mine, type: ISSUE, first: 20) { nodes { ... on PullRequest { number title url isDraft updatedAt reviewDecision comments { totalCount } reviews { totalCount } repository { nameWithOwner } commits(last: 1) { nodes { commit { statusCheckRollup { state } } } } } } }
10 issues: search(query: $issues, type: ISSUE, first: 20) { nodes { ... on Issue { number title url updatedAt repository { nameWithOwner } } } }
11}`
12
13export function searchTerms(login: string, now: number, repo?: string): { review: string; mine: string; issues: string } {
14 const since = new Date(now - DAYS * 86_400_000).toISOString().slice(0, 10)
15 const scope = `archived:false updated:>=${since}${repo ? ` repo:${repo}` : ''} sort:updated-desc`
16 return {
17 review: `is:pr is:open review-requested:${login} ${scope}`,
18 mine: `is:pr is:open author:${login} ${scope}`,
19 issues: `is:issue is:open assignee:${login} ${scope}`,
20 }
21}
22
23export function graphqlArgs(login: string, now: number, repo?: string): string[] {
24 const terms = searchTerms(login, now, repo)
25 return ['gh', 'api', 'graphql', '-f', `query=${QUERY}`, '-f', `review=${terms.review}`, '-f', `mine=${terms.mine}`, '-f', `issues=${terms.issues}`]
26}
27
28type Node = Record<string, any>
29
30const RANK: Record<MineState, number> = { changes: 0, failing: 1, ready: 2, waiting: 3, draft: 4 }
31
32function stateOf(pr: Node): MineState {
33 const checks = pr.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state as string | undefined
34 if (pr.isDraft) return 'draft'
35 if (pr.reviewDecision === 'CHANGES_REQUESTED') return 'changes'
36 if (checks === 'FAILURE' || checks === 'ERROR') return 'failing'
37 if (pr.reviewDecision === 'APPROVED' && (checks === undefined || checks === 'SUCCESS')) return 'ready'
38 return 'waiting'
39}
40
41export function parseQueue(text: string, now: number): Queue {
42 const data = (JSON.parse(text) as { data?: Record<string, { nodes?: Node[] }> }).data ?? {}
43 const nodes = (key: string) => (data[key]?.nodes ?? []).filter(n => n && typeof n.url === 'string')
44 const reviews: Review[] = nodes('review').map(n => ({
45 url: n.url, repo: n.repository?.nameWithOwner ?? '', number: n.number, title: n.title,
46 author: n.author?.login ?? 'someone', isDraft: Boolean(n.isDraft),
47 additions: n.additions ?? 0, deletions: n.deletions ?? 0, files: n.changedFiles ?? 0, updatedAt: n.updatedAt,
48 }))
49 const mine: Mine[] = nodes('mine')
50 .map(n => ({
51 url: n.url, repo: n.repository?.nameWithOwner ?? '', number: n.number, title: n.title,
52 state: stateOf(n), talk: (n.comments?.totalCount ?? 0) + (n.reviews?.totalCount ?? 0), updatedAt: n.updatedAt,
53 }))
54 .sort((a, b) => RANK[a.state] - RANK[b.state])
55 const issues: Assigned[] = nodes('issues').map(n => ({ url: n.url, repo: n.repository?.nameWithOwner ?? '', number: n.number, title: n.title, updatedAt: n.updatedAt }))
56 return { reviews, mine, issues, fetchedAt: now }
57}
58
59export const STATE_TEXT: Record<MineState, string> = {
60 changes: 'changes requested',
61 failing: 'checks failing',
62 ready: 'approved, ready to merge',
63 waiting: 'waiting on reviewers',
64 draft: 'draft',
65}
66
67// Comments and reviews since the owner last opened it. A pull request seen for the first
68// time sets the baseline instead of counting everything as new.
69export function newTalk(pr: Mine, seen: Record<string, number>): number {
70 const was = seen[pr.url]
71 return was === undefined ? 0 : Math.max(0, pr.talk - was)
72}
73
74export function needsYou(pr: Mine, seen: Record<string, number>): boolean {
75 return pr.state === 'changes' || pr.state === 'failing' || pr.state === 'ready' || newTalk(pr, seen) > 0
76}
77
78export function hintOf(queue: Queue, seen: Record<string, number>): string | undefined {
79 const parts: string[] = []
80 if (queue.reviews.length) parts.push(`${queue.reviews.length} to review`)
81 const mine = queue.mine.filter(pr => needsYou(pr, seen)).length
82 if (mine) parts.push(`${mine} of your PRs need${mine === 1 ? 's' : ''} you`)
83 if (queue.issues.length) parts.push(`${queue.issues.length} assigned`)
84 return parts.length ? `👀 While you wait: ${parts.join(' · ')} · /wait` : undefined
85}
86
87export function agoText(iso: string, now: number): string {
88 const minutes = Math.max(0, Math.round((now - Date.parse(iso)) / 60_000))
89 if (minutes < 60) return `${minutes}m ago`
90 if (minutes < 48 * 60) return `${Math.round(minutes / 60)}h ago`
91 return `${Math.round(minutes / 1440)}d ago`
92}
93
94// ---- diffs -------------------------------------------------------------
95
96const CONTROL = /[\x00-\x08\x0b-\x1f\x7f]/g
97
98function counted(lines: readonly string[]): { old: number; fresh: number } {
99 let old = 0
100 let fresh = 0
101 for (const line of lines) {
102 if (line.startsWith('+')) fresh += 1
103 else if (line.startsWith('-')) old += 1
104 else if (line.startsWith(' ') || line === '') {
105 old += 1
106 fresh += 1
107 }
108 }
109 return { old, fresh }
110}
111
112// A hunk cut short gets its header's line counts rewritten, or the drawing refuses it.
113export function cutHunk(hunk: string, room: number): string | undefined {
114 const [header, ...body] = hunk.split('\n')
115 const match = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/.exec(header ?? '')
116 if (!match) return undefined
117 const kept: string[] = []
118 let size = header!.length + 1
119 for (const line of body) {
120 if (size + line.length + 1 > room) break
121 kept.push(line)
122 size += line.length + 1
123 }
124 if (kept.length === 0) return undefined
125 const { old, fresh } = counted(kept)
126 return [`@@ -${match[1]},${old} +${match[2]},${fresh} @@${match[3]}`, ...kept].join('\n')
127}
128
129function hunksOf(lines: readonly string[]): string[] {
130 const hunks: string[] = []
131 let current: string[] = []
132 for (const line of lines) {
133 if (line.startsWith('@@')) {
134 if (current.length) hunks.push(current.join('\n'))
135 current = [line]
136 } else if (current.length && !line.startsWith('\\')) {
137 current.push(line)
138 }
139 }
140 if (current.length) hunks.push(current.join('\n'))
141 return hunks.map(h => h.replace(/\n+$/, ''))
142}
143
144export function splitDiff(text: string): { files: DiffFile[]; more: number } {
145 const chunks = text.replace(/\r/g, '').replace(CONTROL, '').split(/^diff --git /m).slice(1)
146 const files = chunks.map(chunk => {
147 const [first, ...lines] = chunk.split('\n')
148 const path = / b\/(.+)$/.exec(first ?? '')?.[1] ?? first ?? 'file'
149 const hunks = hunksOf(lines)
150 const body = hunks.flatMap(h => h.split('\n').slice(1))
151 const added = body.filter(l => l.startsWith('+')).length
152 const removed = body.filter(l => l.startsWith('-')).length
153 const kept: string[] = []
154 let size = 0
155 let isCut = false
156 for (const hunk of hunks) {
157 if (size + hunk.length + 1 <= MAX_FILE_CHARS) {
158 kept.push(hunk)
159 size += hunk.length + 1
160 continue
161 }
162 isCut = true
163 const piece = cutHunk(hunk, MAX_FILE_CHARS - size)
164 if (piece) kept.push(piece)
165 break
166 }
167 return { path, added, removed, hunks: kept.join('\n'), isCut }
168 })
169 return { files: files.slice(0, MAX_FILES), more: Math.max(0, files.length - MAX_FILES) }
170}
171
172export const PRE_REVIEW = `You help an engineer review a pull request in a couple of minutes.
173From the title and diff, write:
174- one line saying what the change does;
175- then up to five lines, each starting "• ", naming a file and something worth a closer look: a likely bug, a risky edit, a missing test, a security or data concern.
176If nothing stands out, say so in one line. Plain text, no preamble, no headings.`
177
178export function preReviewPrompt(title: string, diff: string): string {
179 const cut = diff.length > 40_000 ? `${diff.slice(0, 40_000)}\n[diff cut at 40,000 characters]` : diff
180 return `Title: ${title}\n\n${cut}`
181}
182types/index.d.ts 14 lines1export type Review = { url: string; repo: string; number: number; title: string; author: string; isDraft: boolean; additions: number; deletions: number; files: number; updatedAt: string }
2export type MineState = 'changes' | 'failing' | 'ready' | 'waiting' | 'draft'
3export type Mine = { url: string; repo: string; number: number; title: string; state: MineState; talk: number; updatedAt: string }
4export type Assigned = { url: string; repo: string; number: number; title: string; updatedAt: string }
5export type Queue = { reviews: Review[]; mine: Mine[]; issues: Assigned[]; fetchedAt: number; problem?: string }
6export type DiffFile = { path: string; added: number; removed: number; hunks: string; isCut: boolean }
7export type Detail = { url: string; title: string; meta: string; files: DiffFile[]; more: number; summary?: string; busy?: 'diff' | 'summary'; problem?: string }
8
9declare module 'claude-code' {
10 interface PluginState {
11 'while-you-wait': { queue: Queue; detail: Detail | null; seen: Record<string, number> }
12 }
13}
14