SLOPSHOPPER

pr-dashboard

Band above the prompt with GitHub PR counts and news for the session's repo (refreshed every 15 minutes via gh), plus uncommitted changes in the band and a…

newpanebandguardcommandtoast
v0.1.3MITupdated 2026-10-26marios-tsigkas/pr-dashboard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pr-dashboard
│ ┃ Uncommitted ✕ › fix the failing auth test and add an audit log call │ ┃ 1 file +11 −3 pane-refresh │ ┃ +11 −3 diff:src/auth.ts 3 ⏺ 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 │ │ › /changes │ ⎿ pr-dashboard: Opened the Uncommitted pane. │ │ app JSON Parse error: Unexpected EOF refresh hide changes +11 −3 changes-pane +11 −3 band-file:src/auth.ts 3 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
app JSON Parse error: Unexpected EOF refresh hide changes +11 −3 changes-pane +11 −3 band-file:src/auth.ts 3
Pane · Uncommitted
1 file +11 −3 pane-refresh +11 −3 diff:src/auth.ts 3
README

pr-dashboard

A Claude Code mod that shows, above the prompt, your GitHub PRs for the session's repo and your uncommitted changes.

  • PRs: review requests, your PRs, assigned, approved, changes requested and failing CI, each a link. Refreshed every 15 minutes, or with ↻.
  • New: review requests, approvals, merges and comments since you last looked. Click one to open it, × to clear it.
  • Uncommitted: changed files with +/− lines. Click a file, ⧉ or /changes for a side pane with each file's diff.

Install

Needs Claude Code 2.1.299 or later (claude --version; claude update to upgrade). Older versions install it but it fails to load or draws nothing.

Type at a Claude Code terminal prompt:

/plugin install pr-dashboard --marketplace marios-tsigkas/pr-dashboard

Answer y to add the marketplace, then pick a scope (user is first). Or from a shell:

claude plugin marketplace add marios-tsigkas/pr-dashboard
claude plugin install pr-dashboard@marios-tsigkas

It needs the GitHub CLI logged in (gh auth login) and git.

Develop

