Live GitHub pane for the current repo: PRs with CI and review state, workflow runs, issues and what needs you, plus repo notifications; details, approve…

Claude Code mods by ReesMehorst.
/plugin marketplace add ReesMehorst/claude-mods
/plugin install usage-band@reesmehorst
/plugin install github-pane@reesmehorst
Answer y to add the marketplace, then pick a scope.
A band above the prompt that shows where your session stands.
pace → 120%) and when you'll hit 100% at that pace (hits 100% ~16:40). Session cost is shown muted as "API value".$/hr. Set a budget under /config → API budget (USD) for a budget bar and ETA./usage-band toggles a compact one-line view (remembered across sessions)./github opens a live pane for the repo you're in (needs the gh CLI, signed in).
1–5, r refresh. In the desktop app the tabs are one segmented row with a count pill each (red when the tab has failing items); in a narrow pane they become a full-width View dropdown and rows drop the branch.c checkout a PR, x rerun failed jobs, l show the failed-step log inline, e mark a notification read.f Fix with Claude puts the failed log and a fix request in the prompt box as a draft. Nothing is sent until you press Enter.d details (diff stat, mergeable state, reviewers, description), a approve (not on your own PRs), y mark a draft ready, m squash-merge and delete the branch. Merge asks for a second m press; changing the selection or Close cancels it. In the desktop app the first press shows a confirm card with Merge and Cancel instead.#n, branch or author; switching tabs clears it. An empty tab says why it is empty.gh signed out pauses polling until r.claude plugin validate ./usage-band
claude plugin test ./usage-bandhooks/register.tsx 434 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Detail, PrParts, Row, Snapshot, Tab, Tone } from '../types'
5import type { Empty, Pill, Seen } from './format'
6import { confirmPress, desktopRow, emptyState, failedRunId, fixPrompt, issueRow, logTail, matches, nextDelay, notifRow, prDetail, prRow, prText, runRow, tabCount, tabOption, toasts } from './format'
7
8const PANE = 'github-pane'
9const EVERY_MS = 60_000
10const NARROW_COLS = 65 // below this the desktop draws tabs as a View picker
11const TABS: [Tab, string][] = [['prs', 'Pull requests'], ['checks', 'Checks'], ['issues', 'Issues'], ['mine', 'For me'], ['notifs', 'Notifications']]
12const DETAIL_FIELDS = 'body,reviewRequests,latestReviews,mergeable,mergeStateStatus,additions,deletions,changedFiles'
13const PR_FIELDS = 'number,title,author,isDraft,headRefName,baseRefName,reviewDecision,statusCheckRollup,updatedAt,url'
14const ISSUE_FIELDS = 'number,title,labels,assignees,updatedAt,url'
15// shortcut: raw translucent tints read on light and dark; swap for theme keys if the desktop theme gets row-tint ones
16const HOVER_BG = 'rgba(127,127,127,0.12)'
17const SELECTED_BG = 'rgba(70,120,220,0.16)'
18const PILL_BG: Record<Tone, string> = { success: 'rgba(46,160,67,0.2)', error: 'rgba(218,54,51,0.2)', warning: 'rgba(210,153,34,0.22)', subtle: 'rgba(127,127,127,0.2)' }
19const EMPTY: Snapshot = { repo: null, rows: { prs: [], checks: [], issues: [], mine: [], notifs: [] }, here: null, at: 0, error: null }
20
21const snap = atom({ plugin: 'github-pane', key: 'snap' } as const, EMPTY)
22const tab = atom({ plugin: 'github-pane', key: 'tab' } as const, 'prs' as Tab)
23const selected = atom({ plugin: 'github-pane', key: 'selected' } as const, null as string | null)
24const detail = atom({ plugin: 'github-pane', key: 'detail' } as const, null as Detail | null)
25const busy = atom({ plugin: 'github-pane', key: 'busy' } as const, null as string | null)
26const confirm = atom({ plugin: 'github-pane', key: 'confirm' } as const, null as string | null) // row key armed for merge
27const filter = atom({ plugin: 'github-pane', key: 'filter' } as const, '') // desktop list filter, cleared on tab change
28const menu = atom({ plugin: 'github-pane', key: 'menu' } as const, false) // narrow desktop pane: View dropdown open
29
30type $ = EngineInterface
31
32async function gh($: $, args: string[], timeoutMs = 30_000) {
33 const r = await $.process.run(['gh', ...args], { timeoutMs })
34 // gh pr checks exits 1 or 8 on failing or pending checks; its stdout is still the answer
35 const isChecks = args[0] === 'pr' && args[1] === 'checks' && !!r.stdout.trim()
36 if (r.exitCode !== 0 && !isChecks) throw new Error(r.stderr.trim().split('\n')[0] || `gh exited ${r.exitCode}`)
37 return r.stdout
38}
39const ghJson = async ($: $, args: string[]) => JSON.parse(await gh($, args))
40
41let prevSeen: Seen | null = null // ponytail: module variable, resets on reload (no toast storm)
42let isRefreshing = false
43let isOpen = false // polls only while the pane is open
44let timer: Timer | null = null
45let delay = EVERY_MS
46let viewer: string | null = null // your login, fetched once; hides Approve on your own PRs
47
48// Refreshes now, then schedules the next poll by the result (backoff, or stop when retrying can't help).
49async function poll($: $) {
50 if (isRefreshing) return // the in-flight poll schedules the next one
51 timer?.cancel()
52 timer = null
53 const d = nextDelay(delay, await refresh($), EVERY_MS)
54 if (d === null || !isOpen) return
55 delay = d
56 timer = $.clock.after(d, () => void poll($))
57}
58
59// Returns the error message, or null on success.
60async function refresh($: $): Promise<string | null> {
61 isRefreshing = true
62 try {
63 const repo = (await ghJson($, ['repo', 'view', '--json', 'nameWithOwner'])).nameWithOwner as string
64 const now = await $.clock.now()
65 const [prs, runs, issues, review, authored, assigned, status, login, notifs] = await Promise.all([
66 ghJson($, ['pr', 'list', '-L', '30', '--json', PR_FIELDS]),
67 ghJson($, ['run', 'list', '-L', '20', '--json', 'databaseId,displayTitle,status,conclusion,workflowName,headBranch,event,createdAt,url']),
68 ghJson($, ['issue', 'list', '-L', '30', '--json', ISSUE_FIELDS]),
69 ghJson($, ['pr', 'list', '--search', 'review-requested:@me', '--json', PR_FIELDS]),
70 ghJson($, ['pr', 'list', '--author', '@me', '--json', PR_FIELDS]),
71 ghJson($, ['issue', 'list', '--assignee', '@me', '--json', ISSUE_FIELDS]),
72 ghJson($, ['pr', 'status', '--json', PR_FIELDS]).catch(() => null), // detached HEAD etc.: no header, not an error
73 viewer ?? gh($, ['api', 'user', '-q', '.login']).then(o => o.trim() || null, () => null),
74 ghJson($, ['api', `repos/${repo}/notifications`]).catch(() => []), // token without notifications scope: empty tab, not an error
75 ])
76 viewer = login
77 const cur = status?.currentBranch
78 const prRows = prs.map((p: any) => prRow(p, now))
79 const runRows = runs.map((r: any) => runRow(r, now))
80 const asked: Row[] = review.map((p: any) => prRow(p, now))
81 const yours: Row[] = authored.map((p: any) => prRow(p, now))
82 const mine = [...asked, ...yours]
83 .filter((r: Row, i: number, all: Row[]) => all.findIndex(x => x.key === r.key) === i)
84 .concat(assigned.map((i: any) => issueRow(i, now)))
85
86 // ponytail: "your runs" = runs on your open PRs' branches; add `--user <login>` if pushes without a PR matter
87 const branches = new Set(authored.map((p: any) => p.headRefName))
88 const seen: Seen = { prs: prRows, asked, authored: yours, runs: runs.flatMap((r: any, i: number) => branches.has(r.headBranch) ? [runRows[i]] : []) }
89 for (const t of toasts(prevSeen, seen)) $.ui.toast(t, { timeoutMs: 10_000 })
90 prevSeen = seen
91
92 await update($, snap, () => ({
93 repo,
94 rows: { prs: prRows, checks: runRows, issues: issues.map((i: any) => issueRow(i, now)), mine, notifs: notifs.map((n: any) => notifRow(n, now)) },
95 here: cur?.number ? { ...prRow(cur, now), key: 'here' } : null,
96 at: now,
97 error: null,
98 }))
99 return null
100 } catch (err: any) {
101 const at = await $.clock.now()
102 const error = String(err?.message ?? err)
103 await update($, snap, s => ({ ...s, at, error }))
104 return error
105 } finally {
106 isRefreshing = false
107 }
108}
109
110// Runs a row action, shows its output (or error) in the detail box.
111async function act($: $, label: string, args: string[], show: (out: string) => string | Detail, refreshAfter = true) {
112 await update($, busy, () => label)
113 try {
114 const out = await gh($, args, 120_000)
115 const d = show(out)
116 await update($, detail, () => typeof d === 'string' ? { text: d } : d)
117 if (refreshAfter) void poll($)
118 } catch (err: any) {
119 await update($, detail, () => ({ text: `${label} failed: ${err?.message ?? err}` }))
120 } finally {
121 await update($, busy, () => null)
122 }
123}
124
125// Fills the prompt box with a failed run's log and a fix request; never sends it.
126async function fix($: $, r: Row) {
127 await update($, busy, () => `Fix #${r.id}`)
128 try {
129 let runId: number | null = r.id
130 if (r.kind === 'pr') {
131 // gh exits non-zero when checks fail, so read stdout directly
132 const c = await $.process.run(['gh', 'pr', 'checks', String(r.id), '--json', 'link,state'], { timeoutMs: 30_000 })
133 runId = failedRunId(JSON.parse(c.stdout || '[]'))
134 if (!runId) throw new Error('no failed GitHub Actions run on this PR')
135 }
136 const log = await gh($, ['run', 'view', String(runId), '--log-failed'], 120_000)
137 const res = await $.prompt.fill({ text: fixPrompt(r, runId, log) })
138 await update($, detail, () => ({ text: res.isFilled ? 'Fix request is in the prompt box: edit it, then press Enter to send.' : `Couldn't fill the prompt box${res.refusal ? ` (${res.refusal})` : ''}.` }))
139 } catch (err: any) {
140 await update($, detail, () => ({ text: `Fix failed: ${err?.message ?? err}` }))
141 } finally {
142 await update($, busy, () => null)
143 }
144}
145
146export const register: Register = on => {
147 on('session.start', async ($, e, next) => {
148 await $.command.register({ name: 'github', description: 'Open the GitHub pane for this repo' })
149 // A reload keeps the pane up but resets module state: resume polling.
150 if ((await $.ui.panes()).some(p => p.id === PANE)) {
151 isOpen = true
152 void poll($)
153 }
154 return next(e)
155 })
156
157 on('command.run', { command: 'github' }, async $ => {
158 await $.ui.open({ id: PANE, title: 'GitHub' })
159 isOpen = true
160 void poll($)
161 return { text: 'GitHub pane opened.' }
162 })
163
164 // The filter field (filter-field.tsx) posts each edit as { q }.
165 on('ui.message', async ($, e, next) => {
166 const d = e.data as { q?: unknown }
167 if (e.requestId !== PANE || e.element !== 'filter' || typeof d?.q !== 'string') return next(e)
168 await update($, filter, () => d.q as string)
169 return {}
170 })
171
172 on('ui.close', { id: PANE }, async ($, e, next) => {
173 isOpen = false
174 timer?.cancel()
175 timer = null
176 return next(e)
177 })
178
179 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
180 const els = $.ui.resolve(e)
181 const { Box, Text, Button, Link, Code, Markdown } = els
182 const isApp = e.surface !== 'terminal' // the terminal keeps its tree; its element table has Markdown too
183 // narrow desktop pane: tabs as a full-width View dropdown (a native Select can't be widened)
184 const narrow = isApp && e.props.bodyColumns < NARROW_COLS
185 const [s, t, sel, det, work, armed, q, isMenu] = await Promise.all([read($, snap), read($, tab), read($, selected), read($, detail), read($, busy), read($, confirm), read($, filter), read($, menu)])
186 const all = s.rows[t]
187 const Client = isApp && 'Client' in els ? els.Client : null // the terminal keeps its tree; mobile and vscode have no Client
188 const query = Client ? q : ''
189 const rows = all.filter(r => matches(r, query))
190 const pick = (key: string | null) => Promise.all([update($, selected, () => key), update($, detail, () => null), update($, confirm, () => null)])
191 const go = (id: Tab) => Promise.all([update($, tab, () => id), pick(null), update($, filter, () => ''), update($, menu, () => false)])
192 const shown = (id: Tab) => id === t && query ? rows.length : undefined // filtered count, active tab only
193 const merge = async (r: Row) => {
194 const c = confirmPress(armed, r.key)
195 await update($, confirm, () => c.armed)
196 if (!c.run) return isApp ? undefined : update($, detail, () => ({ text: `Press m again to squash-merge #${r.id} and delete its branch.` })) // desktop draws the confirm card
197 return act($, `Merge #${r.id}`, ['pr', 'merge', String(r.id), '--squash', '--delete-branch'], o => o.trim() || `Merged #${r.id}`)
198 }
199 const time = s.at ? new Date(s.at).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : null
200
201 const actions = (r: Row) => (
202 <Box key={`act-${r.key}`} flexDirection="row" flexWrap="wrap" columnGap={2} rowGap="xs" paddingLeft={isApp ? 0 : 4} alignItems="center">
203 <Link href={r.url}>Open on GitHub</Link>
204 {r.kind === 'pr' ? (
205 <Button key="checkout" hotkey="c" onPress={() => act($, `Checkout #${r.id}`, ['pr', 'checkout', String(r.id)], o => o.trim() || `Checked out #${r.id}`)}>Checkout</Button>
206 ) : null}
207 {r.kind === 'pr' ? (
208 <Button key="details" hotkey="d" onPress={() => act($, `Details #${r.id}`, ['pr', 'view', String(r.id), '--json', DETAIL_FIELDS], o => { const pr = prDetail(JSON.parse(o)); return { text: prText(pr), pr } }, false)}>Details</Button>
209 ) : null}
210 {r.kind === 'pr' && viewer && r.author !== viewer ? (
211 <Button key="approve" hotkey="a" onPress={() => act($, `Approve #${r.id}`, ['pr', 'review', String(r.id), '--approve'], o => o.trim() || `Approved #${r.id}`)}>Approve</Button>
212 ) : null}
213 {r.kind === 'pr' && r.isDraft ? (
214 <Button key="ready" hotkey="y" onPress={() => act($, `Ready #${r.id}`, ['pr', 'ready', String(r.id)], o => o.trim() || `#${r.id} marked ready for review`)}>Ready</Button>
215 ) : null}
216 {r.kind === 'pr' ? (
217 <Button key="merge" hotkey="m" variant={isApp || armed === r.key ? 'primary' : 'secondary'} onPress={() => merge(r)}>{armed === r.key && !isApp ? 'Confirm merge' : 'Merge'}</Button>
218 ) : null}
219 {r.kind === 'run' && r.isFailed ? (
220 <Button key="rerun" hotkey="x" variant={isApp ? 'primary' : undefined} onPress={() => act($, 'Rerun', ['run', 'rerun', String(r.id), '--failed'], () => `Rerunning failed jobs of run ${r.id}`)}>Rerun failed</Button>
221 ) : null}
222 {r.kind === 'run' && r.isFailed ? (
223 <Button key="log" hotkey="l" onPress={() => act($, 'Failed log', ['run', 'view', String(r.id), '--log-failed'], o => { const t = logTail(o); return { text: t || 'No failed-step log.', code: !!t } })}>Failed log</Button>
224 ) : null}
225 {(r.kind === 'run' || r.kind === 'pr') && r.isFailed ? (
226 <Button key="fix" hotkey="f" onPress={() => fix($, r)}>Fix with Claude</Button>
227 ) : null}
228 {r.kind === 'notif' ? (
229 <Button key="read" hotkey="e" onPress={() => act($, 'Mark read', ['api', '-X', 'PATCH', `notifications/threads/${r.id}`], () => 'Marked read')}>Mark read</Button>
230 ) : null}
231 {r.kind === 'pr' && r.isFailed ? (
232 <Button key="prchecks" hotkey="l" onPress={() => act($, 'Checks', ['pr', 'checks', String(r.id)], o => ({ text: o.trim(), code: true }))}>Show checks</Button>
233 ) : null}
234 </Box>
235 )
236
237 const rowView = (r: Row) => (
238 <Box key={r.key} flexDirection="column">
239 <Button key={`row-${r.key}`} plain onPress={() => pick(sel === r.key ? null : r.key)}>
240 <Text color={r.tone}>{`${sel === r.key ? '▸' : ' '} ${r.glyph} `}</Text>
241 <Text weight={sel === r.key ? 'bold' : 'regular'}>{r.title}</Text>
242 <Text dimColor>{` ${r.sub} · ${r.age}`}</Text>
243 </Button>
244 {sel === r.key ? actions(r) : null}
245 </Box>
246 )
247
248 const closeDetail = () => Promise.all([update($, detail, () => null), update($, confirm, () => null)])
249 const prParts = (d: PrParts) => (
250 <Box key="pr-parts" flexDirection="column" rowGap={1}>
251 {d.reviewers.length ? (
252 <Box key="reviewers" flexDirection="column">
253 {d.reviewers.map(v => (
254 <Box key={`rev-${v.who}`} flexDirection="row" columnGap={1}>
255 <Text size="caption">{v.who}</Text>
256 {pill({ text: v.text, tone: v.tone })}
257 </Box>
258 ))}
259 </Box>
260 ) : <Text size="caption" dimColor>No reviewers.</Text>}
261 {d.body ? <Markdown key="pr-body" text={d.body} /> : <Text dimColor>No description.</Text>}
262 </Box>
263 )
264 // Desktop detail: header (PR stat line, icon close), then the PR parts, a code block or plain text.
265 const appDetail = (d: Detail) => (
266 <Box key="detail" flexDirection="column" rowGap="xs" paddingTop="xs">
267 <Box flexDirection="row" justifyContent="space-between" alignItems="center" columnGap={1}>
268 <Text size="caption" dimColor tabular wrap="truncate-end">{d.pr ? `${d.pr.stat} · ${d.pr.merge}` : ''}</Text>
269 <Button key="close-detail" plain dimColor onPress={closeDetail}>✕ Close details</Button>
270 </Box>
271 {d.pr ? prParts(d.pr) : d.code ? <Code source={d.text} wrap="wrap" /> : <Text wrap="wrap">{d.text}</Text>}
272 </Box>
273 )
274
275 // Desktop merge confirm: replaces the card's detail and actions until Merge or Cancel.
276 const confirmCard = (r: Row) => (
277 <Box key="merge-confirm" flexDirection="column" rowGap="xs" paddingY="xs">
278 <Text weight="semibold" wrap="wrap">{`Squash-merge #${r.id} into ${r.base ?? 'its base'}?`}</Text>
279 <Text size="caption" dimColor wrap="wrap">{`Deletes branch ${r.branch ?? ''} on GitHub and locally.`}</Text>
280 <Box flexDirection="row" justifyContent="flex-end" columnGap={1}>
281 <Button key="cancel-merge" onPress={() => update($, confirm, () => null)}>Cancel</Button>
282 <Button key="confirm-merge" hotkey="m" variant="primary" onPress={() => merge(r)}>Merge</Button>
283 </Box>
284 </Box>
285 )
286 const emptyCard = (x: Empty) => (
287 <Box key="empty" flexDirection="column" alignItems="center" rowGap="xs" borderStyle="dashed" borderColor="subtle" padding={1}>
288 <Text dimColor>○</Text>
289 <Text weight="semibold">{x.title}</Text>
290 <Text size="caption" dimColor wrap="wrap">{x.line}</Text>
291 {x.href ? <Link href={x.href}>Open all notifications on GitHub</Link> : null}
292 </Box>
293 )
294
295 const pill = (p: Pill) => <Text key={`pill-${p.text}`} size="caption" backgroundColor={PILL_BG[p.tone]} color={p.tone}>{` ${p.text} `}</Text>
296 const appRowView = (r: Row) => {
297 const d = desktopRow(r, narrow)
298 return (
299 <Box key={r.key} flexDirection="column" backgroundColor={sel === r.key ? SELECTED_BG : undefined}>
300 <Box key={`box-${r.key}`} flexDirection="column" paddingX={1} paddingY="xs" hover={{ backgroundColor: sel === r.key ? SELECTED_BG : HOVER_BG }}>
301 <Box flexDirection="row" justifyContent="space-between" columnGap={1}>
302 {/* glyph outside the Button: the desktop draws a Button's label in one colour */}
303 <Box flexDirection="row" columnGap={1}>
304 <Box width={2}><Text color={r.tone}>{r.glyph}</Text></Box>{/* fixed width: ✓ and · differ, titles must line up */}
305 <Button key={`row-${r.key}`} plain onPress={() => pick(sel === r.key ? null : r.key)}>
306 <Text weight="medium" wrap="truncate-end">{d.title}</Text>
307 </Button>
308 </Box>
309 <Text size="caption" dimColor tabular>{r.age}</Text>
310 </Box>
311 <Box flexDirection="row" columnGap={1} paddingLeft={3}>
312 <Text size="caption" dimColor wrap="truncate-end">{d.meta}</Text>
313 {d.pills.map(pill)}
314 </Box>
315 </Box>
316 {sel === r.key ? (
317 <Box key={`card-${r.key}`} flexDirection="column" rowGap={1} borderStyle="round" borderColor="subtle" paddingX={1} marginX={1} marginBottom="xs">
318 {armed === r.key ? confirmCard(r) : null}
319 {armed !== r.key && det ? appDetail(det) : null}
320 {armed !== r.key ? actions(r) : null}
321 </Box>
322 ) : null}
323 </Box>
324 )
325 }
326 const row = isApp ? appRowView : rowView
327 const status = work ? `${work}…` : time ? `updated ${time} · ${timer ? `every ${delay / 1000}s` : 'paused, press r'}` : 'loading…'
328 const errorText = s.error ? (s.repo ? `Refresh failed: ${s.error}` : `No GitHub repo here (${s.error}). Run /github from a cloned repo with gh signed in.`) : null
329
330 return (
331 <Box flexDirection="column" rowGap={1}>
332 {isApp ? (
333 <Box flexDirection="row" justifyContent="space-between" alignItems="center" columnGap={2}>
334 <Box flexDirection="column">
335 <Text size="heading" weight="semibold" wrap="truncate-end">{s.repo ?? 'GitHub'}</Text>
336 <Text dimColor size="caption">{status}</Text>
337 </Box>
338 <Button key="refresh" hotkey="r" plain onPress={() => poll($)}>Refresh</Button>
339 </Box>
340 ) : (
341 <Box flexDirection="row" justifyContent="space-between" flexWrap="wrap" columnGap={2}>
342 <Text weight="bold">{s.repo ?? 'GitHub'}</Text>
343 <Box flexDirection="row" columnGap={2}>
344 <Text dimColor size="caption">{status}</Text>
345 <Button key="refresh" hotkey="r" plain onPress={() => poll($)}>↻ Refresh</Button>
346 </Box>
347 </Box>
348 )}
349
350 {s.repo && s.at ? (
351 <Box key="here" flexDirection="column">
352 <Text dimColor size="caption">This branch</Text>
353 {s.here ? row(s.here) : <Text dimColor>{isApp ? 'No PR for this branch.' : ' No PR for this branch.'}</Text>}
354 </Box>
355 ) : null}
356
357 {narrow ? (
358 <Box key="view" flexDirection="column" width="100%">
359 <Box key="view-trigger" flexDirection="row" justifyContent="space-between" alignItems="center" columnGap={1} paddingX={1}
360 borderStyle="round" borderColor="subtle" hover={{ backgroundColor: HOVER_BG }}>
361 <Box flexDirection="row" alignItems="center" columnGap={1}>
362 <Text size="caption" dimColor>View</Text>
363 <Button key="tab-menu" plain onPress={() => update($, menu, m => !m)}>
364 <Text weight="semibold">{tabOption(TABS.find(([id]) => id === t)![1], all, shown(t))}</Text>
365 </Button>
366 </Box>
367 <Text dimColor>{isMenu ? '▴' : '▾'}</Text>
368 </Box>
369 {isMenu ? (
370 <Box key="view-options" flexDirection="column" borderStyle="round" borderColor="subtle">
371 {TABS.map(([id, label], i) => (
372 <Box key={`opt-box-${id}`} paddingX={1} backgroundColor={id === t ? SELECTED_BG : undefined} hover={{ backgroundColor: id === t ? SELECTED_BG : HOVER_BG }}>
373 <Button key={`opt-${id}`} hotkey={String(i + 1)} plain onPress={() => go(id)}>
374 <Text weight={id === t ? 'semibold' : 'regular'}>{tabOption(label, s.rows[id], shown(id))}</Text>
375 </Button>
376 </Box>
377 ))}
378 </Box>
379 ) : null}
380 </Box>
381 ) : isApp ? (
382 <Box key="tabs" flexDirection="row" flexWrap="wrap" borderStyle="round" borderColor="subtle">
383 {TABS.map(([id, label], i) => (
384 <Box key={`seg-${id}`} flexDirection="row" alignItems="center" columnGap={1} paddingX={1}
385 backgroundColor={id === t ? SELECTED_BG : undefined} hover={{ backgroundColor: id === t ? SELECTED_BG : HOVER_BG }}>
386 <Button key={`tab-${id}`} hotkey={String(i + 1)} plain onPress={() => go(id)}>
387 <Text weight={id === t ? 'semibold' : 'regular'}>{label}</Text>
388 </Button>
389 {pill(tabCount(s.rows[id], shown(id)))}
390 </Box>
391 ))}
392 </Box>
393 ) : (
394 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
395 {TABS.map(([id, label], i) => (
396 <Button key={`tab-${id}`} hotkey={String(i + 1)} variant={id === t ? 'primary' : 'secondary'} onPress={() => go(id)}>
397 {`${label} ${s.rows[id].length}`}
398 </Button>
399 ))}
400 </Box>
401 )}
402
403 {errorText && isApp ? (
404 <Box borderStyle="round" borderColor="error" paddingX={1}><Text color="error" wrap="wrap">{errorText}</Text></Box>
405 ) : errorText ? <Text color="error">{errorText}</Text> : null}
406
407 {Client && all.length ? (
408 <Box key="filter-row" flexDirection="row" alignItems="center" columnGap={1}>
409 {/* a Client, not an Input: the desktop draws a native Input at a fixed width */}
410 <Client key="filter" module="./filter-field.tsx" props={{ q }} flexGrow={1} />
411 {q ? <Button key="clear-filter" label="Clear filter" plain dimColor onPress={() => update($, filter, () => '')}><Text>✕</Text></Button> : null}
412 </Box>
413 ) : null}
414
415 <Box flexDirection="column">
416 {all.length === 0 && s.at && s.repo ? (isApp ? emptyCard(emptyState(t, s.repo)) : <Text dimColor>{emptyState(t, s.repo).line}</Text>) : null}
417 {all.length > 0 && rows.length === 0 ? emptyCard({ title: 'No matches', line: `Nothing in this tab matches "${query.trim()}".` }) : null}
418 {rows.map(row)}
419 </Box>
420 {query && rows.length ? <Text size="caption" dimColor>{`${rows.length} of ${all.length} match`}</Text> : null}
421
422 {det && isApp && !(s.here?.key === sel || rows.some(r => r.key === sel)) ? (
423 <Box flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={1}>{appDetail(det)}</Box>
424 ) : det && !isApp ? (
425 <Box flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={1}>
426 <Text mono wrap="wrap">{det.text}</Text>
427 <Button key="close-detail" plain dimColor onPress={closeDetail}>Close</Button>
428 </Box>
429 ) : null}
430 </Box>
431 )
432 })
433}
434hooks/format.ts 250 lines1import type { PrParts, Row, Tab, Tone } from '../types'
2
3export const ago = (iso: string, now: number) => {
4 const m = Math.max(0, Math.round((now - Date.parse(iso)) / 60_000))
5 return m < 1 ? 'now' : m < 60 ? `${m}m` : m < 1440 ? `${Math.round(m / 60)}h` : `${Math.round(m / 1440)}d`
6}
7
8const clip = (s: unknown, n = 80) => {
9 const t = String(s ?? '').replace(/\s+/g, ' ').trim()
10 return t.length > n ? `${t.slice(0, n - 1)}…` : t
11}
12
13type Status = { glyph: string; tone: Tone }
14const PASS: Status = { glyph: '✓', tone: 'success' }
15const FAIL: Status = { glyph: '✗', tone: 'error' }
16const PENDING: Status = { glyph: '◐', tone: 'warning' }
17const NONE: Status = { glyph: '·', tone: 'subtle' }
18
19export const checks = (rollup: any[] | undefined): Status => {
20 const r = (rollup ?? []).map(c => String(c.conclusion || c.state || c.status || '').toUpperCase())
21 if (r.some(x => /FAIL|ERROR|CANCEL|TIMED_OUT|ACTION_REQUIRED/.test(x))) return FAIL
22 if (r.some(x => /PENDING|PROGRESS|QUEUED|WAITING|EXPECTED/.test(x))) return PENDING
23 return r.length ? PASS : NONE
24}
25
26const REVIEW: Record<string, string> = {
27 APPROVED: 'approved',
28 CHANGES_REQUESTED: 'changes requested',
29 REVIEW_REQUIRED: 'review required',
30}
31
32export const prRow = (p: any, now: number): Row => {
33 const c = checks(p.statusCheckRollup)
34 return {
35 key: `pr-${p.number}`,
36 kind: 'pr',
37 id: p.number,
38 ...c,
39 title: `${p.isDraft ? '[draft] ' : ''}${clip(p.title)}`,
40 sub: [`#${p.number}`, `${p.headRefName} → ${p.baseRefName}`, p.author?.login && `@${p.author.login}`, REVIEW[p.reviewDecision]]
41 .filter(Boolean).join(' · '),
42 age: ago(p.updatedAt, now),
43 url: p.url,
44 isFailed: c === FAIL,
45 isDraft: !!p.isDraft,
46 author: p.author?.login,
47 branch: p.headRefName,
48 base: p.baseRefName,
49 decision: p.reviewDecision || undefined,
50 }
51}
52
53export type Pill = { text: string; tone: Tone }
54const CI: Record<Tone, string> = { success: 'CI passing', error: 'CI failing', warning: 'CI running', subtle: 'No checks' }
55const RUN: Record<Tone, string> = { success: 'Passed', error: 'Failed', warning: 'Running', subtle: 'Skipped' }
56const REVIEW_PILL: Record<string, Pill> = {
57 APPROVED: { text: 'Approved', tone: 'success' },
58 CHANGES_REQUESTED: { text: 'Changes requested', tone: 'error' },
59 REVIEW_REQUIRED: { text: 'Review required', tone: 'warning' },
60}
61
62// A row as the desktop draws it: title, meta line and pills (the terminal folds review and draft into the text).
63// Narrow pane: no branch in the meta, one pill (a red one if any, else the first).
64export const desktopRow = (r: Row, narrow = false): { title: string; meta: string; pills: Pill[] } => {
65 if (r.kind === 'pr') {
66 const review = r.isDraft ? { text: 'Draft', tone: 'subtle' as Tone } : REVIEW_PILL[r.decision ?? '']
67 const pills = [...(review ? [review] : []), { text: CI[r.tone], tone: r.tone }]
68 return {
69 title: r.isDraft ? r.title.replace(/^\[draft\] /, '') : r.title,
70 meta: [`#${r.id}`, !narrow && r.branch && r.base && `${r.branch} → ${r.base}`, r.author && `@${r.author}`].filter(Boolean).join(' · '),
71 pills: narrow ? [pills.find(p => p.tone === 'error') ?? pills[0]] : pills,
72 }
73 }
74 return { title: r.title, meta: r.sub, pills: r.kind === 'run' ? [{ text: RUN[r.tone], tone: r.tone }] : [] }
75}
76
77export const runRow = (r: any, now: number): Row => {
78 const s = r.status !== 'completed' ? PENDING
79 : r.conclusion === 'success' ? PASS
80 : r.conclusion === 'skipped' || r.conclusion === 'neutral' ? NONE
81 : FAIL
82 return {
83 key: `run-${r.databaseId}`,
84 kind: 'run',
85 id: r.databaseId,
86 ...s,
87 title: clip(r.displayTitle),
88 sub: [r.workflowName, r.headBranch, r.event].filter(Boolean).join(' · '),
89 age: ago(r.createdAt, now),
90 url: r.url,
91 isFailed: s === FAIL,
92 isPending: s === PENDING,
93 branch: r.headBranch,
94 }
95}
96
97export const issueRow = (i: any, now: number): Row => ({
98 key: `issue-${i.number}`,
99 kind: 'issue',
100 id: i.number,
101 glyph: '○',
102 tone: 'success',
103 title: clip(i.title),
104 sub: [`#${i.number}`, ...(i.labels ?? []).slice(0, 3).map((l: any) => l.name), ...(i.assignees ?? []).map((a: any) => `@${a.login}`)]
105 .join(' · '),
106 age: ago(i.updatedAt, now),
107 url: i.url,
108 isFailed: false,
109})
110
111// What one poll saw, for toasts: all PRs, PRs asking your review, your PRs, runs on your PRs' branches.
112export type Seen = { prs: Row[]; asked: Row[]; authored: Row[]; runs: Row[] }
113
114// Toast texts for what changed since the last poll (first poll: none, so a reload never replays).
115export const toasts = (prev: Seen | null, next: Seen): string[] => {
116 if (!prev) return []
117 const was = (list: Row[], r: Row) => list.find(p => p.id === r.id)
118 return [
119 ...next.prs.filter(r => r.isFailed && was(prev.prs, r)?.isFailed === false)
120 .map(r => `✗ Checks failed on #${r.id}: ${r.title}`),
121 ...next.asked.filter(r => !was(prev.asked, r))
122 .map(r => `Review requested on #${r.id}: ${r.title}`),
123 ...next.authored.filter(r => { const p = was(prev.authored, r); return r.decision === 'APPROVED' && p && p.decision !== 'APPROVED' })
124 .map(r => `✓ Approved #${r.id}: ${r.title}`),
125 ...next.runs.filter(r => !r.isPending && was(prev.runs, r)?.isPending)
126 .map(r => `${r.isFailed ? '✗' : '✓'} Run finished: ${r.title} (${r.sub})`),
127 ]
128}
129
130// Failed-log tail: drop the "job\tstep\t" prefix gh puts on each line, ANSI colours,
131// and each job's "Post job cleanup." section (it buries the error).
132export const logTail = (raw: string, lines = 30) => {
133 let job = ''
134 let cleanup = false
135 return raw.split(/\r?\n/).filter(Boolean)
136 .filter(l => {
137 const j = l.split('\t')[0]
138 if (j !== job) { job = j; cleanup = false }
139 return !(cleanup ||= l.endsWith('Post job cleanup.'))
140 })
141 .slice(-lines).map(l => l.split('\t').at(-1)!.replace(/^\S+Z /, '').replace(/\x1b\[[\d;]*m/g, '')).join('\n')
142}
143
144// Errors a retry can't fix: no repo, no remote, gh missing or signed out.
145const FATAL = /not a git repository|no git remotes|none of the git remotes|gh auth login|not logged in|ENOENT|executable file not found|command not found/i
146
147// Next poll delay: base when ok, doubling up to 10 min on errors, null (stop) when fatal.
148export const nextDelay = (prev: number, error: string | null, base: number) =>
149 !error ? base : FATAL.test(error) ? null : Math.min(prev * 2, 600_000)
150
151// `gh pr view --json body,reviewRequests,latestReviews,mergeable,mergeStateStatus,additions,deletions,changedFiles` → parts.
152export const prDetail = (p: any): PrParts => {
153 const low = (x: unknown) => String(x ?? 'unknown').toLowerCase().replace(/_/g, ' ')
154 const tone: Record<string, Tone> = { APPROVED: 'success', CHANGES_REQUESTED: 'error' }
155 return {
156 stat: `+${p.additions ?? 0} −${p.deletions ?? 0} · ${p.changedFiles ?? 0} files`,
157 merge: `${low(p.mergeable)} · ${low(p.mergeStateStatus)}`,
158 reviewers: [
159 ...(p.latestReviews ?? []).map((r: any) => ({ who: `@${r.author?.login}`, text: low(r.state), tone: tone[r.state] ?? 'subtle' })),
160 ...(p.reviewRequests ?? []).map((r: any) => ({ who: r.login ? `@${r.login}` : r.name ?? r.slug, text: 'requested', tone: 'warning' as Tone })),
161 ],
162 body: String(p.body ?? '').trim(),
163 }
164}
165
166// PR parts as one text, body clipped (the terminal draws this).
167export const prText = (d: PrParts, bodyLines = 20) => {
168 const body = d.body.split(/\r?\n/)
169 const more = body.length - bodyLines
170 return [
171 d.stat,
172 d.merge,
173 ...(d.reviewers.length ? ['Reviewers:', ...d.reviewers.map(r => ` ${r.who} ${r.text}`)] : ['No reviewers.']),
174 '',
175 body[0] ? body.slice(0, bodyLines).join('\n') : 'No description.',
176 ...(more > 0 ? [`… ${more} more lines`] : []),
177 ].join('\n')
178}
179
180// Two-press confirm: first press on a key arms it, second press on the same key runs (and disarms).
181export const confirmPress = (armed: string | null, key: string) =>
182 armed === key ? { run: true, armed: null } : { run: false, armed: key }
183
184// Notification subject URLs point at the API: PRs and issues map to their page, anything else to the repo.
185export const notifUrl = (n: any) => {
186 const m = /repos\/([^/]+\/[^/]+)\/(pulls|issues)\/(\d+)$/.exec(n.subject?.url ?? '')
187 return m ? `https://github.com/${m[1]}/${m[2] === 'pulls' ? 'pull' : 'issues'}/${m[3]}` : n.repository?.html_url ?? ''
188}
189
190// `gh api repos/<repo>/notifications` item → row; id is the thread id (for mark read).
191export const notifRow = (n: any, now: number): Row => ({
192 key: `notif-${n.id}`,
193 kind: 'notif',
194 id: Number(n.id),
195 glyph: n.unread ? '●' : '○',
196 tone: n.unread ? 'warning' : 'subtle',
197 title: clip(n.subject?.title),
198 sub: [String(n.reason ?? '').replace(/_/g, ' '), n.subject?.type].filter(Boolean).join(' · '),
199 age: ago(n.updated_at, now),
200 url: notifUrl(n),
201 isFailed: false,
202})
203
204// `gh pr checks --json link,state` → the first failed Actions run id, or null (external checks have no run).
205export const failedRunId = (cs: any[]) => {
206 for (const c of cs) {
207 const m = /\/actions\/runs\/(\d+)/.exec(c.link ?? '')
208 if (m && /FAIL|ERROR|CANCEL|TIMED_OUT/i.test(c.state ?? '')) return Number(m[1])
209 }
210 return null
211}
212
213// Draft for the prompt box: what failed, where, and the log tail.
214export const fixPrompt = (r: Row, runId: number, log: string) =>
215 [
216 `CI failed on ${r.kind === 'pr' ? `PR #${r.id}, ` : ''}run ${runId}${r.branch ? ` (branch ${r.branch})` : ''}.`,
217 '```',
218 logTail(log, 60),
219 '```',
220 'Find and fix the cause of this CI failure.',
221 ].join('\n')
222
223// Filter: the query (trimmed, any case) inside the title, `#n`, branch or author; empty matches all.
224export const matches = (r: Row, query: string) => {
225 const q = query.trim().toLowerCase()
226 return !q || [r.title, `#${r.id}`, r.branch, r.author].some(x => x?.toLowerCase().includes(q))
227}
228
229// Why a tab is empty: a short title (desktop card) and one sentence (both surfaces), a link where one helps.
230export type Empty = { title: string; line: string; href?: string }
231export const emptyState = (t: Tab, repo: string): Empty => ({
232 prs: { title: 'No open pull requests', line: 'No open pull requests in this repo.' },
233 checks: { title: 'No workflow runs', line: 'No workflow runs in this repo.' },
234 issues: { title: 'No open issues', line: 'No open issues in this repo.' },
235 mine: { title: 'Nothing waiting on you', line: 'Nothing waiting on you: no review requests, open PRs of yours or issues assigned to you.' },
236 notifs: { title: 'No unread notifications', line: `No unread notifications for ${repo}. This tab shows this repo only.`, href: 'https://github.com/notifications' },
237})[t]
238
239// A tab's count pill: the total (`shown / total` while filtered), red when any row failed.
240export const tabCount = (rows: Row[], shown = rows.length): Pill => ({
241 text: shown === rows.length ? String(rows.length) : `${shown} / ${rows.length}`,
242 tone: rows.some(r => r.isFailed) ? 'error' : 'subtle',
243})
244
245// A tab as a narrow pane's Select option: `Checks (2 failing)`, else `Issues (4)`.
246export const tabOption = (label: string, rows: Row[], shown = rows.length) => {
247 const failing = rows.filter(r => r.isFailed).length
248 return `${label} (${failing ? `${failing} failing` : tabCount(rows, shown).text})`
249}
250hooks/filter-field.tsx 35 lines1import type { ClientModule } from 'claude-code'
2
3// Full-width filter field for the desktop pane (a native Input can't be widened).
4// Click to focus, type to filter, Backspace deletes, ctrl+U clears, Escape returns focus.
5// The query lives here while typing and is posted to the hooks module as `{ q }`;
6// a different `props.q` (cleared, or a tab change) replaces it.
7type Props = { q: string }
8type State = { q: string; seen: string }
9
10export const edit = (q: string, k: { key: string; ctrl?: true; meta?: true }) =>
11 k.key === 'backspace' ? q.slice(0, -1)
12 : k.ctrl && k.key === 'u' ? ''
13 : k.key.length === 1 && !k.ctrl && !k.meta ? q + k.key
14 : q
15
16const FilterField: ClientModule<Props, State> = (props, surface) => {
17 const { Box, Text } = surface.elements
18 const state = surface.state
19 if (!state || state.seen !== props.q) surface.setState({ q: props.q, seen: props.q }) // adopt a change from outside
20 const q = state && state.seen === props.q ? state.q : props.q
21 surface.onKey(k => {
22 const next = edit(q, k)
23 if (next === q) return
24 surface.setState({ q: next, seen: props.q })
25 surface.post({ q: next })
26 })
27 return (
28 <Box flexDirection="row" borderStyle="round" borderColor="subtle" paddingX={1}>
29 {q ? <Text wrap="truncate-end">{q}</Text> : <Text dimColor>Filter</Text>}
30 <Text dimColor>▏</Text>
31 </Box>
32 )
33}
34export default FilterField
35types/index.d.ts 38 lines1export type Tab = 'prs' | 'checks' | 'issues' | 'mine' | 'notifs'
2export type Tone = 'success' | 'warning' | 'error' | 'subtle'
3export type Row = {
4 key: string
5 kind: 'pr' | 'run' | 'issue' | 'notif'
6 id: number
7 glyph: string
8 tone: Tone
9 title: string
10 sub: string
11 age: string
12 url: string
13 isFailed: boolean
14 isDraft?: boolean // PRs only
15 author?: string // PRs only: login
16 decision?: string // PRs only: raw reviewDecision
17 isPending?: boolean // runs only: not completed
18 branch?: string // PRs and runs: head branch
19 base?: string // PRs only: base branch
20}
21export type Reviewer = { who: string; text: string; tone: Tone }
22export type PrParts = { stat: string; merge: string; reviewers: Reviewer[]; body: string }
23// What the detail box shows: text (the terminal draws only this), or a code block, or a PR's parts.
24export type Detail = { text: string; code?: boolean; pr?: PrParts }
25export type Snapshot = {
26 repo: string | null
27 rows: Record<Tab, Row[]>
28 here: Row | null
29 at: number
30 error: string | null
31}
32
33declare module 'claude-code' {
34 interface PluginState {
35 'github-pane': { snap: Snapshot; tab: Tab; selected: string | null; detail: Detail | null; busy: string | null; confirm: string | null; filter: string; menu: boolean }
36 }
37}
38