SLOPSHOPPER

while-you-wait

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)…

newpanecommandstatusmodelprocess
v0.1.0MITupdated 2026-10-04xyzbuilds/while-you-wait
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · while-you-wait
│ ┃ while-you-wait ✕ › fix the failing auth test and add an audit log call │ ┃ 👀 While you wait loading… │ ┃ ────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ──────────── ⎿ Read 6 lines │ ┃ ────────────────────────────────────────── ⏺ Update(src/auth.ts) │ ┃ ──────────── ⎿ Added 2 lines, removed 1 line │ ┃ [ Refresh ] all your repositories ⏺ 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 │ │ › /wait │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · while-you-wait
👀 While you wait loading… ────────────────────────────────────────────────────── ────────────────────────────────────────────────────── [ Refresh ] all your repositories
README

While you wait

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:

  • Review requested: pull requests waiting for your review. Diff shows the changes in the panel, file by file. Pre-review asks Claude Haiku for a one-line summary and up to five places worth a closer look.
  • Your pull requests: changes requested, failing checks, approved and ready to merge, new comments since you last opened them.
  • Assigned issues.

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.

Install

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" } }

Tests

claude plugin validate .
claude plugin test .

Settings

In /config:

SettingDefault
Minutes before the hint2
Open the side panel by itselfoff
This repository onlyoff

Commands

/wait opens the panel; /wait refresh fetches again first. The panel refreshes every 10 minutes.

MIT License · © 2026 XYZ Makes LLC

Source 3 files
hooks/register.tsx 344 lines
1import { 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}
344
hooks/github.ts 182 lines
1import 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}
182
types/index.d.ts 14 lines
1export 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