claude plugin validate .
claude plugin test .
Source 2 files
hooks/register.tsx 1020 lines
1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register } from 'claude-code'
3
4import type { Change, Counts, Diff, Pr, PrEvent, PrEventKind } from '../types'
5
6const REFRESH_MS = 15 * 60 * 1000
7// Sessions share one fetch through $.store; a cache younger than this is reused.
8const CACHE_MS = REFRESH_MS - 15 * 1000
9// Skips the fetch of any session that starts before the next tick; the tick itself retries.
10const RATE_LIMIT_BACKOFF_MS = REFRESH_MS - 30 * 1000
11// While one session fetches, others starting at the same moment wait for its result.
12const LEASE_MS = 60 * 1000
13const LEASE_RETRY_MS = 20 * 1000
14const MAX_FRESH_SHOWN = 3
15// News nobody clears is kept this long at most, newest first.
16const MAX_FRESH_KEPT = 50
17const MAX_CHANGES_SHOWN = 6
18const MAX_CHANGES_IN_PANE = 200
19// A diff draws whole hunks up to about this many lines; cut mid-hunk it would no longer parse.
20const MAX_DIFF_LINES = 400
21const CHANGES_PANE = { id: 'uncommitted', title: 'Uncommitted' }
22const CHANGES_COMMAND = 'changes'
23const KEYS = { cache: 'cache-v5', seen: 'seen', activity: 'activitySeen', fresh: 'fresh', lease: 'lease' }
24// The rate limit is the GitHub user's, so its backoff is shared by every repo.
25const BACKOFF_KEY = 'backoffUntil'
26// Keys of earlier versions, removed once per session.
27const isRetiredKey = (key: string) => /^(cache|cache-v[234]|seen|activitySeen|fresh)$/.test(key) || /^cache-v[234]:/.test(key)
28
29// Each session follows the GitHub repo of its working directory (its `origin`); null means every repo.
30type Repo = string | null
31
32const resolveRepo = async ($: EngineInterface): Promise<Repo> => {
33  const { exitCode, stdout } = await $.process.run(['git', 'remote', 'get-url', 'origin'])
34  if (exitCode !== 0) return null
35  const match = /(?:^|[@/])github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/.exec(stdout.trim())
36  const repo = match?.[1] ?? null
37
38  // Only a plain owner/name goes into search qualifiers.
39  return repo !== null && /^[\w.-]+\/[\w.-]+$/.test(repo) ? repo : null
40}
41
42const keyFor = (key: string, repo: Repo) => `${key}:${repo ?? '*'}`
43const repoQualifier = (repo: Repo) => (repo ? `repo:${repo} ` : '')
44const scoped = (repo: Repo, q: string) => `is:open is:pr archived:false ${repoQualifier(repo)}${q}`
45const MERGED_WINDOW_MS = 7 * 24 * 60 * 60 * 1000
46const searchUrl = (q: string) => `https://github.com/pulls?q=${encodeURIComponent(q)}`
47const linksFor = (repo: Repo) => ({
48  reviews: searchUrl(scoped(repo, 'review-requested:@me draft:false')),
49  mine: searchUrl(scoped(repo, 'author:@me draft:false')),
50  failing: searchUrl(scoped(repo, 'author:@me draft:false status:failure')),
51  approved: searchUrl(scoped(repo, 'author:@me draft:false review:approved')),
52  changes: searchUrl(scoped(repo, 'author:@me draft:false review:changes-requested')),
53  assigned: searchUrl(scoped(repo, 'assignee:@me draft:false')),
54  // Merged PRs included and newest activity first, so the items past the shown few lead the list.
55  recent: searchUrl(`is:pr ${repoQualifier(repo)}involves:@me sort:updated-desc`),
56})
57const LABELS: Record<PrEventKind, string> = {
58  'review-requested': 'review requested',
59  assigned: 'assigned',
60  approved: 'approved',
61  'changes-requested': 'changes requested',
62  merged: 'merged',
63  comment: 'comment',
64  reply: 'reply',
65}
66// Kinds found by a PR entering a list; comments and replies are found by newer activity.
67type ListKind = 'review-requested' | 'assigned' | 'approved' | 'changes-requested' | 'merged'
68const LIST_KINDS: ListKind[] = ['review-requested', 'assigned', 'approved', 'changes-requested', 'merged']
69// List news that goes stale once its PR leaves the list (a merge stays news).
70const LIVE_KINDS = new Set<PrEventKind>(['review-requested', 'assigned', 'approved', 'changes-requested'])
71
72// One GraphQL request (rate-limit cost ~20) instead of one REST search per count. The searches
73// travel as variables, never spliced into the document.
74const QUERY = `query($merged: String!, $reviews: String!, $assigned: String!, $mine: String!, $reviewed: String!) {
75  viewer { login }
76  merged: search(query: $merged, type: ISSUE, first: 20) {
77    nodes { ... on PullRequest { url title number repository { nameWithOwner } } }
78  }
79  reviews: search(query: $reviews, type: ISSUE, first: 100) {
80    issueCount
81    nodes { ... on PullRequest { url title number repository { nameWithOwner } } }
82  }
83  assigned: search(query: $assigned, type: ISSUE, first: 100) {
84    issueCount
85    nodes { ... on PullRequest { url title number repository { nameWithOwner } author { login } } }
86  }
87  mine: search(query: $mine, type: ISSUE, first: 100) {
88    issueCount
89    nodes { ... on PullRequest { url title number repository { nameWithOwner } isDraft reviewDecision
90      commits(last: 1) { nodes { commit { statusCheckRollup { state } } } } ...Activity } }
91  }
92  reviewed: search(query: $reviewed, type: ISSUE, first: 50) {
93    nodes { ... on PullRequest { url title number repository { nameWithOwner } ...Activity } }
94  }
95}
96fragment Activity on PullRequest {
97  comments(last: 3) { nodes { author { __typename login } createdAt url } }
98  reviews(last: 3) { nodes { author { __typename login } state submittedAt url } }
99  reviewThreads(last: 10) { nodes { comments(last: 1) { nodes { author { __typename login } createdAt url } } } }
100}`
101
102const searchesFor = (repo: Repo, since: string) => ({
103  merged: `is:pr is:merged archived:false ${repoQualifier(repo)}author:@me merged:>=${since} sort:updated-desc`,
104  reviews: scoped(repo, 'draft:false review-requested:@me'),
105  assigned: scoped(repo, 'draft:false assignee:@me'),
106  mine: scoped(repo, 'draft:false author:@me'),
107  reviewed: scoped(repo, 'reviewed-by:@me -author:@me sort:updated-desc'),
108})
109
110type GhAuthor = { __typename: string; login: string } | null
111type GhNote = { author: GhAuthor; createdAt?: string; submittedAt?: string | null; state?: string; url: string }
112type GhPr = {
113  url: string
114  title: string
115  number: number
116  repository: { nameWithOwner: string }
117  author?: { login: string } | null
118  isDraft?: boolean
119  reviewDecision?: string | null
120  commits?: { nodes: { commit: { statusCheckRollup: { state: string } | null } }[] }
121  comments?: { nodes: GhNote[] }
122  reviews?: { nodes: GhNote[] }
123  reviewThreads?: { nodes: { comments: { nodes: GhNote[] } }[] }
124}
125type GhSearch = { issueCount?: number; nodes: (GhPr | null)[] }
126// The newest comment, review or thread reply on a PR by someone other than the viewer or a bot.
127// `state` is a review's state (APPROVED, CHANGES_REQUESTED, COMMENTED); absent for a comment.
128type Activity = { at: string; href: string; by: string; state?: string } | null
129const REVIEW_VERBS: Record<string, string> = {
130  APPROVED: 'approved',
131  CHANGES_REQUESTED: 'changes requested',
132  COMMENTED: 'review',
133}
134type WatchedPr = Pr & { activity: Activity }
135type MinePr = WatchedPr & { decision: string | null; checks: string | null }
136type AssignedPr = Pr & { isMine: boolean }
137type Fetched = {
138  merged: Pr[]
139  reviews: Pr[]
140  assigned: AssignedPr[]
141  mine: MinePr[]
142  reviewed: WatchedPr[]
143  totals: { reviews: number; assigned: number; mine: number }
144}
145type Cache = { at: number; data: Fetched }
146type Seen = Record<ListKind, string[]>
147type ActivitySeen = Record<string, string>
148
149const snapshot = atom({ plugin: 'pr-dashboard', key: 'snapshot' } as const, null)
150const fresh = atom({ plugin: 'pr-dashboard', key: 'fresh' } as const, [])
151const isHidden = atom({ plugin: 'pr-dashboard', key: 'isHidden' } as const, false)
152const changes = atom({ plugin: 'pr-dashboard', key: 'changes' } as const, null)
153const isChangesOpen = atom({ plugin: 'pr-dashboard', key: 'isChangesOpen' } as const, true)
154const expanded = atom({ plugin: 'pr-dashboard', key: 'expanded' } as const, [])
155const diffs = atom({ plugin: 'pr-dashboard', key: 'diffs' } as const, {})
156
157const toPr = (pr: GhPr): Pr => ({
158  url: pr.url,
159  title: pr.title,
160  number: pr.number,
161  repo: pr.repository.nameWithOwner,
162})
163
164const isBot = (author: NonNullable<GhAuthor>) => author.__typename === 'Bot' || author.login.endsWith('[bot]')
165
166const latestActivity = (pr: GhPr, me: string): Activity => {
167  const notes = [
168    ...(pr.comments?.nodes ?? []),
169    ...(pr.reviews?.nodes ?? []),
170    ...(pr.reviewThreads?.nodes.flatMap(thread => thread.comments.nodes) ?? []),
171  ]
172  let latest: Activity = null
173  for (const note of notes) {
174    const at = note.createdAt ?? note.submittedAt
175    if (!at || !note.author || isBot(note.author) || note.author.login === me) continue
176    if (latest === null || at > latest.at) {
177      latest = { at, href: note.url, by: note.author.login, ...(note.state && { state: note.state }) }
178    }
179  }
180
181  return latest
182}
183
184// reviewDecision is null when the repo requires no reviews; then the latest reviews decide
185// (only the last three are fetched, so an older approval can be missed).
186const decisionOf = (pr: GhPr): string | null => {
187  if (pr.reviewDecision) return pr.reviewDecision
188  const latestByReviewer = new Map<string, string>()
189  for (const review of pr.reviews?.nodes ?? []) {
190    if (review.author && (review.state === 'APPROVED' || review.state === 'CHANGES_REQUESTED')) {
191      latestByReviewer.set(review.author.login, review.state)
192    }
193  }
194  const states = [...latestByReviewer.values()]
195  if (states.includes('CHANGES_REQUESTED')) return 'CHANGES_REQUESTED'
196
197  return states.includes('APPROVED') ? 'APPROVED' : null
198}
199
200const prsOf = (search: GhSearch | undefined) => (search?.nodes ?? []).filter((pr): pr is GhPr => Boolean(pr?.url))
201
202const fetchPrs = async ($: EngineInterface, repo: Repo): Promise<Fetched> => {
203  const since = new Date((await $.clock.now()) - MERGED_WINDOW_MS).toISOString().slice(0, 10)
204  const variables = Object.entries(searchesFor(repo, since)).flatMap(([name, q]) => ['-f', `${name}=${q}`])
205  const { exitCode, stdout, stderr } = await $.process.run(['gh', 'api', 'graphql', '-f', `query=${QUERY}`, ...variables])
206  if (exitCode !== 0) throw new Error(stderr.trim() || `gh exited ${exitCode}`)
207  const { data } = JSON.parse(stdout) as {
208    data?: {
209      viewer: { login: string }
210      merged?: GhSearch
211      reviews?: GhSearch
212      assigned?: GhSearch
213      mine?: GhSearch
214      reviewed?: GhSearch
215    } | null
216  }
217  if (!data) throw new Error('GitHub returned no data')
218  const me = data.viewer.login
219  const mine = prsOf(data.mine).filter(pr => !pr.isDraft)
220  const reviews = prsOf(data.reviews).filter(pr => !pr.isDraft)
221  const assigned = prsOf(data.assigned)
222
223  return {
224    merged: prsOf(data.merged).map(toPr),
225    reviews: reviews.map(toPr),
226    assigned: assigned.map(pr => ({ ...toPr(pr), isMine: pr.author?.login === me })),
227    mine: mine.map(pr => ({
228      ...toPr(pr),
229      decision: decisionOf(pr),
230      checks: pr.commits?.nodes[0]?.commit.statusCheckRollup?.state ?? null,
231      activity: latestActivity(pr, me),
232    })),
233    reviewed: prsOf(data.reviewed).map(pr => ({ ...toPr(pr), activity: latestActivity(pr, me) })),
234    // GitHub's own totals: the lists above stop at their first page.
235    totals: {
236      reviews: data.reviews?.issueCount ?? reviews.length,
237      assigned: data.assigned?.issueCount ?? assigned.length,
238      mine: data.mine?.issueCount ?? mine.length,
239    },
240  }
241}
242
243const bucket = (data: Fetched): Record<ListKind, Pr[]> & { failing: Pr[] } => ({
244  'review-requested': data.reviews,
245  merged: data.merged,
246  // Self-assigning a PR of one's own is counted but is no news.
247  assigned: data.assigned.filter(pr => !pr.isMine),
248  approved: data.mine.filter(pr => pr.decision === 'APPROVED'),
249  'changes-requested': data.mine.filter(pr => pr.decision === 'CHANGES_REQUESTED'),
250  failing: data.mine.filter(pr => pr.checks === 'FAILURE' || pr.checks === 'ERROR'),
251})
252
253// News found against what was seen last time, and the new baseline to write once the news is saved.
254type Found = { events: PrEvent[]; save: () => Promise<void> }
255
256// Diffs against the URLs seen last time (kept across sessions); the first run only records a baseline.
257const findNew = async ($: EngineInterface, repo: Repo, current: Record<ListKind, Pr[]>): Promise<Found> => {
258  const seen = (await $.store.get(keyFor(KEYS.seen, repo))) as Seen | undefined
259  const next = Object.fromEntries(LIST_KINDS.map(kind => [kind, current[kind].map(pr => pr.url)]))
260  const save = () => $.store.set(keyFor(KEYS.seen, repo), next)
261  if (seen === undefined) return { events: [], save }
262
263  return {
264    events: LIST_KINDS.flatMap(kind =>
265      current[kind].filter(pr => seen[kind] !== undefined && !seen[kind].includes(pr.url)).map(pr => ({ kind, pr })),
266    ),
267    save,
268  }
269}
270
271// A PR seen before ('' = no activity then) whose newest activity is newer is a new comment.
272// A PR seen for the first time only records its baseline.
273const findActivity = async ($: EngineInterface, repo: Repo, data: Fetched): Promise<Found> => {
274  const seen = ((await $.store.get(keyFor(KEYS.activity, repo))) as ActivitySeen | undefined) ?? {}
275  const watched = [
276    ...data.mine.map(pr => ({ kind: 'comment' as const, pr })),
277    ...data.reviewed.map(pr => ({ kind: 'reply' as const, pr })),
278  ]
279  const next = Object.fromEntries(watched.map(({ pr }) => [pr.url, pr.activity?.at ?? '']))
280
281  return {
282    events: watched.flatMap(({ kind, pr: { activity, ...pr } }) => {
283      const before = seen[pr.url]
284      if (activity === null || before === undefined || activity.at <= before) return []
285
286      return [{ kind, pr, href: activity.href, by: activity.by, ...(activity.state && { state: activity.state }) }]
287    }),
288    save: () => $.store.set(keyFor(KEYS.activity, repo), next),
289  }
290}
291
292const STATE_OF_KIND: Partial<Record<PrEventKind, string>> = { approved: 'APPROVED', 'changes-requested': 'CHANGES_REQUESTED' }
293
294// One item per happening: a merge replaces the PR's other news, and a review that moved the
295// PR into approved / changes requested is that one item, carrying who did it.
296const combine = (events: PrEvent[]): PrEvent[] => {
297  const merged = new Set(events.filter(e => e.kind === 'merged').map(e => e.pr.url))
298  const kept = events.filter(e => e.kind === 'merged' || !merged.has(e.pr.url))
299  const reviewFor = (event: PrEvent) =>
300    kept.find(e => e.kind === 'comment' && e.pr.url === event.pr.url && e.state === STATE_OF_KIND[event.kind])
301
302  return kept.flatMap(event => {
303    if (STATE_OF_KIND[event.kind]) {
304      const review = reviewFor(event)
305      return [review ? { ...event, href: review.href, by: review.by } : event]
306    }
307    const isCovered =
308      event.kind === 'comment' &&
309      kept.some(e => STATE_OF_KIND[e.kind] !== undefined && e.pr.url === event.pr.url && event.state === STATE_OF_KIND[e.kind])
310
311    return isCovered ? [] : [event]
312  })
313}
314
315// An approval or change request is not a comment, even when it arrives as activity.
316const isComment = (event: PrEvent) =>
317  (event.kind === 'comment' || event.kind === 'reply') && event.state !== 'APPROVED' && event.state !== 'CHANGES_REQUESTED'
318
319const verbOf = (event: PrEvent) => (event.state ? REVIEW_VERBS[event.state] : undefined) ?? LABELS[event.kind]
320
321const clockTime = (ms: number) => new Date(ms).toTimeString().slice(0, 5)
322
323const shortError = (message: string, retryAt: number | null) => {
324  if (/rate limit/i.test(message)) return `GitHub rate limit${retryAt === null ? '' : `, retrying at ${clockTime(retryAt)}`}`
325  const line = message.split('\n')[0] ?? message
326
327  return line.length > 80 ? `${line.slice(0, 79)}…` : line
328}
329
330// This session's next timer tick, so a rate-limit message names when it really retries.
331let nextTickAt = 0
332
333const readFresh = async ($: EngineInterface, repo: Repo) =>
334  ((await $.store.get(keyFor(KEYS.fresh, repo))) as PrEvent[] | undefined) ?? []
335
336const fetchAndDiff = async ($: EngineInterface, repo: Repo, now: number): Promise<Fetched> => {
337  await $.store.set(keyFor(KEYS.lease, repo), now + LEASE_MS)
338  try {
339    const data = await fetchPrs($, repo)
340    await $.store.set(keyFor(KEYS.cache, repo), { at: now, data } satisfies Cache)
341    await $.store.delete(BACKOFF_KEY)
342    const found = [await findNew($, repo, bucket(data)), await findActivity($, repo, data)]
343    const events = combine(found.flatMap(f => f.events))
344    if (events.length > 0) {
345      const stored = await readFresh($, repo)
346      await $.store.set(keyFor(KEYS.fresh, repo), [
347        ...events,
348        ...stored.filter(old => !events.some(e => e.pr.url === old.pr.url && (e.kind === old.kind || e.kind === 'merged'))),
349      ])
350      const [first] = events
351      $.ui.toast(
352        events.length === 1 && first
353          ? `PR ${verbOf(first)}${first.by ? ` by ${first.by}` : ''}: #${first.pr.number} ${first.pr.title}`
354          : `${events.length} PR updates`,
355      )
356    }
357    // Baselines move only once the news they found is saved.
358    for (const f of found) await f.save()
359
360    return data
361  } finally {
362    await $.store.delete(keyFor(KEYS.lease, repo))
363  }
364}
365
366const refresh = async ($: EngineInterface, { force = false } = {}) => {
367  const now = await $.clock.now()
368  let repo: Repo = null
369  try {
370    repo = await resolveRepo($)
371    const cache = (await $.store.get(keyFor(KEYS.cache, repo))) as Cache | undefined
372    const backoffUntil = Number((await $.store.get(BACKOFF_KEY)) ?? 0)
373    const leaseUntil = Number((await $.store.get(keyFor(KEYS.lease, repo))) ?? 0)
374    const isStale = cache === undefined || now - cache.at >= CACHE_MS
375    let data = cache?.data
376    let checkedAt = cache?.at ?? now
377    if (force || (isStale && now >= backoffUntil && now >= leaseUntil)) {
378      data = await fetchAndDiff($, repo, now)
379      checkedAt = now
380    } else if (isStale && now < leaseUntil) {
381      // Another session is fetching: show its result once it lands.
382      $.clock.after(LEASE_RETRY_MS, () => void refresh($))
383    }
384    // During a backoff or a lease this draws the last cache (or nothing yet); the fetch waits.
385    if (data === undefined) return
386
387    const buckets = bucket(data)
388    const counts: Counts = {
389      reviews: data.totals.reviews,
390      assigned: data.totals.assigned,
391      mine: data.totals.mine,
392      failing: buckets.failing,
393      approved: buckets.approved.length,
394      changes: buckets['changes-requested'].length,
395    }
396    // News goes stale when its PR merged, or left the list that made it news (no longer requested,
397    // assigned, approved...). The rest is kept newest first, up to a cap.
398    const merged = new Set(data.merged.map(pr => pr.url))
399    const stillListed = (event: PrEvent) =>
400      !LIVE_KINDS.has(event.kind) || buckets[event.kind as ListKind].some(pr => pr.url === event.pr.url)
401    const stored = await readFresh($, repo)
402    const current = stored
403      .filter(e => (e.kind === 'merged' || !merged.has(e.pr.url)) && stillListed(e))
404      .slice(0, MAX_FRESH_KEPT)
405    if (current.length !== stored.length) await $.store.set(keyFor(KEYS.fresh, repo), current)
406    await update($, fresh, () => current)
407    await update($, snapshot, () => ({ repo, counts, checkedAt }))
408  } catch (error) {
409    const message = error instanceof Error ? error.message : String(error)
410    const isRateLimited = /rate limit/i.test(message)
411    let retryAt: number | null = null
412    if (isRateLimited) {
413      const backoffUntil = now + RATE_LIMIT_BACKOFF_MS
414      await $.store.set(BACKOFF_KEY, backoffUntil).catch(() => undefined)
415      // The first tick of this session's timer at or after the backoff.
416      retryAt = nextTickAt
417      while (retryAt < backoffUntil) retryAt += REFRESH_MS
418    }
419    await update($, snapshot, prev => ({
420      repo,
421      counts: prev?.counts ?? null,
422      checkedAt: prev?.checkedAt ?? now,
423      error: shortError(message, retryAt),
424    }))
425  }
426}
427
428const dismiss = async ($: EngineInterface) => {
429  const snap = await read($, snapshot)
430  await $.store.set(keyFor(KEYS.fresh, snap?.repo ?? null), [])
431  await update($, fresh, () => [])
432}
433
434const newsLabel = (event: PrEvent) =>
435  `${verbOf(event)}${event.by ? ` by ${event.by}` : ''} #${event.pr.number} ${event.pr.title}`
436// Keeps a path's end (the file name), cutting from the front.
437const cutStart = (text: string, width: number) => (text.length > width ? `…${text.slice(text.length - width + 1)}` : text)
438const cut = (text: string, width: number) => (text.length > width ? `${text.slice(0, width - 1)}…` : text)
439const escapeMarkdown = (text: string) => text.replace(/[\\`*_[\]()<>#|~]/g, ch => `\\${ch}`)
440
441const isSame = (a: PrEvent, b: PrEvent) => a.kind === b.kind && a.pr.url === b.pr.url
442
443const removeEvent = async ($: EngineInterface, event: PrEvent) => {
444  const snap = await read($, snapshot)
445  const key = keyFor(KEYS.fresh, snap?.repo ?? null)
446  const stored = ((await $.store.get(key)) as PrEvent[] | undefined) ?? []
447  await $.store.set(key, stored.filter(e => !isSame(e, event)))
448  await update($, fresh, list => list.filter(e => !isSame(e, event)))
449}
450
451// Opens a GitHub URL in the host's browser: macOS `open`, else `xdg-open`.
452const openUrl = async ($: EngineInterface, url: string) => {
453  if (!url.startsWith('https://github.com/')) return false
454  for (const opener of ['open', 'xdg-open']) {
455    const run = await $.process.run([opener, url]).catch(() => null)
456    if (run?.exitCode === 0) return true
457  }
458
459  return false
460}
461
462// The surface opens nothing for a link the plugin answers, so this opens it, and clears the item
463// only once it opened.
464const openEvent = async ($: EngineInterface, event: PrEvent) => {
465  if (await openUrl($, event.href ?? event.pr.url)) await removeEvent($, event)
466  else $.ui.toast(`Couldn't open ${event.href ?? event.pr.url}`)
467}
468
469// `git diff --numstat -z`: `added\tdeleted\tpath\0`, or for a rename `added\tdeleted\t\0old\0new\0`.
470const parseNumstat = (out: string): Change[] => {
471  const parts = out.split('\0')
472  const list: Change[] = []
473  for (let i = 0; i < parts.length; i++) {
474    const record = parts[i]
475    if (!record) continue
476    const [added = '-', deleted = '-', path = ''] = record.split('\t')
477    const name = path === '' ? `${parts[i + 1] ?? ''} → ${parts[i + 2] ?? ''}` : path
478    if (path === '') i += 2
479    list.push({
480      path: name,
481      added: added === '-' ? null : Number(added),
482      deleted: deleted === '-' ? null : Number(deleted),
483      isNew: false,
484    })
485  }
486
487  return list
488}
489
490// Uncommitted work against HEAD (staged and unstaged) plus untracked files; local git only.
491// Before the first commit there is no HEAD: staged files are then diffed against nothing.
492const readChanges = async ($: EngineInterface): Promise<Change[] | null> => {
493  const run = (argv: string[]) => $.process.run(argv).catch(() => null)
494  let diff = await run(['git', 'diff', 'HEAD', '--numstat', '-z'])
495  if (diff?.exitCode !== 0) {
496    const inside = await run(['git', 'rev-parse', '--is-inside-work-tree'])
497    if (inside?.exitCode !== 0) return null
498    diff = await run(['git', 'diff', '--cached', '--numstat', '-z'])
499  }
500  // Each new file on its own row, even inside a new folder.
501  const untracked = await run(['git', 'ls-files', '--others', '--exclude-standard', '-z'])
502  const tracked = diff?.exitCode === 0 ? parseNumstat(diff.stdout) : []
503  const added = (untracked?.exitCode === 0 ? untracked.stdout.split('\0').filter(Boolean) : []).map(path => ({
504    path,
505    added: null,
506    deleted: null,
507    isNew: true,
508  }))
509
510  return [...tracked, ...added]
511}
512
513// A rename is listed as `old → new`.
514const filesOf = (change: Change) => change.path.split(' → ')
515
516// Highlighter language ids by file extension, named outright so a diff is coloured by its code.
517// An extension not listed is left to the engine, which infers it from the path.
518const LANGUAGES: Record<string, string> = {
519  js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript',
520  ts: 'typescript', tsx: 'typescript', mts: 'typescript', cts: 'typescript',
521  php: 'php', py: 'python', rb: 'ruby', go: 'go', rs: 'rust', java: 'java', kt: 'kotlin', swift: 'swift',
522  c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', hpp: 'cpp', cs: 'csharp',
523  css: 'css', scss: 'scss', less: 'less', html: 'xml', htm: 'xml', xml: 'xml', svg: 'xml',
524  json: 'json', yml: 'yaml', yaml: 'yaml', md: 'markdown', sh: 'bash', bash: 'bash', zsh: 'bash', sql: 'sql',
525}
526const languageOf = (file: string) => LANGUAGES[file.split('.').at(-1)?.toLowerCase() ?? '']
527
528// Whole hunks from git's output (its header lines dropped), up to the line cap.
529const toDiff = (out: string): Diff => {
530  const start = out.startsWith('@@') ? 0 : out.indexOf('\n@@') + 1
531  if (start === 0 && !out.startsWith('@@')) return { source: '', omittedLines: 0 }
532  const hunks = out.slice(start).replace(/\n$/, '').split(/\n(?=@@ )/)
533  const kept: string[] = []
534  let lines = 0
535  let omittedLines = 0
536  for (const hunk of hunks) {
537    const size = hunk.split('\n').length
538    if (kept.length > 0 && lines + size > MAX_DIFF_LINES) omittedLines += size
539    else {
540      kept.push(hunk)
541      lines += size
542    }
543  }
544
545  return { source: kept.join('\n'), omittedLines }
546}
547
548const readDiff = async ($: EngineInterface, change: Change): Promise<Diff> => {
549  const files = filesOf(change)
550  const [file = change.path] = files.slice(-1)
551  const run = (argv: string[]) => $.process.run(argv).catch(() => null)
552  // `--` keeps a path from reading as an option. An untracked file diffs against nothing
553  // (`--no-index` exits 1 when they differ); before the first commit there is no HEAD.
554  const out = change.isNew
555    ? await run(['git', 'diff', '--no-index', '--', '/dev/null', file])
556    : await run(['git', 'diff', 'HEAD', '-M', '--', ...files])
557  const text =
558    out && (out.exitCode === 0 || (change.isNew && out.exitCode === 1))
559      ? out.stdout
560      : ((await run(['git', 'diff', '--cached', '-M', '--', ...files]))?.stdout ?? '')
561
562  return toDiff(text)
563}
564
565// Re-reads every open diff, dropping those whose file is no longer changed.
566const refreshDiffs = async ($: EngineInterface) => {
567  const list = (await read($, changes)) ?? []
568  const open = (await read($, expanded)).filter(path => list.some(c => c.path === path))
569  const next: Record<string, Diff> = {}
570  for (const path of open) {
571    const change = list.find(c => c.path === path)
572    if (change) next[path] = await readDiff($, change)
573  }
574  await update($, expanded, () => open)
575  await update($, diffs, () => next)
576}
577
578const showDiff = async ($: EngineInterface, change: Change) => {
579  const diff = await readDiff($, change)
580  await update($, diffs, all => ({ ...all, [change.path]: diff }))
581  await update($, expanded, open => (open.includes(change.path) ? open : [...open, change.path]))
582  await openChangesPane($, { isWide: true })
583}
584
585const toggleDiff = async ($: EngineInterface, change: Change) => {
586  if ((await read($, expanded)).includes(change.path)) {
587    await update($, expanded, open => open.filter(path => path !== change.path))
588  } else await showDiff($, change)
589}
590
591// One read at a time; a request while one runs reruns once after it, so the last read wins in order.
592let isReading = false
593let isRerunWanted = false
594const refreshChanges = async ($: EngineInterface): Promise<void> => {
595  if (isReading) {
596    isRerunWanted = true
597    return
598  }
599  isReading = true
600  try {
601    const list = await readChanges($)
602    await update($, changes, () => list)
603    await refreshDiffs($)
604  } finally {
605    isReading = false
606  }
607  if (isRerunWanted) {
608    isRerunWanted = false
609    await refreshChanges($)
610  }
611}
612
613const totalsOf = (list: readonly Change[]) => ({
614  added: list.reduce((sum, c) => sum + (c.added ?? 0), 0),
615  deleted: list.reduce((sum, c) => sum + (c.deleted ?? 0), 0),
616})
617
618// Docked beside the transcript, a pane showing code asks for more room than a file list needs.
619const DIFF_PANE_COLUMNS = 100
620const openChangesPane = ($: EngineInterface, { isWide = false } = {}) =>
621  $.ui.open({ ...CHANGES_PANE, ...(isWide && { columns: DIFF_PANE_COLUMNS }) }).catch(() => undefined)
622
623// A diff's hunks as runs of like lines, each numbered from its first line: removed lines by the
624// old file, the rest by the new. Drawn as plain code, a run keeps its language's colours.
625type DiffRun = { header: string } | { kind: 'context' | 'added' | 'removed'; start: number; lines: string[] }
626const toRuns = (source: string): DiffRun[] => {
627  const runs: DiffRun[] = []
628  let oldLine = 0
629  let newLine = 0
630  for (const line of source.split('\n')) {
631    const hunk = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line)
632    if (hunk) {
633      oldLine = Number(hunk[1])
634      newLine = Number(hunk[2])
635      runs.push({ header: line })
636      continue
637    }
638    const kind = line.startsWith('+') ? 'added' : line.startsWith('-') ? 'removed' : line.startsWith(' ') || line === '' ? 'context' : null
639    // `\ No newline at end of file` and anything else unexpected.
640    if (kind === null) continue
641    const number = kind === 'removed' ? oldLine++ : kind === 'added' ? newLine++ : (oldLine++, newLine++)
642    const last = runs.at(-1)
643    if (last && 'kind' in last && last.kind === kind) last.lines.push(line.slice(1))
644    else runs.push({ kind, start: number, lines: [line.slice(1)] })
645  }
646
647  return runs
648}
649
650type DiffRunsProps = {
651  diff: Diff
652  path: string
653  Box: Elements['desktop']['Box'] | Elements['vscode']['Box'] | Elements['mobile']['Box'] | Elements['terminal']['Box']
654  Code: Elements['desktop']['Code'] | Elements['vscode']['Code'] | Elements['mobile']['Code'] | Elements['terminal']['Code']
655  Text: Elements['desktop']['Text'] | Elements['vscode']['Text'] | Elements['mobile']['Text'] | Elements['terminal']['Text']
656}
657
658// The desktop draws a `format: 'diff'` Code in add/remove colours only, so there a diff is drawn
659// as runs of plain code: a coloured bar outside the code marks added and removed lines.
660const DiffRuns = ({ diff, path, Box, Code, Text }: DiffRunsProps) => (
661  <Box flexDirection="column" width="100%" overflow="hidden">
662    {toRuns(diff.source).map((run, i) =>
663      'header' in run ? (
664        <Text key={`hunk:${i}`} dimColor size="caption" wrap="truncate-end">
665          {run.header}
666        </Text>
667      ) : (
668        <Box
669          key={`run:${i}`}
670          flexDirection="row"
671          {...(run.kind !== 'context' && { backgroundColor: run.kind === 'added' ? 'diffAddedDimmed' : 'diffRemovedDimmed' })}
672        >
673          <Box
674            width={1}
675            flexShrink={0}
676            {...(run.kind !== 'context' && { backgroundColor: run.kind === 'added' ? 'diffAdded' : 'diffRemoved' })}
677          />
678          <Box flexGrow={1} minWidth={0} overflow="hidden" paddingLeft={1}>
679            <Code
680              key={`code:${path}:${i}`}
681              source={run.lines.join('\n')}
682              path={path}
683              startLine={run.start}
684              wrap="truncate-end"
685              {...(languageOf(path) && { language: languageOf(path) })}
686            />
687          </Box>
688        </Box>
689      ),
690    )}
691  </Box>
692)
693
694type ChangeStatProps = {
695  change: Change
696  Box: Elements['terminal']['Box'] | Elements['desktop']['Box'] | Elements['vscode']['Box'] | Elements['mobile']['Box']
697  Text: Elements['terminal']['Text'] | Elements['desktop']['Text'] | Elements['vscode']['Text'] | Elements['mobile']['Text']
698}
699
700// A file's +/- (or new / bin), shared by the band and the pane.
701const ChangeStat = ({ change, Box, Text }: ChangeStatProps) =>
702  change.isNew ? (
703    <Text color="diffAdded" size="caption">new</Text>
704  ) : change.added === null ? (
705    <Text dimColor size="caption">bin</Text>
706  ) : (
707    <Box flexShrink={0} columnGap={1}>
708      <Text color="diffAdded" size="caption">+{change.added}</Text>
709      <Text color="diffRemoved" size="caption">−{change.deleted}</Text>
710    </Box>
711  )
712
713// Tools that can change the working tree; the turn's end catches anything else.
714const WRITING_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'Bash'])
715// Polling starts once something draws: at session start under the terminal REPL, else on the
716// first band draw (the desktop app runs sessions through the SDK, which reports no surface at
717// start). A `-p` run never draws, so it never polls.
718let isStarted = false
719
720const start = async ($: EngineInterface) => {
721  if (isStarted) return
722  isStarted = true
723  nextTickAt = (await $.clock.now()) + REFRESH_MS
724  $.clock.every(REFRESH_MS, () => {
725    nextTickAt += REFRESH_MS
726    void refresh($)
727  })
728  void refresh($)
729  // The pane opens only when asked: a file, ⧉ or /changes.
730  void refreshChanges($)
731}
732
733export const register: Register = on => {
734  on('session.start', async ($, e, next) => {
735    if (e.isInteractive) await start($)
736    void $.store
737      .keys()
738      .then(keys => Promise.all(keys.filter(isRetiredKey).map(key => $.store.delete(key))))
739      .catch(() => undefined)
740    // A refused registration must not cost the session its pane and polling.
741    await $.command.register({ name: CHANGES_COMMAND, description: 'Show uncommitted changes in a side pane' }).catch(() => undefined)
742
743    return next(e)
744  })
745
746  on('command.run', { command: CHANGES_COMMAND }, async $ => {
747    await refreshChanges($)
748    await openChangesPane($)
749
750    return { text: 'Opened the Uncommitted pane.' }
751  })
752
753  on('ui.render', { component: 'Pane', requestId: CHANGES_PANE.id }, async ($, e) => {
754    const { Box, Button, Code, Text } = $.ui.resolve(e)
755    const changed = await read($, changes)
756    const open = await read($, expanded)
757    const diffOf = await read($, diffs)
758    const width = Math.max(20, Math.floor(e.props.bodyColumns * (e.surface === 'terminal' ? 1 : 1.3)) - 10)
759    const { added, deleted } = totalsOf(changed ?? [])
760
761    return (
762      <Box flexDirection="column">
763        <Box flexDirection="row" columnGap={2}>
764          <Text dimColor>
765            {changed === null
766              ? 'Not a git repository'
767              : changed.length === 0
768                ? 'No uncommitted changes'
769                : `${changed.length} ${changed.length === 1 ? 'file' : 'files'}`}
770          </Text>
771          {changed !== null && changed.length > 0 && (
772            <Box flexShrink={0} columnGap={1}>
773              <Text color="diffAdded">+{added}</Text>
774              <Text color="diffRemoved">−{deleted}</Text>
775            </Box>
776          )}
777          <Button key="pane-refresh" plain onPress={() => void refreshChanges($)}>
778            <Text dimColor size="caption">↻</Text>
779          </Button>
780        </Box>
781        {(changed ?? []).slice(0, MAX_CHANGES_IN_PANE).map(change => {
782          const isExpanded = open.includes(change.path)
783          const diff = diffOf[change.path]
784          return (
785            // An open file is a framed card, set apart from its neighbours.
786            <Box
787              key={`pane:${change.path}`}
788              flexDirection="column"
789              // Held to the pane's width, so one long line cannot push the card past its edge.
790              {...(isExpanded && { borderStyle: 'round', borderDimColor: true, paddingX: 1, marginY: 1, width: '100%', overflow: 'hidden' })}
791            >
792              <Box flexDirection="row" columnGap={1}>
793                <ChangeStat change={change} Box={Box} Text={Text} />
794                {/* Clicking a file shows or hides its diff below it. */}
795                <Button key={`diff:${change.path}`} plain onPress={() => void toggleDiff($, change)}>
796                  {isExpanded ? (
797                    <Text weight="semibold">▾ {cutStart(change.path, width - 6)}</Text>
798                  ) : (
799                    <Text dimColor size="caption">▸ {cutStart(change.path, width - 2)}</Text>
800                  )}
801                </Button>
802              </Box>
803              {isExpanded && diff && diff.source !== '' && e.surface !== 'terminal' && (
804                <DiffRuns diff={diff} path={filesOf(change).at(-1) ?? change.path} Box={Box} Code={Code} Text={Text} />
805              )}
806              {isExpanded && diff && diff.source !== '' && e.surface === 'terminal' && (
807                // One row per line, cut at the edge; tokens are coloured by the (new) file's language.
808                <Code
809                  key={`code:${change.path}`}
810                  source={diff.source}
811                  format="diff"
812                  wrap="truncate-end"
813                  path={filesOf(change).at(-1) ?? change.path}
814                  {...(languageOf(filesOf(change).at(-1) ?? change.path) && {
815                    language: languageOf(filesOf(change).at(-1) ?? change.path),
816                  })}
817                />
818              )}
819              {isExpanded && diff && diff.source === '' && (
820                <Text dimColor size="caption">No text diff (binary, or empty)</Text>
821              )}
822              {isExpanded && diff && diff.omittedLines > 0 && (
823                <Text dimColor size="caption">+{diff.omittedLines} more lines</Text>
824              )}
825            </Box>
826          )
827        })}
828        {(changed ?? []).length > MAX_CHANGES_IN_PANE && (
829          <Text dimColor size="caption">+{(changed ?? []).length - MAX_CHANGES_IN_PANE} more</Text>
830        )}
831      </Box>
832    )
833  })
834
835  on('tool.call', async ($, e, next) => {
836    const result = await next(e)
837    if (isStarted && WRITING_TOOLS.has(String(e.tool))) void refreshChanges($)
838
839    return result
840  })
841
842  on('turn.complete', async ($, e, next) => {
843    const result = await next(e)
844    if (isStarted) void refreshChanges($)
845
846    return result
847  })
848
849  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
850    // A surface is drawing: start polling, from a timer since a draw may not write state.
851    if (!isStarted) $.clock.after(0, () => void start($))
852    const snap = await read($, snapshot)
853    const changed = (await read($, changes)) ?? []
854    // Uncommitted shows on its own while the PR data is still on its way.
855    if (e.props.hasSurvey || (snap === null && changed.length === 0)) return next(e)
856
857    const isPrsHidden = await read($, isHidden)
858    const events = snap ? await read($, fresh) : []
859    const newReviews = events.filter(event => event.kind === 'review-requested').length
860    const commentEvents = events.filter(isComment)
861    const [firstComment] = commentEvents
862    const { Box, Button, Link, Markdown, Text } = $.ui.resolve(e)
863    // One line per item, cut to the band's width. bodyColumns counts terminal cells; other surfaces
864    // draw a proportional font that fits about 1.3 characters per cell (measured on the desktop app).
865    const charsPerCell = e.surface === 'terminal' ? 1 : 1.3
866    const itemWidth = Math.max(20, Math.floor(e.props.bodyColumns * charsPerCell) - 6)
867    const counts = snap?.counts ?? null
868    const LINKS = linksFor(snap?.repo ?? null)
869    const isOpen = await read($, isChangesOpen)
870    const { added, deleted } = totalsOf(changed)
871
872    return (
873      <Box flexDirection="column">
874        {snap && isPrsHidden && (
875          <Button key="show" plain onPress={() => update($, isHidden, () => false)}>
876            <Text dimColor size="caption">▸ PRs</Text>
877          </Button>
878        )}
879        {snap && !isPrsHidden && (
880          // Items keep their width and wrap as a whole onto the next line rather than breaking.
881          <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
882            <Box flexShrink={0}>
883              <Text dimColor>{snap.repo ? snap.repo.split('/')[1] : 'all repos'}</Text>
884            </Box>
885            {counts && (
886              <Box flexShrink={0}>
887                <Link href={LINKS.reviews}>
888                  {newReviews > 0 ? `● Reviews ${counts.reviews} (+${newReviews} new)` : `Reviews ${counts.reviews}`}
889                </Link>
890              </Box>
891            )}
892            {counts && (
893              <Box flexShrink={0}>
894                <Link href={LINKS.mine}>My PRs {counts.mine}</Link>
895              </Box>
896            )}
897            {counts && counts.assigned > 0 && (
898              <Box flexShrink={0}>
899                <Link href={LINKS.assigned}>⇢ Assigned {counts.assigned}</Link>
900              </Box>
901            )}
902            {counts && counts.approved > 0 && (
903              <Box flexShrink={0}>
904                <Link href={LINKS.approved}>✓ Approved {counts.approved}</Link>
905              </Box>
906            )}
907            {counts && counts.changes > 0 && (
908              <Box flexShrink={0}>
909                <Link href={LINKS.changes}>✎ Changes {counts.changes}</Link>
910              </Box>
911            )}
912            {counts && counts.failing.length > 0 && (
913              <Box flexShrink={0}>
914                {/* One failing PR opens directly; GitHub's status search can lag behind the PR itself. */}
915                <Link href={counts.failing.length === 1 ? (counts.failing[0]?.url ?? LINKS.failing) : LINKS.failing}>
916                  ✗ CI {counts.failing.length}
917                </Link>
918              </Box>
919            )}
920            {firstComment && commentEvents.length === 1 && (
921              <Box flexShrink={0}>
922                <Markdown
923                  key="comments"
924                  text={`[💬 1 new](${firstComment.href ?? firstComment.pr.url})`}
925                  pressableLinks={[firstComment.href ?? firstComment.pr.url]}
926                  onLinkPress={() => void openEvent($, firstComment)}
927                />
928              </Box>
929            )}
930            {commentEvents.length > 1 && (
931              <Box flexShrink={0}>
932                <Link href={LINKS.recent}>💬 {commentEvents.length} new</Link>
933              </Box>
934            )}
935            <Box flexShrink={0}>
936              {/* A clock time: the band redraws on new data, not every minute, so "Nm ago" would go stale. */}
937              <Text dimColor size="caption">{snap.error ?? `at ${clockTime(snap.checkedAt)}`}</Text>
938            </Box>
939            <Box flexShrink={0} columnGap={1}>
940              <Button key="refresh" plain onPress={() => void refresh($, { force: true })}>
941                <Text dimColor size="caption">↻</Text>
942              </Button>
943              <Button key="hide" plain onPress={() => update($, isHidden, () => true)}>
944                <Text dimColor size="caption">hide</Text>
945              </Button>
946            </Box>
947          </Box>
948        )}
949        {!isPrsHidden && events.length > 0 && (
950          <Box flexDirection="column">
951            <Box flexDirection="row" gap={2}>
952              <Text dimColor>New:</Text>
953              {events.length > MAX_FRESH_SHOWN && (
954                <Link href={LINKS.recent}>+{events.length - MAX_FRESH_SHOWN} more</Link>
955              )}
956              <Button key="dismiss" plain label="dismiss" onPress={() => void dismiss($)} />
957            </Box>
958            {events.slice(0, MAX_FRESH_SHOWN).map(event => {
959              const href = event.href ?? event.pr.url
960              // A Markdown link is a real link that also reports the press, so a click can clear it.
961              const id = `${event.kind}:${event.pr.url}`
962              return (
963                <Box key={`row:${id}`} flexDirection="row" columnGap={1}>
964                  <Markdown
965                    key={`news:${id}`}
966                    text={`[${escapeMarkdown(cut(newsLabel(event), itemWidth))}](${href})`}
967                    pressableLinks={[href]}
968                    onLinkPress={() => void openEvent($, event)}
969                  />
970                  <Button key={`drop:${id}`} plain onPress={() => void removeEvent($, event)}>
971                    <Text dimColor size="caption">×</Text>
972                  </Button>
973                </Box>
974              )
975            })}
976          </Box>
977        )}
978        {changed.length > 0 && (
979          <Box flexDirection="column">
980            <Box flexDirection="row" columnGap={2}>
981              <Button key="changes" plain onPress={() => update($, isChangesOpen, open => !open)}>
982                <Text dimColor>
983                  {isOpen ? '▾' : '▸'} Uncommitted: {changed.length} {changed.length === 1 ? 'file' : 'files'}
984                </Text>
985              </Button>
986              <Box flexShrink={0} columnGap={1}>
987                <Text color="diffAdded">+{added}</Text>
988                <Text color="diffRemoved">−{deleted}</Text>
989              </Box>
990              <Button key="changes-pane" plain onPress={() => void openChangesPane($)}>
991                <Text dimColor size="caption">⧉</Text>
992              </Button>
993            </Box>
994            {isOpen &&
995              changed.slice(0, MAX_CHANGES_SHOWN).map(change => (
996                <Box key={`change:${change.path}`} flexDirection="row" columnGap={1}>
997                  <ChangeStat change={change} Box={Box} Text={Text} />
998                  {/* Opens the pane with this file's diff shown. */}
999                  <Button
1000                    key={`band-file:${change.path}`}
1001                    plain
1002                    onPress={() => void showDiff($, change)}
1003                  >
1004                    <Text dimColor size="caption">{cutStart(change.path, itemWidth - 10)}</Text>
1005                  </Button>
1006                </Box>
1007              ))}
1008            {isOpen && changed.length > MAX_CHANGES_SHOWN && (
1009              // The pane lists every file.
1010              <Button key="changes-more" plain onPress={() => void openChangesPane($)}>
1011                <Text dimColor size="caption">+{changed.length - MAX_CHANGES_SHOWN} more</Text>
1012              </Button>
1013            )}
1014          </Box>
1015        )}
1016      </Box>
1017    )
1018  })
1019}
1020
types/index.d.ts 47 lines
1export type Pr = { url: string; title: string; number: number; repo: string }
2
3export type PrEventKind = 'review-requested' | 'assigned' | 'approved' | 'changes-requested' | 'merged' | 'comment' | 'reply'
4
5// `href` and `by` point at the comment or review itself; `state` is a review's (APPROVED, CHANGES_REQUESTED, COMMENTED).
6export type PrEvent = { kind: PrEventKind; pr: Pr; href?: string; by?: string; state?: string }
7
8export type Counts = {
9  reviews: number
10  assigned: number
11  mine: number
12  failing: Pr[]
13  approved: number
14  changes: number
15}
16
17export type Snapshot = {
18  // owner/name of the session's GitHub repo; null when the session has none (every repo shown).
19  repo: string | null
20  counts: Counts | null
21  checkedAt: number
22  error?: string
23}
24
25// One uncommitted file against HEAD: lines added / deleted (null for a binary), or untracked.
26export type Change = { path: string; added: number | null; deleted: number | null; isNew: boolean }
27
28// A file's diff as unified hunks ('' when there is none to show: binary, a new folder), and the
29// lines left out past the cap.
30export type Diff = { source: string; omittedLines: number }
31
32declare module 'claude-code' {
33  interface PluginState {
34    'pr-dashboard': {
35      snapshot: Snapshot | null
36      fresh: PrEvent[]
37      isHidden: boolean
38      // null outside a git repo.
39      changes: Change[] | null
40      isChangesOpen: boolean
41      // Paths whose diff is open in the pane, and each one's diff as last read.
42      expanded: string[]
43      diffs: Record<string, Diff>
44    }
45  }
46}
47