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…

A Claude Code mod that shows, above the prompt, your GitHub PRs for the session's repo and your uncommitted changes.
/changes for a side pane with each file's diff.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.
claude plugin validate .
claude plugin test .hooks/register.tsx 1020 lines1import { 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}
1020types/index.d.ts 47 lines1export 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