SLOPSHOPPER

github-pane

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…

newpanecommandtoastprocesstimer
v0.9.0no licenseupdated 2026-10-19ReesMehorst/claude-mods/github-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · github-pane
│ ┃ GitHub ✕ › fix the failing auth test and add an audit log call │ ┃ GitHub loading… r: ↻ Refresh │ ┃ ⏺ Read(src/auth.ts) │ ┃ [ Pull requests 0 ] [ Checks 0 ] [ Issues 0 ⎿ 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 │ │ › /github │ ⎿ github-pane: GitHub pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · GitHub
GitHub loading… r: ↻ Refresh [ Pull requests 0 ] [ Checks 0 ] [ Issues 0 ] [ For me 0 ] [
README

claude-mods

Claude Code mods by ReesMehorst.

Install

/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.

Mods

usage-band

A band above the prompt that shows where your session stands.

  • Plan mode (Pro/Max subscription): 5-hour and weekly limits with pace projection (pace → 120%) and when you'll hit 100% at that pace (hits 100% ~16:40). Session cost is shown muted as "API value".
  • API mode (API credits): spend leads, with $/hr. Set a budget under /config → API budget (USD) for a budget bar and ETA.
  • Context fill, and a token breakdown (in / out / cache hit %).
  • Warnings as toasts: context ≥ 80%, a limit on pace past 100%, spend ≥ 90% of budget.
  • /usage-band toggles a compact one-line view (remembered across sessions).

github-pane

/github opens a live pane for the repo you're in (needs the gh CLI, signed in).

  • This branch: the PR for your checked-out branch, pinned above the tabs.
  • Tabs: Pull requests, Checks (workflow runs), Issues, For me (review requests, your PRs, issues assigned to you), Notifications (unread, this repo). Hotkeys 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.
  • Each row: CI status, title, branch → base, author, review state, age. In the desktop app a row is two lines, with review and CI as coloured pills; a selected row opens a card under it with its actions and details: the PR description as formatted markdown, reviewers with a state pill each, failed logs and checks in a code block.
  • Select a row for actions: open on GitHub, c checkout a PR, x rerun failed jobs, l show the failed-step log inline, e mark a notification read.
  • On a failed run or PR: 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.
  • On a PR: 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.
  • Desktop app: a full-width Filter field above the list (click it, then type; ctrl+U clears) narrows the tab by title, #n, branch or author; switching tabs clears it. An empty tab says why it is empty.
  • Refreshes every 60 s while the pane is open, with toasts when a PR's checks turn red, your review is requested, your PR is approved, or a run on your PR's branch finishes. Errors back off (up to 10 min); no repo or gh signed out pauses polling until r.

Develop

claude plugin validate ./usage-band
claude plugin test ./usage-band
Source 4 files
hooks/register.tsx 434 lines
1import { 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}
434
hooks/format.ts 250 lines
1import 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}
250
hooks/filter-field.tsx 35 lines
1import 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
35
types/index.d.ts 38 lines
1export 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