SLOPSHOPPER

pr-mod

The repo's open pull requests in a pane and a band line: CI and merge state, merge, update branch, and fix-conflict / fix-CI prompts.

newpanebandguardcommandtoast
★ 10v0.4.0MITupdated 2026-10-09juzser/blacksmith/mods/pr-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pr-mod
│ ┃ PRs ✕ › fix the failing auth test and add an audit log call │ ┃ pr-mod: no GitHub repo here (JSON Parse │ ┃ error: Unexpected EOF) ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /pr-mod │ ⎿ pr-mod: pr-mod: no GitHub repo here (JSON Parse error: Unexpecte │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · PRs
pr-mod: no GitHub repo here (JSON Parse error: Unexpected EOF)
README

pr-mod

The open pull requests of the repo your Claude Code session stands in, in a pane and a line above the prompt. It is optional. Blacksmith runs the same without it.

/plugin marketplace add juzser/blacksmith
/plugin install pr-mod@blacksmith

pr-mod is a Claude Code plugin module, not skills or agents. It needs a Claude Code build that loads plugin modules, and the gh CLI signed in to GitHub. claude plugin details pr-mod shows it once installed.

What it shows

  • The band is one line above the prompt: PR 4 open, then the counts that are not zero: green, CI red, conflict and pending. It shows only while a PR is open, marks itself stale when the last read failed, and stacks over other plugins' bands rather than hiding them.
  • The pane (/pr-mod) lists every open PR, the ones this session created with gh pr create first, then the newest. A header row carries the repo, the number open and how long ago it was fetched, with a Refresh button. Each row shows the PR number, its title, its CI state and its merge state.
  • Toasts report a PR whose CI went red, a new conflict with its base, CI turning green or a PR leaving the list on one this session created, and the outcome of a button you pressed.
  • Colors. The band and the pane use the same Catppuccin pastels as bs-mod, by the same rule: Mocha under a dark /config theme (dark-ansi too), Latte under a light one, and the theme's own colors under a daltonized theme, auto, or one it cannot read. Green is success, red is failure, yellow is pending or a conflict. Labels and secondary text are soft. Changing the theme repaints at once.

The pane's actions

ButtonDoes
RefreshReads the PR list now. It is read every 60 s while a PR is open or the pane is shown.
MergeMerges the PR with the repo's first allowed method (squash, merge, rebase). See the gate below.
Update branchRuns gh pr update-branch, on a PR that is behind its base.
Fix conflictSends a prompt into this session asking it to merge the base into the PR's head and resolve the conflict.
Fix CISends a prompt into this session naming the failing checks and their runs.
OpenCopies the PR's URL and shows it in a toast.

The fix buttons do not run anything themselves: they submit a prompt into this session, which you can read and which does the work. After one is sent it reads fix sent until the PR's head moves or 15 minutes pass.

The merge gate

Merge shows only when the PR can be merged cleanly:

  • CI is all green,
  • the merge state is CLEAN,
  • the PR is not a draft,
  • and the repo allows a merge method.

Pressing it once only arms it: the button reads Confirm merge #N (method) for 8 seconds. A second press in that window runs gh pr merge. Any other state hides the button, and the row says why where it can, for example a review that is still required.

Developing it

Load this checkout's copy into one session. This installs nothing:

claude --plugin-dir "$PWD/mods/pr-mod"

From the repo root, three checks cover the mod:

claude plugin validate mods/pr-mod   # manifest, hooks.json, what register.tsx reads and writes
claude plugin test mods/pr-mod       # hooks/*.test.ts, in Claude Code's own test runner
pnpm exec tsc -p mods/pr-mod         # types; needs .claude-plugin/types/

Claude Code generates .claude-plugin/types/ when a session loads the mod, and the folder is gitignored, so load the mod once with --plugin-dir before the type check. The tests fake $.process.run, so none of them calls gh.

scripts/check.sh runs the first two checks whenever claude is on PATH; CI has no claude, so there they print SKIP, and factory/orchestrator/test/pluginManifest.test.ts guards the marketplace wiring. The repo's vitest does not run this folder's tests.

hooks/palette.ts is pr-mod's own copy of the few colors it needs from bs-mod's palette, because a plugin cannot import another's files. Keep the hex values in step with mods/bs-mod/hooks/fold.ts.

A release bumps .claude-plugin/plugin.json's version together with the package's. A test enforces that.

Source 4 files
hooks/register.tsx 572 lines
1// pr-mod: the open PRs of the repo this session stands in, as a pane (/pr-mod) with merge, update and fix
2// actions, and a one-line band of counts above the prompt that stacks over whatever the plugins beneath draw.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, Register, RenderElement, RenderNode, TextProps, Timer, UiPressArgument } from 'claude-code'
5
6import type { Armed, FixHold, Pr, PrCache, RepoInfo } from '../types'
7import {
8  FIX_HOLD_MS,
9  PR_LIST_ARGV,
10  REPO_VIEW_ARGV,
11  ago,
12  bandCounts,
13  canMerge,
14  stillMergeable,
15  ciPart,
16  firstLine,
17  fixCiPrompt,
18  fixConflictPrompt,
19  isConflict,
20  isHeld,
21  isMine,
22  parsePrs,
23  parseRepo,
24  prUrlsIn,
25  rollup,
26  sortPrs,
27  statePart,
28  toastsBetween,
29  unconsumedMerges,
30} from './prs'
31import type { Tone } from './prs'
32import { paletteOf } from './palette'
33import type { Palette, PaletteRole } from './palette'
34
35const PANE = 'pr-mod'
36const POLL_MS = 60_000
37const RECHECK_MS = 5_000
38const RECHECK_MAX = 3
39const ARM_MS = 8_000
40const LIST_TIMEOUT_MS = 30_000
41const ACTION_TIMEOUT_MS = 120_000
42/** A Button with chrome draws its label plus two columns each side. */
43const BUTTON_CHROME_W = 4
44const GAP = 1
45const GH_PR_CREATE = /\bgh\s+pr\s+create\b/
46const REFRESHES = /\bgit\s+push\b|\bgh\s+pr\s/
47
48const EMPTY_CACHE: PrCache = { cwd: '', prs: [], fetchedAt: null, error: null }
49const cacheAtom = atom({ plugin: 'pr-mod', key: 'cache' } as const, EMPTY_CACHE)
50const repoAtom = atom({ plugin: 'pr-mod', key: 'repo' } as const, null as RepoInfo | null)
51const mineAtom = atom({ plugin: 'pr-mod', key: 'mine' } as const, [] as string[])
52const armedAtom = atom({ plugin: 'pr-mod', key: 'armed' } as const, null as Armed | null)
53const fixAtom = atom({ plugin: 'pr-mod', key: 'fixSent' } as const, {} as Record<string, FixHold>)
54const busyAtom = atom({ plugin: 'pr-mod', key: 'busy' } as const, {} as Record<string, string>)
55/** the `/config` theme, which picks the palette (palette.ts paletteOf); null until read, so the theme keys draw */
56const themeAtom = atom({ plugin: 'pr-mod', key: 'theme' } as const, null as string | null)
57
58type St = {
59  running: Promise<void> | null
60  timer: Timer | null
61  recheckTimer: Timer | null
62  disarmTimer: Timer | null
63  recheck: number
64  /** PRs this mod merged: their leaving the list is no news */
65  mergedHere: number[]
66}
67
68type FixKind = 'conflict' | 'ci'
69
70// ---------------------------------------------------------------- drawing helpers
71
72/** `color`: a palette role (palette.ts PaletteRole) */
73type Look = { color?: PaletteRole; bold?: boolean; dim?: boolean }
74type Run = Look & { text: string; shrink?: boolean }
75
76function run(text: string, look: Look = {}): Run {
77  return { text, ...look }
78}
79
80function cols(text: string): number {
81  return [...text].length
82}
83
84function cut(text: string, n: number): string {
85  return [...text].slice(0, Math.max(0, n)).join('')
86}
87
88function runsWidth(runs: readonly Run[]): number {
89  return runs.reduce((n, r) => n + cols(r.text), 0)
90}
91
92/** Fits runs into width: the shrinkable run gives first, else the line is cut with an ellipsis. */
93function clip(runs: readonly Run[], width: number): Run[] {
94  if (width < 1) return []
95  const out = runs.filter(r => r.text)
96  const over = runsWidth(out) - width
97  if (over <= 0) return out
98  const i = out.findIndex(r => r.shrink)
99  const give = out[i]
100  const keep = give ? cols(give.text) - over - 1 : 0
101  if (give && keep > 0) {
102    out[i] = { ...give, text: `${cut(give.text, keep).trimEnd()}…` }
103    return out
104  }
105  const kept: Run[] = []
106  let room = width - 1
107  for (const r of out) {
108    if (cols(r.text) <= room) {
109      kept.push(r)
110      room -= cols(r.text)
111      continue
112    }
113    kept.push({ ...r, text: `${cut(r.text, room).trimEnd()}…` })
114    break
115  }
116  return kept
117}
118
119/** Whether a tree beneath draws anything: core's own component and empty Boxes do not. */
120function drawable(node: RenderNode | null | undefined): boolean {
121  if (node === null || node === undefined) return false
122  if (typeof node === 'string') return node.length > 0
123  if (typeof node !== 'object') return false
124  if (node.type === 'engine') return false
125  if (node.type === 'Box' || node.type === 'Text') return (node.children ?? []).some(drawable)
126  return true
127}
128
129function messageOf(err: unknown): string {
130  return err instanceof Error ? err.message : String(err)
131}
132
133function style(look: Look, pal: Palette): TextProps {
134  const out: TextProps = {}
135  if (look.color) out.color = pal[look.color]
136  if (look.bold) out.bold = true
137  if (look.dim) out.dimColor = true
138  return out
139}
140
141function toneLook(tone: Tone): Look {
142  if (tone === 'success') return { color: 'success' }
143  if (tone === 'error') return { color: 'error' }
144  if (tone === 'warning') return { color: 'warning' }
145  if (tone === 'pending') return { color: 'warning' }
146  return { dim: true }
147}
148
149// ---------------------------------------------------------------- fetching
150
151function failure(out: { exitCode: number; stderr: string }): string {
152  return firstLine(out.stderr) || `exit ${out.exitCode}`
153}
154
155async function tick($: EngineInterface, st: St, isRecheck: boolean): Promise<void> {
156  const cwd = await $.session.cwd()
157  const prev = await read($, cacheAtom)
158  let repo = await read($, repoAtom)
159  if (!repo || repo.cwd !== cwd) {
160    let found: RepoInfo
161    try {
162      const out = await $.process.run(REPO_VIEW_ARGV, { cwd, timeoutMs: LIST_TIMEOUT_MS })
163      if (out.exitCode !== 0) throw new Error(failure(out))
164      found = parseRepo(out.stdout, cwd)
165    } catch (err) {
166      const error = messageOf(err)
167      await update($, repoAtom, () => null)
168      await update($, cacheAtom, () => ({ cwd, prs: [], fetchedAt: null, error }))
169      return
170    }
171    repo = found
172    await update($, repoAtom, () => found)
173  }
174
175  const kept = prev.cwd === cwd ? prev.prs : []
176  let next: Pr[]
177  try {
178    const out = await $.process.run(PR_LIST_ARGV, { cwd, timeoutMs: LIST_TIMEOUT_MS })
179    if (out.exitCode !== 0) throw new Error(failure(out))
180    next = parsePrs(out.stdout, kept)
181  } catch (err) {
182    const error = firstLine(messageOf(err)) || 'gh pr list failed'
183    await update($, cacheAtom, c => (c.cwd === cwd ? { ...c, error } : { cwd, prs: [], fetchedAt: null, error }))
184    return
185  }
186
187  if (prev.fetchedAt !== null && prev.cwd === cwd) {
188    const mine = await read($, mineAtom)
189    for (const text of toastsBetween(prev.prs, next, mine, st.mergedHere)) $.ui.toast(text)
190  }
191  st.mergedHere = unconsumedMerges(st.mergedHere, next)
192  const holds = await read($, fixAtom)
193  const stillHeld = Object.entries(holds).filter(([key, hold]) => {
194    const pr = next.find(p => p.number === Number(key.slice(key.indexOf(':') + 1)))
195    return pr !== undefined && pr.headRefOid === hold.oid
196  })
197  if (stillHeld.length !== Object.keys(holds).length) await update($, fixAtom, () => Object.fromEntries(stillHeld))
198  const now = await $.clock.now()
199  await update($, cacheAtom, () => ({ cwd, prs: next, fetchedAt: now, error: null }))
200
201  // GitHub answers UNKNOWN while it computes mergeability: look again soon, a few times
202  if (!isRecheck) st.recheck = 0
203  st.recheckTimer?.cancel()
204  st.recheckTimer = null
205  const computing = next.some(p => p.mergeStateStatus === 'UNKNOWN' || p.mergeable === 'UNKNOWN')
206  if (computing && st.recheck < RECHECK_MAX) {
207    st.recheck += 1
208    st.recheckTimer = $.clock.after(RECHECK_MS, () => {
209      st.recheckTimer = null
210      void kick($, st, true)
211    })
212  }
213}
214
215/** Starts a fetch, or joins the one in flight. */
216function kick($: EngineInterface, st: St, isRecheck = false): Promise<void> {
217  if (st.running) return st.running
218  st.running = tick($, st, isRecheck)
219    .catch(() => {
220      // a failed read leaves the cache as it was; the next tick tries again
221    })
222    .finally(() => {
223      st.running = null
224    })
225  return st.running
226}
227
228/** A fetch that starts now: one already in flight may predate what changed. */
229async function refresh($: EngineInterface, st: St): Promise<void> {
230  if (st.running) await st.running
231  await kick($, st)
232}
233
234async function paneShown($: EngineInterface): Promise<boolean> {
235  try {
236    return (await $.ui.panes()).some(p => p.id === PANE && p.isShown)
237  } catch {
238    return false
239  }
240}
241
242/** Polls only while something shows the PRs: the band (PRs open) or the pane. */
243async function poll($: EngineInterface, st: St): Promise<void> {
244  const cache = await read($, cacheAtom)
245  if (cache.prs.length === 0 && !(await paneShown($))) return
246  await kick($, st)
247}
248
249// ---------------------------------------------------------------- actions
250
251async function prOf($: EngineInterface, n: number): Promise<Pr | undefined> {
252  return (await read($, cacheAtom)).prs.find(p => p.number === n)
253}
254
255async function setBusy($: EngineInterface, n: number, what: string | null): Promise<void> {
256  await update($, busyAtom, b => {
257    const out = { ...b }
258    if (what === null) delete out[String(n)]
259    else out[String(n)] = what
260    return out
261  })
262}
263
264async function pressMerge($: EngineInterface, st: St, n: number): Promise<void> {
265  if ((await read($, busyAtom))[String(n)]) return
266  const repo = await read($, repoAtom)
267  const method = repo?.method
268  if (!repo || !method) return
269  const now = await $.clock.now()
270  const armed = await read($, armedAtom)
271  const pr = await prOf($, n)
272  if (!armed || armed.number !== n || now >= armed.until) {
273    if (!pr || !canMerge(pr)) return
274    const until = now + ARM_MS
275    const oid = pr.headRefOid
276    await update($, armedAtom, () => ({ number: n, until, oid }))
277    st.disarmTimer?.cancel()
278    st.disarmTimer = $.clock.after(ARM_MS, () => {
279      st.disarmTimer = null
280      void update($, armedAtom, a => (a && a.number === n && a.until === until ? null : a))
281    })
282    return
283  }
284  st.disarmTimer?.cancel()
285  st.disarmTimer = null
286  await update($, armedAtom, () => null)
287  if (!stillMergeable(pr, armed.oid)) {
288    $.ui.toast(`#${n} changed since you armed it; press Merge again`)
289    return
290  }
291  await setBusy($, n, 'merge')
292  try {
293    const out = await $.process.run(['gh', 'pr', 'merge', String(n), `--${method}`, '--match-head-commit', armed.oid], { cwd: repo.cwd, timeoutMs: ACTION_TIMEOUT_MS })
294    if (out.exitCode === 0) {
295      st.mergedHere.push(n)
296      $.ui.toast(`Merged #${n} (${method})`)
297    } else {
298      $.ui.toast(`Merge #${n} failed: ${failure(out)}`)
299    }
300  } catch (err) {
301    $.ui.toast(`Merge #${n} failed: ${firstLine(messageOf(err))}`)
302  } finally {
303    await setBusy($, n, null)
304  }
305  await refresh($, st)
306}
307
308async function pressUpdate($: EngineInterface, st: St, n: number): Promise<void> {
309  if ((await read($, busyAtom))[String(n)]) return
310  const repo = await read($, repoAtom)
311  const pr = await prOf($, n)
312  if (!repo || !pr) return
313  await setBusy($, n, 'update')
314  try {
315    const out = await $.process.run(['gh', 'pr', 'update-branch', String(n)], { cwd: repo.cwd, timeoutMs: ACTION_TIMEOUT_MS })
316    if (out.exitCode === 0) $.ui.toast(`Updated #${n} from ${pr.baseRefName}`)
317    else $.ui.toast(`Update #${n} failed: ${failure(out)}`)
318  } catch (err) {
319    $.ui.toast(`Update #${n} failed: ${firstLine(messageOf(err))}`)
320  } finally {
321    await setBusy($, n, null)
322  }
323  await refresh($, st)
324}
325
326async function dropHold($: EngineInterface, key: string, hold: FixHold): Promise<void> {
327  await update($, fixAtom, h => {
328    const cur = h[key]
329    if (!cur || cur.at !== hold.at || cur.oid !== hold.oid) return h
330    const out = { ...h }
331    delete out[key]
332    return out
333  })
334}
335
336async function pressFix($: EngineInterface, n: number, kind: FixKind): Promise<void> {
337  const pr = await prOf($, n)
338  if (!pr) return
339  const key = `${kind}:${n}`
340  const now = await $.clock.now()
341  if (isHeld((await read($, fixAtom))[key], pr, now)) return
342  // the hold goes up before the prompt: a second press while it submits does nothing
343  const hold: FixHold = { at: now, oid: pr.headRefOid, kind }
344  await update($, fixAtom, h => ({ ...h, [key]: hold }))
345  $.clock.after(FIX_HOLD_MS, () => void dropHold($, key, hold))
346  const what = kind === 'conflict' ? 'conflict' : 'CI'
347  try {
348    const sent = await $.prompt.submit({ text: kind === 'conflict' ? fixConflictPrompt(pr) : fixCiPrompt(pr) })
349    if (sent.drop !== undefined) {
350      await dropHold($, key, hold)
351      $.ui.toast(`Fix ${what} for #${n} not queued: ${firstLine(sent.drop)}`)
352      return
353    }
354    $.ui.toast(`Queued: fix ${what} for #${n}`)
355  } catch (err) {
356    await dropHold($, key, hold)
357    $.ui.toast(`Fix ${what} for #${n} not queued: ${firstLine(messageOf(err))}`)
358  }
359}
360
361/** No platform call opens a browser: the URL goes to a toast, and to the clipboard where one is reachable. */
362async function pressOpen($: EngineInterface, n: number, press: UiPressArgument): Promise<void> {
363  const pr = await prOf($, n)
364  if (!pr) return
365  let isCopied = false
366  try {
367    isCopied = (await $.ui.copy({ text: pr.url, surface: press.surface })).isCopied
368  } catch {
369    // no clipboard on this surface: the toast still carries the URL
370  }
371  $.ui.toast(`#${n} ${pr.url}${isCopied ? ' (copied)' : ''}`)
372}
373
374function textOf(ran: unknown): string {
375  if (!ran || typeof ran !== 'object') return ''
376  const r = ran as { text?: unknown; result?: unknown }
377  const parts: string[] = []
378  if (typeof r.text === 'string') parts.push(r.text)
379  const res = r.result as { stdout?: unknown } | null | undefined
380  if (res && typeof res === 'object' && typeof res.stdout === 'string') parts.push(res.stdout)
381  return parts.join('\n')
382}
383
384// ---------------------------------------------------------------- register
385
386export const register: Register = on => {
387  const st: St = { running: null, timer: null, recheckTimer: null, disarmTimer: null, recheck: 0, mergedHere: [] }
388
389  on('session.start', async ($, e, next) => {
390    await $.command.register({
391      name: PANE,
392      description: 'Open PRs of this repo: a pane with merge, update and fix actions',
393    })
394    st.timer?.cancel()
395    st.timer = $.clock.every(POLL_MS, () => void poll($, st))
396    void kick($, st)
397    const started = await next(e)
398    try {
399      const theme = (await $.config.list()).find(row => row.key === 'theme')?.value
400      await update($, themeAtom, () => (typeof theme === 'string' ? theme : null))
401    } catch {
402      // no theme read: the theme keys draw until a theme is set
403    }
404    return started
405  })
406
407  // A theme written from /config or a plugin repaints the band and the pane; a deny or a failed write keeps the palette.
408  on('config.set', { key: 'theme' }, async ($, e, next) => {
409    const set = await next(e)
410    if (set.deny === undefined && typeof set.value === 'string') {
411      const theme = set.value
412      try {
413        await update($, themeAtom, () => theme)
414      } catch {
415        // the palette stays as it was
416      }
417    }
418    return set
419  }).catch(($, e, next) => next(e))
420
421  // gh pr create names a PR this session made; a push or any gh pr command may have changed the list
422  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
423    const ran = await next(e)
424    if (ran.deny !== undefined) return ran
425    if (GH_PR_CREATE.test(e.command)) {
426      const urls = prUrlsIn(textOf(ran))
427      if (urls.length > 0) await update($, mineAtom, mine => [...new Set([...mine, ...urls])])
428    }
429    if (REFRESHES.test(e.command)) void refresh($, st)
430    return ran
431  }).catch(($, e, next) => next(e))
432
433  on('command.run', { command: PANE }, async $ => {
434    await refresh($, st)
435    try {
436      await $.ui.open({ id: PANE, title: 'PRs' })
437    } catch (err) {
438      return { text: `pr-mod: the pane did not open: ${messageOf(err)}` }
439    }
440    const repo = await read($, repoAtom)
441    const cache = await read($, cacheAtom)
442    if (!repo) return { text: `pr-mod: no GitHub repo here (${cache.error ?? 'not read yet'})` }
443    return { text: `Opened the PR pane: ${repo.nameWithOwner}, ${cache.prs.length} open` }
444  })
445
446  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
447    const { Box, Button, Text } = $.ui.resolve(e)
448    const cache = await read($, cacheAtom)
449    const repo = await read($, repoAtom)
450    const mine = await read($, mineAtom)
451    const armed = await read($, armedAtom)
452    const holds = await read($, fixAtom)
453    const busy = await read($, busyAtom)
454    const now = await $.clock.now()
455    const width = e.props.bodyColumns
456    const pal = paletteOf(await read($, themeAtom))
457    const texts = (runs: Run[]) => runs.map(r => <Text {...style(r, pal)}>{r.text}</Text>)
458
459    if (!repo) {
460      const line = cache.error ? `pr-mod: no GitHub repo here (${cache.error})` : 'pr-mod: reading open PRs…'
461      // the reason is the point of this line: it wraps rather than losing its end
462      return (
463        <Box flexDirection="column">
464          <Box key="none">
465            <Text dimColor wrap="wrap">
466              {line}
467            </Text>
468          </Box>
469        </Box>
470      )
471    }
472
473    const rows: RenderElement[] = []
474    const refreshW = cols('Refresh') + BUTTON_CHROME_W
475    const head = [
476      run(repo.nameWithOwner, { dim: true }),
477      run(' · ', { dim: true }),
478      run(String(cache.prs.length), { bold: true, color: 'claude' }),
479      run(' open', { dim: true }),
480      run(' · ', { dim: true }),
481      run(cache.fetchedAt === null ? 'not fetched yet' : `fetched ${ago(now - cache.fetchedAt)}`, { dim: true }),
482    ]
483    rows.push(
484      <Box key="header" flexDirection="row" justifyContent="space-between">
485        <Box flexDirection="row">{texts(clip(head, width - refreshW - GAP))}</Box>
486        <Button key="refresh" label="Refresh" onPress={() => refresh($, st)} />
487      </Box>,
488    )
489    if (cache.error) rows.push(<Box key="stale">{texts(clip([run(`stale: ${cache.error}`, { dim: true })], width))}</Box>)
490
491    const sorted = sortPrs(cache.prs, mine)
492    const room = Math.max(1, e.props.scroll.bodyRows - rows.length)
493    const shown = sorted.length > room ? sorted.slice(0, room - 1) : sorted
494
495    for (const pr of shown) {
496      const n = pr.number
497      const ci = ciPart(pr)
498      const state = statePart(pr)
499      const left: Run[] = [run(`#${n}`, { bold: true }), run(' '), { text: pr.title, shrink: true }, run('  '), run(ci.text, toneLook(ci.tone))]
500      if (state) left.push(run('  '), run(state.text, toneLook(state.tone)))
501      if (canMerge(pr) && !repo.method) left.push(run(' · no merge method allowed', { dim: true }))
502      if (isMine(pr, mine)) left.push(run(' · mine', { dim: true }))
503
504      type Act = { key: string; label: string; dim?: boolean; onPress: (press: UiPressArgument) => void }
505      const acts: Act[] = []
506      const doing = busy[String(n)]
507      if (canMerge(pr) && repo.method) {
508        const isArmed = armed !== null && armed.number === n && now < armed.until
509        const text = doing === 'merge' ? 'Merging…' : isArmed ? `Confirm merge #${n} (${repo.method})` : 'Merge'
510        acts.push({ key: `merge:${n}`, label: text, onPress: () => pressMerge($, st, n) })
511      }
512      if (pr.mergeStateStatus === 'BEHIND') {
513        acts.push({ key: `update:${n}`, label: doing === 'update' ? 'Updating…' : 'Update branch', onPress: () => pressUpdate($, st, n) })
514      }
515      if (isConflict(pr)) {
516        const held = isHeld(holds[`conflict:${n}`], pr, now)
517        acts.push({ key: `fixc:${n}`, label: held ? 'fix sent' : 'Fix conflict', dim: held, onPress: () => pressFix($, n, 'conflict') })
518      }
519      if (rollup(pr.checks).kind === 'red') {
520        const held = isHeld(holds[`ci:${n}`], pr, now)
521        acts.push({ key: `fixci:${n}`, label: held ? 'fix sent' : 'Fix CI', dim: held, onPress: () => pressFix($, n, 'ci') })
522      }
523      acts.push({ key: `open:${n}`, label: 'Open', dim: true, onPress: press => pressOpen($, n, press) })
524
525      const actsW = acts.reduce((w, a) => w + cols(a.label) + BUTTON_CHROME_W, 0) + GAP * (acts.length - 1)
526      rows.push(
527        <Box key={`pr:${n}`} flexDirection="row" justifyContent="space-between">
528          <Box flexDirection="row">{texts(clip(left, width - actsW - GAP))}</Box>
529          <Box flexDirection="row" columnGap={GAP}>
530            {acts.map(a => (
531              <Button key={a.key} label={a.label} dimColor={a.dim === true} onPress={a.onPress} />
532            ))}
533          </Box>
534        </Box>,
535      )
536    }
537    if (shown.length < sorted.length) {
538      rows.push(<Box key="more">{texts([run(`+${sorted.length - shown.length} more`, { dim: true })])}</Box>)
539    }
540    return <Box flexDirection="column">{rows}</Box>
541  })
542
543  // One line of counts over what the plugins beneath draw; nothing when no PR is open, and a survey passes through.
544  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
545    const cache = await read($, cacheAtom)
546    const repo = await read($, repoAtom)
547    if (e.props.hasSurvey || !repo || cache.prs.length === 0) return next(e)
548    const below = await next(e)
549    const { Box, Text } = $.ui.resolve(e)
550    const pal = paletteOf(await read($, themeAtom))
551    const c = bandCounts(cache.prs)
552    const runs: Run[] = [run('PR', { bold: true, color: 'claude' }), run(' '), run(String(c.open), { bold: true, color: 'claude' }), run(' open', { dim: true })]
553    const add = (count: number, text: string, look: Look) => {
554      if (count > 0) runs.push(run(' · ', { dim: true }), run(text, look))
555    }
556    add(c.green, `✔ ${c.green} green`, { color: 'success' })
557    add(c.red, `✖ ${c.red} CI red`, { color: 'error' })
558    add(c.conflict, `⚠ ${c.conflict} conflict`, { color: 'warning' })
559    add(c.pending, `◌ ${c.pending} pending`, { color: 'warning' })
560    if (cache.error) runs.push(run(' · stale', { dim: true }))
561    const rows: RenderElement[] = [
562      <Box key="pr-mod" flexDirection="row">
563        {clip(runs, e.props.bodyColumns).map(r => (
564          <Text {...style(r, pal)}>{r.text}</Text>
565        ))}
566      </Box>,
567    ]
568    if (drawable(below)) rows.push(<Box key="below" flexDirection="column">{below}</Box>)
569    return <Box flexDirection="column">{rows}</Box>
570  })
571}
572
hooks/prs.ts 338 lines
1// The PR model: what `gh` answers, read into plain values the band, the pane and the toasts draw from.
2// No `$` here: every function is pure, so the tests drive it with plain JSON.
3import type { Check, FixHold, MergeMethod, Pr, RepoInfo } from '../types'
4
5export type CheckClass = 'ok' | 'bad' | 'pending'
6export type RollupKind = 'green' | 'red' | 'pending' | 'none'
7export type Rollup = { kind: RollupKind; failing: Check[] }
8export type Counts = { open: number; green: number; red: number; conflict: number; pending: number }
9export type Tone = 'success' | 'error' | 'warning' | 'pending' | 'dim'
10export type Part = { text: string; tone: Tone }
11
12const PR_FIELDS = [
13  'number', 'title', 'headRefName', 'baseRefName', 'isDraft', 'author', 'mergeable', 'mergeStateStatus',
14  'reviewDecision', 'statusCheckRollup', 'url', 'updatedAt', 'headRefOid',
15].join(',')
16
17export const PR_LIST_ARGV: readonly string[] = ['gh', 'pr', 'list', '--state', 'open', '--json', PR_FIELDS, '--limit', '50']
18export const REPO_VIEW_ARGV: readonly string[] = ['gh', 'repo', 'view', '--json', 'nameWithOwner,squashMergeAllowed,mergeCommitAllowed,rebaseMergeAllowed']
19/** How long a sent fix holds its button, unless the PR's head moves first. */
20export const FIX_HOLD_MS = 15 * 60_000
21
22type Json = Record<string, unknown>
23
24function obj(v: unknown): Json {
25  return v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Json) : {}
26}
27function str(v: unknown): string {
28  return typeof v === 'string' ? v : ''
29}
30
31function checkOf(v: unknown): Check {
32  const c = obj(v)
33  if (c.__typename === 'StatusContext') {
34    return { type: 'StatusContext', name: str(c.context) || str(c.name), status: '', conclusion: '', state: str(c.state), url: str(c.targetUrl) }
35  }
36  return {
37    type: 'CheckRun',
38    name: str(c.name) || str(c.context),
39    status: str(c.status),
40    conclusion: str(c.conclusion),
41    state: '',
42    url: str(c.detailsUrl) || str(c.targetUrl),
43  }
44}
45
46/**
47 * `gh pr list --json` into Prs. An UNKNOWN merge state (GitHub still computing) keeps the state the
48 * previous poll knew, so a recompute does not read as a change. Throws on anything but a JSON array.
49 */
50export function parsePrs(stdout: string, prev: readonly Pr[] = []): Pr[] {
51  const data: unknown = JSON.parse(stdout)
52  if (!Array.isArray(data)) throw new Error('gh pr list did not answer a JSON array')
53  return data.map((item): Pr => {
54    const p = obj(item)
55    const number = typeof p.number === 'number' ? p.number : Number(p.number) || 0
56    const state = str(p.mergeStateStatus)
57    const rollupRaw: unknown = p.statusCheckRollup
58    const lastKnownState = state && state !== 'UNKNOWN' ? state : (prev.find(x => x.number === number)?.lastKnownState ?? '')
59    return {
60      number,
61      title: str(p.title),
62      headRefName: str(p.headRefName),
63      baseRefName: str(p.baseRefName),
64      isDraft: p.isDraft === true,
65      author: str(obj(p.author).login),
66      mergeable: str(p.mergeable),
67      mergeStateStatus: state,
68      lastKnownState,
69      reviewDecision: str(p.reviewDecision),
70      checks: Array.isArray(rollupRaw) ? rollupRaw.map(checkOf) : [],
71      url: str(p.url),
72      updatedAt: str(p.updatedAt),
73      headRefOid: str(p.headRefOid),
74    }
75  })
76}
77
78/** squash, else merge, else rebase; null when the repo allows none. */
79export function mergeMethod(allowed: { squash: boolean; merge: boolean; rebase: boolean }): MergeMethod | null {
80  if (allowed.squash) return 'squash'
81  if (allowed.merge) return 'merge'
82  if (allowed.rebase) return 'rebase'
83  return null
84}
85
86export function parseRepo(stdout: string, cwd: string): RepoInfo {
87  const data: unknown = JSON.parse(stdout)
88  if (data === null || typeof data !== 'object' || Array.isArray(data)) throw new Error('gh repo view did not answer a JSON object')
89  const r = data as Json
90  return {
91    cwd,
92    nameWithOwner: str(r.nameWithOwner),
93    method: mergeMethod({ squash: r.squashMergeAllowed === true, merge: r.mergeCommitAllowed === true, rebase: r.rebaseMergeAllowed === true }),
94  }
95}
96
97const RUN_OK = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED'])
98const RUN_BAD = new Set(['FAILURE', 'CANCELLED', 'TIMED_OUT', 'ACTION_REQUIRED', 'STARTUP_FAILURE'])
99
100/** A StatusContext reads its state; a CheckRun its status, then its conclusion. */
101export function checkClass(c: Check): CheckClass {
102  if (c.type === 'StatusContext') {
103    if (c.state === 'SUCCESS') return 'ok'
104    if (c.state === 'FAILURE' || c.state === 'ERROR') return 'bad'
105    return 'pending'
106  }
107  if (c.status !== 'COMPLETED') return 'pending'
108  if (RUN_OK.has(c.conclusion)) return 'ok'
109  if (RUN_BAD.has(c.conclusion)) return 'bad'
110  return 'pending'
111}
112
113/** red if any check failed, else pending if any runs, else green; none without checks. */
114export function rollup(checks: readonly Check[]): Rollup {
115  if (checks.length === 0) return { kind: 'none', failing: [] }
116  const classes = checks.map(checkClass)
117  const failing = checks.filter((_, i) => classes[i] === 'bad')
118  if (failing.length > 0) return { kind: 'red', failing }
119  if (classes.includes('pending')) return { kind: 'pending', failing: [] }
120  return { kind: 'green', failing: [] }
121}
122
123export function isConflict(pr: Pr): boolean {
124  return pr.mergeStateStatus === 'DIRTY' || pr.mergeable === 'CONFLICTING'
125}
126
127function blocker(pr: Pr): string {
128  if (pr.reviewDecision === 'REVIEW_REQUIRED') return 'review required'
129  if (pr.reviewDecision === 'CHANGES_REQUESTED') return 'changes requested'
130  const kind = rollup(pr.checks).kind
131  if (kind === 'red') return 'failing checks'
132  if (kind === 'pending') return 'pending checks'
133  return 'branch rules'
134}
135
136/** Why Merge is not offered, or null when it is: CI green, state CLEAN, not a draft. */
137export function mergeBlock(pr: Pr): string | null {
138  if (pr.isDraft || pr.mergeStateStatus === 'DRAFT') return 'draft'
139  const kind = rollup(pr.checks).kind
140  if (kind === 'red') return 'CI red'
141  if (kind === 'pending') return 'CI pending'
142  if (kind === 'none') return 'no checks'
143  switch (pr.mergeStateStatus) {
144    case 'CLEAN': return null
145    case 'DIRTY': return 'conflict'
146    case 'BEHIND': return `behind ${pr.baseRefName}`
147    case 'BLOCKED': return `blocked: ${blocker(pr)}`
148    case 'UNSTABLE': return 'unstable'
149    case 'UNKNOWN': return 'computing'
150    default: return pr.mergeStateStatus.toLowerCase() || 'computing'
151  }
152}
153
154export function canMerge(pr: Pr): boolean {
155  return mergeBlock(pr) === null
156}
157
158/** Whether the PR armed at `oid` may still be merged: present, mergeable, and still at that head commit. */
159export function stillMergeable(pr: Pr | undefined, oid: string): pr is Pr {
160  return pr !== undefined && canMerge(pr) && pr.headRefOid === oid
161}
162
163export function ciPart(pr: Pr): Part {
164  const r = rollup(pr.checks)
165  if (r.kind === 'green') return { text: '✔ green', tone: 'success' }
166  if (r.kind === 'red') return { text: `✖ CI red (${r.failing.length})`, tone: 'error' }
167  if (r.kind === 'pending') return { text: '◌ pending', tone: 'pending' }
168  return { text: 'no checks', tone: 'dim' }
169}
170
171/** The merge state as a mark; null where the CI part already says it. */
172export function statePart(pr: Pr): Part | null {
173  const s = pr.mergeStateStatus
174  if (s === 'DRAFT' || (pr.isDraft && s === 'CLEAN')) return { text: 'draft', tone: 'dim' }
175  switch (s) {
176    case 'CLEAN': return { text: '⚑ CLEAN', tone: 'success' }
177    case 'DIRTY': return { text: '⚠ conflict', tone: 'warning' }
178    case 'BEHIND': return { text: `↓ behind ${pr.baseRefName}`, tone: 'warning' }
179    case 'BLOCKED': return { text: `⊘ blocked: ${blocker(pr)}`, tone: 'warning' }
180    case 'UNSTABLE': return rollup(pr.checks).kind === 'red' ? null : { text: '⚠ unstable', tone: 'warning' }
181    case 'UNKNOWN': return { text: '… computing', tone: 'dim' }
182    case '': return null
183    default: return { text: s.toLowerCase(), tone: 'dim' }
184  }
185}
186
187export function isMine(pr: Pr, mine: readonly string[]): boolean {
188  return mine.includes(pr.url)
189}
190
191/** This session's PRs first, then the most recently updated. */
192export function sortPrs(prs: readonly Pr[], mine: readonly string[]): Pr[] {
193  const t = (p: Pr) => Date.parse(p.updatedAt) || 0
194  return [...prs].sort(
195    (a, b) => Number(isMine(b, mine)) - Number(isMine(a, mine)) || t(b) - t(a) || b.number - a.number,
196  )
197}
198
199export function bandCounts(prs: readonly Pr[]): Counts {
200  const out: Counts = { open: prs.length, green: 0, red: 0, conflict: 0, pending: 0 }
201  for (const pr of prs) {
202    const kind = rollup(pr.checks).kind
203    if (kind === 'green') out.green++
204    else if (kind === 'red') out.red++
205    else if (kind === 'pending') out.pending++
206    if (isConflict(pr)) out.conflict++
207  }
208  return out
209}
210
211/** A one-line, quoted copy of text from GitHub: a title cannot open a line of its own in a prompt. */
212function quoted(text: string): string {
213  return JSON.stringify(text.replace(/\s+/g, ' ').trim())
214}
215
216/** A URL from GitHub, only when it is one plain https token: anything else is left out of a prompt. */
217function safeUrl(url: string): string | null {
218  return /^https:\/\/\S+$/.test(url) && !/["'`]/.test(url) ? url : null
219}
220
221function prHeader(lead: string, pr: Pr): string[] {
222  const url = safeUrl(pr.url)
223  return [
224    `${lead} pull request #${pr.number} ${quoted(pr.title)}.`,
225    ...(url ? [`URL: ${url}`] : []),
226    `Head branch: ${quoted(pr.headRefName)}`,
227    `Base branch: ${quoted(pr.baseRefName)}`,
228  ]
229}
230
231export function fixConflictPrompt(pr: Pr): string {
232  return [
233    ...prHeader('Fix the merge conflict on', pr),
234    '',
235    'Steps:',
236    `1. If the head branch is not checked out here, work in a separate git worktree rather than switching this checkout's branch.`,
237    `2. Fetch, then merge ${quoted(`origin/${pr.baseRefName}`)} into the head branch. Do not rebase and do not force-push.`,
238    "3. Resolve each conflict keeping both sides' intent.",
239    "4. Run the project's checks and commit.",
240    '5. Push.',
241    '6. Report what conflicted and how it was resolved.',
242    '',
243    'Do not merge the PR.',
244  ].join('\n')
245}
246
247function runId(url: string): string | null {
248  return /\/actions\/runs\/(\d+)/.exec(url)?.[1] ?? null
249}
250
251export function fixCiPrompt(pr: Pr): string {
252  const failing = rollup(pr.checks).failing.map(c => {
253    const url = safeUrl(c.url)
254    const id = url ? runId(url) : null
255    return `- ${quoted(c.name)}${id ? ` (run ${id})` : ''}${url ? `: ${url}` : ''}`
256  })
257  return [
258    ...prHeader('Fix the failing CI on', pr),
259    '',
260    'Failing checks:',
261    ...failing,
262    '',
263    'Steps:',
264    `1. Read the failure with \`gh pr checks ${pr.number}\` and \`gh run view <run-id> --log-failed\`.`,
265    '2. Reproduce it locally.',
266    `3. Fix it in the head branch. If that branch is not checked out here, work in a separate git worktree rather than switching this checkout's branch.`,
267    "4. Run the project's checks, then push.",
268    '5. Report the root cause.',
269    '',
270    'Do not merge the PR.',
271  ].join('\n')
272}
273
274function conflicting(pr: Pr): boolean {
275  return pr.lastKnownState === 'DIRTY' || pr.mergeable === 'CONFLICTING'
276}
277function known(pr: Pr): boolean {
278  return pr.lastKnownState !== '' || pr.mergeable === 'MERGEABLE' || pr.mergeable === 'CONFLICTING'
279}
280function titleOf(pr: Pr): string {
281  return pr.title.replace(/\s+/g, ' ').trim()
282}
283
284/**
285 * What changed between two polls, as toast lines: CI went red (any PR), CI went green (mine),
286 * turned conflicting (any PR), left the list (mine, unless this session merged it). A PR new to the
287 * list toasts nothing.
288 */
289export function toastsBetween(prev: readonly Pr[], next: readonly Pr[], mine: readonly string[], mergedHere: readonly number[]): string[] {
290  const out: string[] = []
291  for (const now of next) {
292    const was = prev.find(p => p.number === now.number)
293    if (!was) continue
294    const before = rollup(was.checks).kind
295    const after = rollup(now.checks).kind
296    if (after === 'red' && before !== 'red') out.push(`#${now.number} CI went red: ${titleOf(now)}`)
297    if (after === 'green' && before !== 'green' && isMine(now, mine)) out.push(`#${now.number} CI is green: ${titleOf(now)}`)
298    if (conflicting(now) && !conflicting(was) && known(was)) out.push(`#${now.number} has a conflict with ${now.baseRefName}: ${titleOf(now)}`)
299  }
300  for (const was of prev) {
301    if (next.some(p => p.number === was.number)) continue
302    if (!isMine(was, mine) || mergedHere.includes(was.number)) continue
303    out.push(`#${was.number} left the open list (merged or closed): ${titleOf(was)}`)
304  }
305  return out
306}
307
308/**
309 * The merges this session made that the list has not yet lost: a number is only needed to mute the
310 * "left the open list" toast, which fires on the poll that sees the PR gone, so it is dropped then.
311 */
312export function unconsumedMerges(mergedHere: readonly number[], next: readonly Pr[]): number[] {
313  return mergedHere.filter(n => next.some(p => p.number === n))
314}
315
316export function isHeld(hold: FixHold | undefined, pr: Pr | undefined, now: number): boolean {
317  if (!hold || !pr) return false
318  return hold.oid === pr.headRefOid && now < hold.at + FIX_HOLD_MS
319}
320
321export function prUrlsIn(text: string): string[] {
322  return [...new Set(text.match(/https?:\/\/[^\s"'<>]+\/pull\/\d+/g) ?? [])]
323}
324
325export function firstLine(text: string): string {
326  return text.split('\n').map(l => l.trim()).find(l => l.length > 0) ?? ''
327}
328
329export function ago(ms: number): string {
330  const s = Math.floor(Math.max(0, ms) / 1000)
331  if (s < 60) return `${s}s ago`
332  const m = Math.floor(s / 60)
333  if (m < 60) return `${m}m ago`
334  const h = Math.floor(m / 60)
335  if (h < 24) return `${h}h ago`
336  return `${Math.floor(h / 24)}d ago`
337}
338
hooks/palette.ts 23 lines
1// The colours pr-mod draws with: the Catppuccin pastels bs-mod uses, by the same theme rule and the same hex
2// values, so one colour means the same thing in both bands. A plugin cannot import another's files, so this is
3// pr-mod's own copy of the few roles it needs; keep it in step with bs-mod's hooks/fold.ts `paletteOf`.
4export type PaletteRole = 'success' | 'error' | 'warning' | 'claude'
5export type Palette = Readonly<Record<PaletteRole, string>>
6
7/** Catppuccin Mocha, for a dark theme. */
8export const MOCHA: Palette = { success: '#a6e3a1', error: '#f38ba8', warning: '#f9e2af', claude: '#fab387' }
9
10/** Catppuccin Latte, for a light theme. */
11export const LATTE: Palette = { success: '#40a02b', error: '#d20f39', warning: '#df8e1d', claude: '#fe640b' }
12
13/** The engine's own theme keys: what a daltonized, `auto` or unknown theme, or an unread one, keeps. */
14export const THEME_KEYS: Palette = { success: 'success', error: 'error', warning: 'warning', claude: 'claude' }
15
16/** The palette for a `/config` theme name: Latte for `light*`, Mocha for `dark*`, the theme keys for anything else. */
17export function paletteOf(theme: string | null | undefined): Palette {
18  if (typeof theme !== 'string' || theme.includes('daltonized')) return THEME_KEYS
19  if (theme.startsWith('light')) return LATTE
20  if (theme.startsWith('dark')) return MOCHA
21  return THEME_KEYS
22}
23
types/index.d.ts 69 lines
1// The pr-mod contract: what the mod keeps in `$.state`, and the shapes its
2// pure module (hooks/prs.ts) hands the drawing.
3
4/** One entry of `statusCheckRollup`, flattened: a CheckRun carries status/conclusion, a StatusContext state. */
5export type Check = {
6  type: 'CheckRun' | 'StatusContext'
7  name: string
8  /** CheckRun: QUEUED, IN_PROGRESS, COMPLETED, ...; '' for a StatusContext */
9  status: string
10  /** CheckRun once COMPLETED: SUCCESS, FAILURE, ...; '' otherwise */
11  conclusion: string
12  /** StatusContext: SUCCESS, FAILURE, ERROR, PENDING, EXPECTED; '' for a CheckRun */
13  state: string
14  /** detailsUrl or targetUrl */
15  url: string
16}
17
18/** An open pull request as `gh pr list --json ...` answers it, normalized. */
19export type Pr = {
20  number: number
21  title: string
22  headRefName: string
23  baseRefName: string
24  isDraft: boolean
25  /** the author's login */
26  author: string
27  mergeable: string
28  mergeStateStatus: string
29  /** the last mergeStateStatus that was not UNKNOWN, kept across polls so a recompute does not read as a change */
30  lastKnownState: string
31  reviewDecision: string
32  checks: Check[]
33  url: string
34  updatedAt: string
35  headRefOid: string
36}
37
38export type MergeMethod = 'squash' | 'merge' | 'rebase'
39
40/** The last good `gh pr list`, when it was read, and why the latest read failed (null: it did not). */
41export type PrCache = { cwd: string; prs: Pr[]; fetchedAt: number | null; error: string | null }
42
43/** `gh repo view`, read once per session and cwd; method null when the repo allows none. */
44export type RepoInfo = { cwd: string; nameWithOwner: string; method: MergeMethod | null }
45
46/** A Merge button pressed once: the second press within `until` merges. */
47export type Armed = { number: number; until: number; oid: string }
48
49/** A fix prompt sent for a PR: held until its head moves or 15 minutes pass. */
50export type FixHold = { at: number; oid: string; kind: 'conflict' | 'ci' }
51
52declare module 'claude-code' {
53  interface PluginState {
54    'pr-mod': {
55      cache: PrCache
56      repo: RepoInfo | null
57      /** URLs of the PRs this session's `gh pr create` printed */
58      mine: string[]
59      armed: Armed | null
60      /** per PR number */
61      fixSent: Record<string, FixHold>
62      /** per PR number: what runs on it now (merging, updating) */
63      busy: Record<string, string>
64      /** the `/config` theme, read at session start and on each theme write; null when unread, so the theme keys draw (hooks/palette.ts) */
65      theme: string | null
66    }
67  }
68}
69