Know when CI breaks without leaving the session: PR and check state above the prompt, a /pr pane, toasts on changes and a note to Claude when a check fails.

A loopkeeper add-on for Claude Code that shows the pull request and CI state for the current branch above the prompt, and gives Claude a one-line note when a check fails. You see a red check or a change request while you are still in the session, and Claude learns which check failed and where its run is without you copying it over.
Use it when
Install: /plugin install lk-pr-watch@loopkeeper, after /plugin marketplace add connectwithub/loopkeeper; it needs gh signed in (more under Requirements and Install). Requires Claude Code 2.1.294 or later.
Data handling: it keeps one setting (the band on or off) in its own plugin store and the watched PRs in session state; it reads ~/.claude/pr-watch.json and <project>/.claude/pr-watch.json and writes no file. Its only network traffic is gh pr view and gh api graphql, which reach GitHub's API through your own gh sign-in; it reads and stores no token. It makes no model calls; a newly failed check adds one note to the transcript. It collects no personal data; the saved setting stays until you delete it. Details under What it runs and touches.
Commands: /pr and its options are listed under Commands.
lk-pr-watch adds:
PR #42 ● checks 3/5 ✓ 1 ✗ 1 … ● changes requested ● mergeable +1 more The PR number in bold, then three chips, each in its own theme tone: the checks (ok when all passed, warning while one runs, error when one failed, quiet with none), the review decision (ok approved, error changes requested, warning review required, quiet with none) and mergeability (ok mergeable or merged, warning behind, error conflicts or closed, quiet for a draft or unknown). The right side holds +N more when several PRs are watched and refresh failed after a failed poll; it is quiet, and drawn in the default text color when the PR needs you (a failed check, changes requested, a conflict, a closed PR, or gh failing). Other bands stay below it./pr pane: the branch's PR as a hero, tiles over every watched PR, and one card per PR with its state, a checks bar, the review decision, every check with a link to its run, and buttons (see Pane).$.session.append: a user-role row Claude reads on its next turn, naming the check and its run URL. It never submits a prompt or starts a turn by itself.Needs Claude Code 2.1.294 or later.
gh on the PATH, signed in: run gh auth status once. The add-on calls gh pr view and gh api graphql in the session's working directory. No token is read or stored by the add-on.
From the loopkeeper marketplace, inside a Claude Code session:
/plugin marketplace add connectwithub/loopkeeper
/plugin install lk-pr-watch@loopkeeper
Turn it off and on again with /plugin disable lk-pr-watch@loopkeeper and /plugin enable lk-pr-watch@loopkeeper. It works with or without the loopkeeper core; with the core on, its hub links here.
To try a checkout without installing: claude --plugin-dir /path/to/loopkeeper/plugins/lk-pr-watch.
gh pr view --json number,url,title,state,isDraft,mergeable,reviewDecision,headRefName,baseRefName when the session starts, after /clear, /resume or /branch, and after any Bash call Claude makes whose command contains gh pr create, git push or gh pr merge. A new branch PR replaces the old one; a branch with no PR drops it.https://github.com/owner/repo/pull/7 (one or more URLs and nothing else) and it is consumed, no model turn runs, and the PR is watched. A prompt with a URL among other words goes to Claude unchanged./pr add <url|number>: a number is looked up in the current repo with gh pr view <number>.One gh api graphql call per watched PR, in the background; no turn waits on it.
pollSec seconds (default 120).hotPollSec seconds (default 30) while a check is pending inside the 20 minute hot window that a git push or gh pr create opens.Each poll is diffed against the previous one. The first poll of a PR reports nothing, so a check that was already red when you started watching does not toast or note.
/pr opens a pane drawn with the kit (hooks/kit.ts, a copy of the loopkeeper core's UI kit): bordered, unfilled cards on every surface, with ● text chips in the terminal and rounded pill chips in the Desktop app. At the top, a hero card (kicker pull requests) names the branch's PR, else the first one watched, with its number and title, over its state, repo and whether it is this branch's or added; its border and title take the PR's level (the theme's ok, warning or error color). Tiles count over every watched PR: Open, Checks passing (passed of all checks), Failing checks and PRs Awaiting review. Under Watched · n, each PR gets a card: a state pill (open in the info color, merged ok, closed and draft quiet, failing in the error color when a check failed) beside its number and title, with the repo on the right; a bar of passed checks with k/n checks and how many are failing or running; the review decision as a pill (approved ok, changes requested error, review required warning) with mergeability and merge state in the quiet color and the age of the last poll (polled 2m ago) on the right; every check (latest run per name) with its icon in its tone, a link to its run, its status and duration; the PR's URL; and Open in browser (xdg-open, then open), Copy URL and Stop watching. r: Refresh now and c: Close sit at the bottom. Colors are theme keys, so the pane follows the light or dark theme.
| Command | What it does | | :- | :- | | /pr | Opens the pane | | /pr add <url\|number> | Watches a PR | | /pr rm <number\|owner/repo#n\|url> | Stops watching it | | /pr refresh | Looks for the branch's PR again and polls every watched PR now | | /pr band on\|off | Shows or hides the band; saved for every project | | /pr text | Prints the state as text in the transcript |
The command is registered with immediate: true, so it runs while Claude is working.
~/.claude/pr-watch.json, then <cwd>/.claude/pr-watch.json (the project file wins field by field):
{
"pollSec": 120,
"hotPollSec": 30,
"notifyOn": ["failed", "green", "review", "merged"],
"appendNoteOnFailure": true
}
pollSec is held to 15 to 3600, hotPollSec to 10 to 3600. The timer ticks every 10 seconds, so cadences round up to the next tick.notifyOn picks which transitions toast. Unknown names are dropped; [] silences toasts.appendNoteOnFailure: false stops the note to Claude. It is independent of notifyOn.Files are read at session start and after /clear, /resume or /branch.
| File | What it holds | | :- | :- | | hooks/register.ts | The hooks: session start, Bash calls, prompt submit, /pr, band and pane | | hooks/pr.ts | Pure parts: the GraphQL query, parsers for gh pr view and the GraphQL response, the diff, the band's parts and text, cadence, config and command parsing | | hooks/kit.ts | The UI kit the band and pane are drawn with: a verbatim copy of the loopkeeper core's hooks/lib/kit.ts (CI checks that the copies match) | | types/index.d.ts | The $.state contract | | tests/pr.test.ts, tests/register.test.ts, tests/pane.test.ts, tests/kit.test.ts, tests/fixtures.ts | Tests and fixture JSON; run claude plugin test . in this folder |
From claude plugin validate --strict . and the code:
gh pr view and gh api graphql (GitHub's API, through your gh sign-in), in the session's working directory, in the background with a timeout; xdg-open, then open, when you press Open in browser.~/.claude/pr-watch.json and <cwd>/.claude/pr-watch.json; writes none.band (the /pr band on|off choice), in the plugin's store.HOME.The directory's validator listed these items for this plugin. Each bullet says what the code does and where.
MOD_RUNS_PROCESS, MOD_PROCESS_COMMAND_COMPUTED). Every program starts from one helper, run() in hooks/register.ts (line 102): $.process.run(argv, { cwd, timeoutMs: 30000 }), in the session's directory. The commands are:gh pr view --json number,url,title,state,isDraft,mergeable,reviewDecision,headRefName,baseRefName, to find the current branch's PR (discover()), and gh pr view <number> --json … the same, for /pr add <number>;gh api graphql -f query=<QUERY> -f o=<owner> -f r=<repo> -F n=<number>, to poll one PR (graphqlArgv() in hooks/pr.ts, line 222; QUERY is the fixed query at line 18 of that file);xdg-open <url>, then open <url> if that fails, only when the user presses Open in browser (openUrl()). The URL is always https://github.com/<owner>/<repo>/pull/<number>, built by refOf() in hooks/pr.ts.The program names and flags are fixed text. Only the owner, repo, PR number and URL vary; they come from gh's own answer or from a GitHub PR URL the user pasted.
gh, signed in with the user's own gh auth login; it never reads, stores or passes on that sign-in. By default it polls every 120 seconds (pollSec), and every 30 seconds (hotPollSec) while a check is pending in the 20 minutes after a git push or gh pr create (see Polling).MOD_LOCAL_DATA_LEAVES, MOD_SESSION_DATA_LEAVES). What reaches GitHub is the owner, repo and PR number, with the fixed query. The files it reads are its own config, ~/.claude/pr-watch.json and <cwd>/.claude/pr-watch.json (poll intervals, which changes to announce, whether to add the failure note); nothing from them goes into a command. The prompt.submit hook (line 423) checks only whether a prompt is nothing but GitHub PR URLs (bareUrls() in hooks/pr.ts, line 84). If it is, the plugin watches those PRs and answers { drop: 'pr-watch: watching …' }, so that prompt does not go to the model; every other prompt is passed on unchanged with next(e). When a watched check fails, announce() (line 213) appends one user-role message to the transcript, [pr-watch: automated notice, not typed by the user] Check "<name>" failed on PR #<number> (<owner>/<repo>): <link>, built from GitHub's answer; appendNoteOnFailure: false turns it off.plugin.json (warnings UNKNOWN_KEY). documentationUrl and supportUrl are listing fields the directory reads. types points at types/index.d.ts, the $.state contract that claude plugin validate checks. Claude Code ignores all three at load time.ICON_MISSING). Added at .claude-plugin/icon.png (512 × 512); its source is docs/assets/icons/lk-pr-watch.svg in the repo.MOD_CALL_NAMED_IN_TEXT). tool.call, prompt.submit and command.run are named in the header comment of hooks/register.ts (lines 6–9), which lists the hooks. The tool.call hook (line 406) watches Bash calls: it returns what next(e) gave back, unchanged, and after a call that was not refused and that ran gh pr create, gh pr merge or git push, it looks up the branch's PR again.MOD_REGISTERS_SURFACES). It registers one slash command, /pr, at session start (line 383). It registers no tools or agents.MOD_HOOKS_POLICY_EVENT). classic.SessionStart with source clear, resume or fork (line 396) loads the config and the band setting again, because those resets clear $.state, and looks up the branch's PR. It returns next(e) unchanged and alters nothing that passes through it.MOD_HOOKS_OPERATION). prompt.submit (line 423) is described above. command.run (line 445) has the matcher { command: 'pr' }, so it runs only for /pr. Neither makes the call it hooks.cc-pr-tracker by Seza Akgün, MIT licence, Copyright (c) 2026 Seza Akgün:
gh api graphql query per PR (merge state, review decision, the head commit's check rollup), here with completedAt added and isRequired left outtoCheck's buckets, the lineage key and latestPerName (latest run per check name), clean() and linkable(){ drop }, the $.session.append note, and the attempt() wrapper for calls newer than a buildThe MIT notice applies to those parts. The rest is written for this add-on.
isRequired, so an optional failing check turns the checks chip to the error color.pollSec.$.state: it survives a hot reload but not a new session, and /clear starts it again from the branch's PR.git push inside a script file Claude runs is not seen. Use /pr refresh.gh pr view for the branch needs the branch pushed with an upstream; a fork's PR may need gh pr view to resolve the head repo.hooks/register.ts 585 lines1// pr-watch: PR and CI state for the current branch above the prompt, with a note to Claude
2// when a check fails.
3//
4// session.start loads config, registers /pr, starts the poll timer, finds the branch's PR
5// classic.SessionStart after /clear, /resume or /branch ($.state was reset): the same again
6// tool.call (Bash) after `gh pr create`, `git push` or `gh pr merge`: finds the PR again;
7// a push or create opens a 20 minute "hot" window of faster polling
8// prompt.submit a prompt that is only PR URLs is consumed ({ drop }) and they are watched
9// command.run /pr and its subcommands
10// ui.render the one-line band above the prompt and the /pr pane
11//
12// All gh work runs in the background (`void work($).catch(() => {})`): no turn waits on it.
13// Helpers that take $ are top-level functions so static analysis can read them.
14//
15// Borrowed (MIT, Copyright (c) 2026 Seza Akgün, github.com/sezaakgun/cc-pr-tracker): the GraphQL
16// poll and diff approach, the bare-URL { drop }, $.session.append for a failure note, attempt().
17
18import { atom, read, update } from 'claude-code'
19import type { EngineInterface, On } from 'claude-code'
20
21import type { Config, Snapshot, Watched } from '../types'
22import { TONES, makeKit } from './kit'
23import type { Elements, Tone } from './kit'
24import {
25 DEFAULTS,
26 HELP,
27 HOT_MS,
28 ICON,
29 VIEW_FIELDS,
30 REVIEW_TONE,
31 bandParts,
32 bandText,
33 bareUrls,
34 countsOf,
35 diff,
36 durationOf,
37 failureNote,
38 graphqlArgv,
39 isDiscoveryCommand,
40 isDue,
41 isNoPr,
42 isPushCommand,
43 levelOf,
44 linkable,
45 matchTarget,
46 mergeConfig,
47 mergeText,
48 newWatched,
49 parseCommand,
50 parseGraphql,
51 parsePrUrl,
52 parsePrView,
53 primaryOf,
54 reviewText,
55 summaryText,
56 toastText,
57} from './pr'
58import type { PrRef, Transition } from './pr'
59
60const PANE = 'pr-watch'
61const CONFIG_FILE = '.claude/pr-watch.json'
62const BAND_KEY = 'band'
63/** The timer ticks this often and polls only when the cadence says a poll is due. */
64const TICK_MS = 10_000
65const GH_TIMEOUT_MS = 30_000
66
67const prsAtom = atom({ plugin: 'lk-pr-watch', key: 'prs' } as const, [] as Watched[])
68const configAtom = atom({ plugin: 'lk-pr-watch', key: 'config' } as const, DEFAULTS)
69const bandAtom = atom({ plugin: 'lk-pr-watch', key: 'band' } as const, true)
70const cwdAtom = atom({ plugin: 'lk-pr-watch', key: 'cwd' } as const, '')
71const hotAtom = atom({ plugin: 'lk-pr-watch', key: 'hotUntil' } as const, 0)
72const lastPollAtom = atom({ plugin: 'lk-pr-watch', key: 'lastPoll' } as const, 0)
73
74type Api = EngineInterface
75type Run = { exitCode: number; stdout: string; stderr: string }
76
77// Module variables: lost on a reload, which is fine (session.start starts them again).
78let timer: { cancel(): void } | undefined
79let polling = false
80const logged = new Set<string>()
81
82/** Runs f; on failure logs once per name and resolves undefined (calls newer than a build). */
83async function attempt<T>($: Api, what: string, f: () => Promise<T>): Promise<T | undefined> {
84 try {
85 return await f()
86 } catch (err) {
87 if (!logged.has(what)) {
88 logged.add(what)
89 try {
90 $.ui.log(`pr-watch: ${what} unavailable: ${err instanceof Error ? err.message : String(err)}`)
91 } catch {
92 // nothing to tell
93 }
94 }
95 return undefined
96 }
97}
98
99async function run($: Api, argv: string[]): Promise<Run> {
100 try {
101 const cwd = await read($, cwdAtom)
102 const r = await $.process.run(argv, cwd ? { cwd, timeoutMs: GH_TIMEOUT_MS } : { timeoutMs: GH_TIMEOUT_MS })
103 return {
104 exitCode: typeof r?.exitCode === 'number' ? r.exitCode : 1,
105 stdout: typeof r?.stdout === 'string' ? r.stdout : '',
106 stderr: typeof r?.stderr === 'string' ? r.stderr : '',
107 }
108 } catch (err) {
109 return { exitCode: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }
110 }
111}
112
113async function now($: Api): Promise<number> {
114 try {
115 const t = await $.clock.now()
116 return typeof t === 'number' ? t : 0
117 } catch {
118 return 0
119 }
120}
121
122async function readJsonFile($: Api, path: string): Promise<unknown> {
123 try {
124 if (!(await $.fs.exists(path))) return null
125 return JSON.parse(String(await $.fs.read(path)))
126 } catch {
127 return null
128 }
129}
130
131/** Defaults, then ~/.claude/pr-watch.json, then <cwd>/.claude/pr-watch.json. */
132async function loadConfig($: Api): Promise<Config> {
133 let home = ''
134 try {
135 home = String((await $.env.get('HOME')) ?? '')
136 } catch {
137 home = ''
138 }
139 const cwd = await read($, cwdAtom)
140 const user = home ? await readJsonFile($, home.replace(/\/+$/, '') + '/' + CONFIG_FILE) : null
141 const project = cwd ? await readJsonFile($, cwd.replace(/\/+$/, '') + '/' + CONFIG_FILE) : null
142 const cfg = mergeConfig(user, project)
143 await update($, configAtom, () => cfg)
144 return cfg
145}
146
147async function loadBand($: Api): Promise<void> {
148 try {
149 const v = await $.store.get(BAND_KEY)
150 await update($, bandAtom, () => v !== false)
151 } catch {
152 // keep what the band had
153 }
154}
155
156/** Adds a PR, or upgrades a user-added one to the branch's. A new branch PR replaces the old one. */
157async function upsert($: Api, ref: PrRef, source: Watched['source']): Promise<boolean> {
158 let added = false
159 await update($, prsAtom, (list: Watched[]) => {
160 let out = source === 'branch' ? list.filter(p => p.source !== 'branch' || p.id === ref.id) : list
161 const at = out.findIndex(p => p.id === ref.id)
162 if (at < 0) {
163 added = true
164 out = [...out, newWatched(ref, source)]
165 } else if (source === 'branch' && out[at].source !== 'branch') {
166 out = out.map((p, i) => (i === at ? { ...p, source: 'branch' as const } : p))
167 }
168 return out
169 })
170 return added
171}
172
173/** Finds the current branch's PR with `gh pr view` and, unless told not to, polls it at once. */
174async function discover($: Api, pollNow = true): Promise<void> {
175 const r = await run($, ['gh', 'pr', 'view', '--json', VIEW_FIELDS])
176 if (r.exitCode !== 0) {
177 // No PR for this branch: stop showing the old branch's. Auth or network trouble: keep it.
178 if (isNoPr(r.stderr)) await update($, prsAtom, (list: Watched[]) => list.filter(p => p.source !== 'branch'))
179 return
180 }
181 const view = parsePrView(r.stdout)
182 const ref = view ? parsePrUrl(view.url) : null
183 if (!ref) return
184 await upsert($, ref, 'branch')
185 if (pollNow) await pollOne($, ref.id)
186}
187
188/** Finds PR <number> in the cwd's repo with `gh pr view <number>`. */
189async function lookupNumber($: Api, n: number): Promise<PrRef | string> {
190 const r = await run($, ['gh', 'pr', 'view', String(n), '--json', VIEW_FIELDS])
191 if (r.exitCode !== 0) return r.stderr.trim() || `gh exited ${r.exitCode}`
192 const view = parsePrView(r.stdout)
193 const ref = view ? parsePrUrl(view.url) : null
194 return ref ?? 'gh returned no PR'
195}
196
197/** Toasts the transitions config asks for and appends one note per newly failed check. */
198async function announce($: Api, w: Watched, ts: Transition[]): Promise<void> {
199 if (!ts.length) return
200 const cfg = await read($, configAtom)
201 const failed = ts.filter((t): t is Extract<Transition, { kind: 'failed' }> => t.kind === 'failed')
202 for (const t of ts) {
203 if (!cfg.notifyOn.includes(t.kind)) continue
204 try {
205 $.ui.toast(toastText(w, t), { timeoutMs: 8000 })
206 } catch {
207 // a toast is best effort
208 }
209 }
210 if (cfg.appendNoteOnFailure) {
211 for (const t of failed) {
212 const text = failureNote(w, t.check)
213 await attempt($, '$.session.append', () => $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } }))
214 }
215 }
216}
217
218/** One GraphQL poll of one watched PR, diffed against the previous one. */
219async function pollOne($: Api, id: string): Promise<void> {
220 const before = (await read($, prsAtom)).find(p => p.id === id)
221 if (!before) return
222 const r = await run($, graphqlArgv(before))
223 const t = await now($)
224 const parsed = r.exitCode === 0 ? parseGraphql(r.stdout) : { error: r.stderr.trim() || `gh exited ${r.exitCode}` }
225 let after: Watched | undefined
226 let ts: Transition[] = []
227 await update($, prsAtom, (list: Watched[]) =>
228 list.map(p => {
229 if (p.id !== id) return p
230 if ('error' in parsed) {
231 after = { ...p, error: parsed.error.slice(0, 200), polledAt: t }
232 } else {
233 ts = diff(p.snap, parsed.snap)
234 after = { ...p, snap: parsed.snap, error: '', polledAt: t }
235 }
236 return after
237 }),
238 )
239 if (after) await announce($, after, ts)
240}
241
242/** Polls every watched PR, one at a time; a second call while one runs returns at once. */
243async function pollAll($: Api): Promise<void> {
244 if (polling) return
245 polling = true
246 try {
247 const ids = (await read($, prsAtom)).map(p => p.id)
248 for (const id of ids) await pollOne($, id)
249 } finally {
250 polling = false
251 const t = await now($)
252 await update($, lastPollAtom, () => t)
253 }
254}
255
256/** The timer's tick: polls when the cadence (hot or normal) says a poll is due. */
257async function tick($: Api): Promise<void> {
258 const prs = await read($, prsAtom)
259 if (!prs.length || polling) return
260 const cfg = await read($, configAtom)
261 const t = await now($)
262 if (isDue(cfg, prs, t, await read($, hotAtom), await read($, lastPollAtom))) await pollAll($)
263}
264
265function startTimer($: Api): void {
266 try {
267 timer?.cancel()
268 } catch {
269 // an old timer from before a reload is already gone
270 }
271 timer = $.clock.every(TICK_MS, () => {
272 void tick($).catch(() => {})
273 })
274}
275
276async function setUp($: Api, cwd: string): Promise<void> {
277 if (cwd) await update($, cwdAtom, () => cwd)
278 await loadConfig($)
279 await loadBand($)
280 const t = await now($)
281 // A fresh session (or one whose lastPoll predates this clock) counts from now.
282 const last = await read($, lastPollAtom)
283 if (!last || last > t) await update($, lastPollAtom, () => t)
284}
285
286function openUrl($: Api, url: string): void {
287 void (async () => {
288 const r = await run($, ['xdg-open', url])
289 if (r.exitCode !== 0) {
290 const mac = await run($, ['open', url])
291 if (mac.exitCode !== 0) $.ui.toast(`pr-watch: could not open ${url}`)
292 }
293 })().catch(() => {})
294}
295
296function copyUrl($: Api, url: string, surface?: 'terminal' | 'desktop' | 'vscode' | 'mobile'): void {
297 void (async () => {
298 // $.ui.copy arrived in 2.1.289: on an older build the call fails and attempt() says so once
299 const res = await attempt($, '$.ui.copy', () => $.ui.copy(surface ? { text: url, surface } : { text: url }))
300 $.ui.toast(res?.isCopied ? `Copied ${url}` : `Could not copy (needs Claude Code 2.1.289+): ${url}`, { timeoutMs: 3000 })
301 })().catch(() => {})
302}
303
304async function removeIds($: Api, ids: string[]): Promise<void> {
305 await update($, prsAtom, (list: Watched[]) => list.filter(p => !ids.includes(p.id)))
306}
307
308/** /pr and its subcommands. */
309async function runCommand($: Api, args: string): Promise<{ text?: string }> {
310 const cmd = parseCommand(args)
311 if (cmd.kind === 'open') {
312 await $.ui.open({ id: PANE, title: 'PR watch', focus: true, closeOnEscape: true })
313 return {}
314 }
315 if (cmd.kind === 'text') return { text: summaryText(await read($, prsAtom), await now($)) }
316 if (cmd.kind === 'refresh') {
317 await discover($, false)
318 await pollAll($)
319 const n = (await read($, prsAtom)).length
320 return { text: n ? `Refreshed ${n} PR${n === 1 ? '' : 's'}.` : 'No PR found for this branch, and none watched.' }
321 }
322 if (cmd.kind === 'add' || cmd.kind === 'add-number') {
323 const ref = cmd.kind === 'add' ? cmd.ref : await lookupNumber($, cmd.number)
324 if (typeof ref === 'string') return { text: `pr-watch: could not find PR #${cmd.kind === 'add-number' ? cmd.number : ''}: ${ref}` }
325 const added = await upsert($, ref, 'user')
326 await pollOne($, ref.id)
327 const w = (await read($, prsAtom)).find(p => p.id === ref.id)
328 return { text: `${added ? 'Watching' : 'Already watching'} ${ref.id}${w ? `: ${bandText(w)}` : ''}` }
329 }
330 if (cmd.kind === 'rm') {
331 const hit = matchTarget(await read($, prsAtom), cmd.target)
332 if (!hit.length) return { text: `pr-watch: no watched PR matches "${cmd.target}".` }
333 await removeIds($, hit.map(p => p.id))
334 return { text: `Stopped watching ${hit.map(p => p.id).join(', ')}.` }
335 }
336 if (cmd.kind === 'band') {
337 await attempt($, '$.store.set', () => $.store.set(BAND_KEY, cmd.on))
338 await update($, bandAtom, () => cmd.on)
339 return { text: cmd.on ? 'PR band shown above the prompt.' : 'PR band hidden.' }
340 }
341 return { text: `${cmd.reason} ${HELP}` }
342}
343
344type SurfaceName = 'terminal' | 'desktop' | 'vscode' | 'mobile'
345type PrState = 'open' | 'draft' | 'merged' | 'closed' | 'failing'
346
347const STATE_TONE: Record<PrState, Tone> = { open: 'info', draft: 'muted', merged: 'ok', closed: 'muted', failing: 'danger' }
348/** Secondary text: the theme's quiet key. */
349const QUIET = TONES.muted.fg
350
351/** open, draft, merged, closed, or failing (open with a failed check). */
352function stateOf(s: Snapshot): PrState {
353 if (s.state === 'MERGED') return 'merged'
354 if (s.state === 'CLOSED') return 'closed'
355 if (countsOf(s).fail > 0) return 'failing'
356 return s.isDraft ? 'draft' : 'open'
357}
358
359/** 'polled just now', 'polled 40s ago', 'polled 5m ago', 'polled 2h ago', 'not polled yet' (clock times). */
360function ageText(polledAt: number, at: number): string {
361 if (!polledAt) return 'not polled yet'
362 const s = Math.max(0, Math.round((at - polledAt) / 1000))
363 if (s < 10) return 'polled just now'
364 if (s < 60) return `polled ${s}s ago`
365 if (s < 3600) return `polled ${Math.floor(s / 60)}m ago`
366 return `polled ${Math.floor(s / 3600)}h ago`
367}
368
369export function register(on: On) {
370 on('session.start', async ($, e, next) => {
371 try {
372 await setUp($, typeof e.cwd === 'string' ? e.cwd : '')
373 } catch {
374 // defaults stay
375 }
376 try {
377 startTimer($)
378 } catch {
379 // no polling, the commands still work
380 }
381 void discover($).catch(() => {})
382 try {
383 await $.command.register({
384 name: 'pr',
385 description: 'PR watch: open the pane, or add, rm, refresh, band on|off, text',
386 argumentHint: '[add <url|number> | rm <number> | refresh | band on|off | text]',
387 immediate: true,
388 })
389 } catch {
390 // the name is taken: the band and the notes still work
391 }
392 return next(e)
393 })
394
395 // /clear, /resume and /branch reset $.state without a session.start
396 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
397 try {
398 await setUp($, typeof e.cwd === 'string' ? e.cwd : '')
399 } catch {
400 // defaults stay
401 }
402 void discover($).catch(() => {})
403 return next(e)
404 })
405
406 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
407 const r = await next(e)
408 try {
409 const command = typeof e.command === 'string' ? e.command : ''
410 if (!('deny' in r && r.deny) && isDiscoveryCommand(command)) {
411 if (isPushCommand(command)) {
412 const t = await now($)
413 await update($, hotAtom, () => t + HOT_MS)
414 }
415 void discover($).catch(() => {})
416 }
417 } catch {
418 // observing only
419 }
420 return r
421 })
422
423 on('prompt.submit', async ($, e, next) => {
424 let refs: PrRef[] = []
425 try {
426 refs = bareUrls(typeof e.text === 'string' ? e.text : '')
427 } catch {
428 refs = []
429 }
430 if (!refs.length) return next(e)
431 const said: string[] = []
432 for (const ref of refs) {
433 let added = false
434 try {
435 added = await upsert($, ref, 'user')
436 } catch {
437 added = false
438 }
439 said.push(`${added ? 'watching' : 'already watching'} ${ref.id}`)
440 void pollOne($, ref.id).catch(() => {})
441 }
442 return { drop: `pr-watch: ${said.join(', ')}` }
443 })
444
445 on('command.run', { command: 'pr' }, async ($, e) => {
446 try {
447 return await runCommand($, typeof e.args === 'string' ? e.args : '')
448 } catch (err) {
449 return { text: 'pr-watch: ' + (err instanceof Error ? err.message : String(err)) }
450 }
451 })
452
453 // The band, one kit line: the PR number in bold, the checks, review and merge chips each in its own
454 // tone, then the rest on the right side: in the default text color when the PR is urgent (a failed
455 // check, changes requested, a conflict), else quiet (kit.line draws its right side quiet).
456 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
457 const band = await read($, bandAtom)
458 const prs = await read($, prsAtom)
459 const parts = band && !(e.props && e.props.hasSurvey) ? bandParts(prs) : null
460 if (!parts) return next(e)
461 const ui = $.ui.resolve(e)
462 const { Box } = ui
463 const kit = makeKit(ui as unknown as Elements, e.surface)
464 const right = parts.right.join(' · ')
465 const drawn = (
466 parts.urgent && right
467 ? Box({
468 flexDirection: 'row',
469 justifyContent: 'space-between',
470 columnGap: 2,
471 children: [kit.line(parts.label, parts.chips) as ReturnType<typeof Box>, kit.text(right, { wrap: 'truncate' }) as ReturnType<typeof Box>],
472 })
473 : kit.line(parts.label, parts.chips, parts.right)
474 ) as ReturnType<typeof Box>
475 const line = Box({ key: 'pr-watch-band', flexDirection: 'column', children: [drawn] })
476 const theirs = await next(e)
477 if (!theirs) return line
478 return Box({ flexDirection: 'column', children: [line, theirs] })
479 })
480
481 // The pane, drawn with the kit: a hero for the branch's PR (else the first), tiles over every
482 // watched PR, then one card per PR (state, checks bar, review, age, checks, buttons). A page:
483 // one row between blocks; the cards are a list, one row apart, their rows tight.
484 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
485 const prs = await read($, prsAtom)
486 const ui = $.ui.resolve(e)
487 const { Link } = ui
488 const kit = makeKit(ui as unknown as Elements, e.surface)
489 const t = Date.now()
490 const clockNow = await now($)
491 const bottom = kit.buttons([
492 { key: 'refresh', label: 'Refresh now', hotkey: 'r', plain: true, onPress: () => void (async () => { await discover($, false); await pollAll($) })().catch(() => {}) },
493 { key: 'close', label: 'Close', hotkey: 'c', plain: true, onPress: () => $.ui.close({ id: PANE }) },
494 ])
495 const top = primaryOf(prs)
496 if (!top) {
497 return kit.page([
498 kit.hero({ kicker: 'pull requests', title: 'No PR watched', sub: 'Push a branch with a PR, paste a PR URL, or run /pr add <url|number>.', tone: 'muted' }),
499 bottom,
500 ]) as never
501 }
502
503 const where = (w: Watched) => `${w.owner}/${w.repo} · ${w.source === 'branch' ? 'this branch' : 'added'}`
504 const hero = kit.hero({
505 kicker: 'pull requests',
506 title: `#${top.number} ${top.snap ? top.snap.title : ''}`.trim(),
507 sub: `${top.snap ? stateOf(top.snap) : top.error ? 'gh failed' : 'loading…'} · ${where(top)}`,
508 tone: top.snap ? levelOf(top.snap) : 'muted',
509 })
510
511 const open = prs.filter(w => w.snap?.state === 'OPEN')
512 const all = prs.map(w => countsOf(w.snap))
513 const total = all.reduce((n, c) => n + c.total, 0)
514 const passing = all.reduce((n, c) => n + c.pass, 0)
515 const failing = all.reduce((n, c) => n + c.fail, 0)
516 const awaiting = open.filter(w => w.snap?.reviewDecision === 'REVIEW_REQUIRED').length
517 const tiles = kit.tiles([
518 { label: 'Open', value: String(open.length), tone: open.length ? 'info' : 'muted' },
519 { label: 'Checks passing', value: `${passing}/${total}`, tone: total && passing === total ? 'ok' : 'muted' },
520 { label: 'Failing', value: String(failing), tone: failing ? 'danger' : 'muted' },
521 { label: 'Awaiting review', value: String(awaiting), tone: awaiting ? 'warn' : 'muted' },
522 ])
523
524 const cards = prs.map(w => {
525 const s = w.snap
526 const children: unknown[] = []
527 const state = s ? stateOf(s) : w.error ? 'gh failed' : 'loading'
528 children.push(
529 kit.row({
530 left: [kit.pill(state, s ? STATE_TONE[state as PrState] : w.error ? 'danger' : 'muted'), kit.text(`#${w.number} ${s ? s.title : ''}`.trim(), { bold: true, wrap: 'truncate-end' })],
531 right: where(w),
532 align: 'center',
533 }),
534 )
535 if (!s) {
536 if (w.error) children.push(kit.text(`gh failed: ${w.error}`, { color: QUIET, wrap: 'wrap' }))
537 } else {
538 const c = countsOf(s)
539 children.push(
540 c.total
541 ? kit.row({
542 left: [kit.bar(c.pass / c.total, { width: 20, tone: c.fail ? 'danger' : c.pending ? 'warn' : 'ok' }), kit.text(`${c.pass}/${c.total} checks`, { bold: true })],
543 right: [c.fail ? `${c.fail} failing` : '', c.pending ? `${c.pending} running` : ''].filter(Boolean).join(' · '),
544 })
545 : kit.text('no checks reported', { color: QUIET }),
546 )
547 children.push(
548 kit.row({
549 left: [kit.pill(reviewText(s.reviewDecision), REVIEW_TONE[s.reviewDecision] ?? 'muted'), kit.text(`${mergeText(s)} · ${s.mergeStateStatus.toLowerCase()}`, { color: QUIET, wrap: 'truncate' })],
550 right: ageText(w.polledAt, clockNow),
551 align: 'center',
552 }),
553 )
554 for (const c of s.checks) {
555 const [icon, tone] = ICON[c.bucket] ?? (['?', 'muted'] as const)
556 const d = durationOf(c, t)
557 children.push(
558 kit.row({
559 key: `check-${w.number}-${c.key}`,
560 left: [
561 kit.text(icon, { color: TONES[tone].fg }),
562 linkable(c.link) ? Link({ href: c.link, label: c.name || '(unnamed)' }) : kit.text(c.name || '(unnamed)', { wrap: 'truncate-end' }),
563 ],
564 right: `${c.bucket}${d ? ` · ${d}` : ''}`,
565 }),
566 )
567 }
568 if (w.error) children.push(kit.text(`refresh failed: ${w.error}`, { color: TONES.danger.fg, wrap: 'wrap' }))
569 }
570 children.push(linkable(w.url) ? Link({ href: w.url, label: w.url }) : kit.text(w.url, { color: QUIET }))
571 children.push(
572 kit.buttons([
573 { key: `open-${w.number}`, label: 'Open in browser', onPress: () => openUrl($, w.url) },
574 { key: `copy-${w.number}`, label: 'Copy URL', onPress: (press?: unknown) => copyUrl($, w.url, (press as { surface?: SurfaceName } | undefined)?.surface) },
575 { key: `rm-${w.number}`, label: 'Stop watching', onPress: () => removeIds($, [w.id]) },
576 ]),
577 )
578 return kit.card({ children })
579 })
580
581 // the Watched header sits tight above its cards; the cards keep one row between them
582 return kit.page([hero, tiles, kit.stack([kit.section(`Watched · ${prs.length}`), kit.list(cards)]), bottom]) as never
583 })
584}
585hooks/kit.ts 955 lines1// kit.ts: the shared UI kit (kit v3).
2//
3// The module has no imports and touches no mods API: every helper takes the elements a hook got
4// from `$.ui.resolve(e)` and the surface (`e.surface`) and returns an element tree.
5//
6// const k = makeKit($.ui.resolve(e) as unknown as Elements, e.surface)
7// return k.stack([k.hero({ kicker: 'right now', title: 'Reading client.test.js' }), k.tiles([...])], 1)
8//
9// What v2 changed on purpose (v1 painted grey fills and dimmed grey text, unreadable on the desktop):
10// - colors are theme keys (suggestion, success, warning, error, claude, inactive) that the host
11// tunes for light and dark; PALETTE below swaps them for named colors in one line;
12// - no dimColor, no backgroundColor, no yellow, white, gray, black or subtle on content: secondary
13// text is `inactive`, state is a bold `● word` chip (glyph and word, so color is never the only cue);
14// - hero and card are one round frame (paddingX 1), tiles are single-border boxes with the label
15// over a bold value; terminal and desktop draw the same structure (the Svg doodle is desktop-only);
16// - kicker, section and tile labels keep the caller's case (v1 upper-cased kicker and section);
17// - additive: line(label, chips, right?) draws one band line.
18//
19// Calibration (claude plugin test, Claude Code 2.1.294): the test harness validates the tree a
20// ui.render hook returns (a Text with a bogus prop makes `$.ui.mount` reject on both surfaces), but
21// not how the desktop paints a theme key. A Button given `plain: false` throws when built (plain and
22// autoFocus take only `true`), so the kit leaves false and undefined props out; a Box inside a Text
23// is refused. doodle() draws its Svg on the desktop only (a pane holding only an Svg opens empty in
24// the terminal).
25//
26// What v3 adds (strictly additive: every v2 export above renders as before). The desktop paints
27// Svg, and its light theme made the v1 fills unreadable, so v3 draws small pictures as Svg wherever
28// the element table has one (desktop, mobile, vscode) and as text on the terminal:
29// - pill(text, tone?): a fully rounded Svg chip (PILL 'svg', the default); PILL 'fill' draws a
30// theme-key backgroundColor under `inverseText` instead (the only fill in the kit); the v2
31// `● text` chip on the terminal;
32// - header(label, right?): an upper-cased `inactive` section label with a right-hand count;
33// - icon(name, tone?), typeBadge(fileName) and progress(ratio, opts?): Svg icons, file-type badges
34// and a rounded bar; a glyph, a bold label and the v2 bar on the terminal;
35// - buttons() items take an optional `variant` ('primary' | 'secondary');
36// - nav(items, currentKey): a pane's nav bar, the current item bold Text, the others plain Buttons;
37// - pure helpers: pillSvg, shortPath and extOf; TONE_HEX, the Svg colors of icons and bars, and
38// CHIP_HEX, the darker pill fills that keep white text at 4.5:1 or more.
39// Svg markup cannot read theme keys, so it uses fixed mid-tone hexes that read on light and dark
40// grounds (never a color word), and each distinct drawing is built once and cached.
41//
42// What v3.1 adds (spacing; additive): on the desktop, blocks stacked with no gap touch border to
43// border and wrapped rows sit line on line. So every wrapping row (buttons, tiles, nav) gets rowGap 1,
44// buttons get columnGap 2, and two new columns space a pane out:
45// - page(children): a pane's top-level column, one row between blocks;
46// - list(children): a column of repeated cards or blocks, one row between them.
47// Rows inside one card or block stay tight (stack with no gap); stack() keeps its props. Bands that
48// draw buttons() are one row, so the new gaps change nothing there. buttons() items also take
49// `autoFocus` (passed only when true).
50
51export const KIT_VERSION = '3.1.0'
52
53/** How pill() draws on a surface with Svg: 'svg' (a fully rounded chip) or 'fill' (a theme-key
54 * background). makeKit's `pill` option overrides it for one kit. */
55export const PILL: 'fill' | 'svg' = 'svg'
56
57/** kit v3: makeKit's options. `pill` overrides PILL for this kit. */
58export type KitOptions = { pill?: 'fill' | 'svg' }
59
60/** 'theme': theme keys that follow the person's light or dark theme. 'named': named colors, for a
61 * host that does not paint theme keys (never yellow: warn is red plus its glyph). One-line switch. */
62export const PALETTE: 'theme' | 'named' = 'theme'
63
64export type Surface = 'terminal' | 'desktop' | (string & {})
65export type Tone = 'info' | 'ok' | 'warn' | 'danger' | 'muted' | 'accent'
66export type Role = 'main' | 'recon' | 'execute' | 'verify' | 'hard' | 'reviewer' | 'other'
67export type DoodleState = 'running' | 'done' | 'failed' | 'waiting' | 'idle'
68/** The tones of pill() and icon(): the six tones plus violet. */
69export type PillTone = Tone | 'violet'
70export type IconName =
71 | 'eye'
72 | 'pencil'
73 | 'plus'
74 | 'branch'
75 | 'search'
76 | 'folder'
77 | 'folder-open'
78 | 'file'
79 | 'shield'
80 | 'flag'
81 | 'check'
82 | 'dot'
83 | 'clock'
84/** What `$.ui.resolve(e)` returns, loosely typed so the kit compiles without the engine's types. */
85export type Elements = Record<string, (props: Record<string, unknown>) => unknown>
86
87export type Kit = {
88 hero(p: { kicker?: string; title: string; sub?: string; icon?: string; tone?: Tone }): unknown
89 card(p: { title?: string; right?: string; tone?: Tone; children: unknown[] }): unknown
90 section(label: string, right?: string): unknown
91 tiles(items: { label: string; value: string; tone?: Tone }[]): unknown
92 badge(text: string, tone?: Tone): unknown
93 legend(items: { tone: Tone; label: string }[]): unknown
94 bar(ratio: number, opts?: { width?: number; tone?: Tone }): unknown
95 /** One row, left side truncating, right side muted. `align: 'center'` (kit v3) centers a row holding a pill. */
96 row(p: { left: unknown[] | string; right?: unknown[] | string; key?: string; align?: 'center' }): unknown
97 kv(label: string, value: string, tone?: Tone): unknown
98 buttons(
99 items: {
100 key: string
101 label: string
102 hotkey?: string
103 onPress: (e?: unknown) => unknown
104 plain?: boolean
105 /** kit v3: the Button's variant, passed only when set. */
106 variant?: 'primary' | 'secondary'
107 /** kit 3.1: focus this Button when the pane opens; passed only when true. */
108 autoFocus?: boolean
109 }[],
110 ): unknown
111 doodle(role: Role, state?: DoodleState): unknown
112 stack(children: unknown[], gap?: number): unknown
113 /** kit 3.1: a pane's top-level column, one row between its blocks; null children are dropped. */
114 page(children: unknown[]): unknown
115 /** kit 3.1: a column of repeated cards or blocks, one row between them; null children are dropped. */
116 list(children: unknown[]): unknown
117 text(s: string, props?: Record<string, unknown>): unknown
118 /**
119 * One band line: `label ● a ● b right · text`. A chip is `{ text, tone? }`, a string (a muted
120 * chip) or an element such as badge(); at most four are drawn. `right` is a string or a list
121 * joined with ` · `, drawn `inactive` and truncated. One row high.
122 */
123 line(label: string, chips: unknown[], right?: string | string[]): unknown
124 /** kit v3: a rounded chip (Svg surfaces) or the v2 `● text` chip (terminal). */
125 pill(text: string, tone?: Tone | 'violet'): unknown
126 /** kit v3: one row, the label upper-cased, bold and `inactive`; `right` upper-cased, right-aligned. */
127 header(label: string, right?: string): unknown
128 /** kit v3: a 16×16 stroke icon (Svg surfaces) or one glyph in the tone (terminal). */
129 icon(name: IconName, tone?: Tone | 'violet'): unknown
130 /** kit v3: a file-type badge ('JS', 'PDF', …) for a file name; null when the name has no extension. */
131 typeBadge(fileName: string): unknown
132 /** kit v3: a rounded bar (Svg surfaces; `width` in CSS pixels, absent: the slot's width) or bar(). */
133 progress(ratio: number, opts?: { width?: number; tone?: Tone }): unknown
134 /**
135 * kit v3: a pane's nav bar, one row keyed 'nav': the item whose key is `currentKey` as bold Text in the accent
136 * tone (not pressable), the others plain Buttons with their hotkeys (`1: Stage`); one without onPress quiet Text.
137 */
138 nav(items: { key: string; label: string; hotkey?: string; onPress?: () => unknown }[], currentKey: string): unknown
139 surface: Surface
140}
141
142/** The quiet color: secondary text, labels, units, timestamps, frame and tile borders. */
143const QUIET = 'inactive'
144
145const THEME_TONES: Record<Tone, string> = {
146 info: 'suggestion',
147 ok: 'success',
148 warn: 'warning',
149 danger: 'error',
150 muted: QUIET,
151 accent: 'claude',
152}
153
154const NAMED_TONES: Record<Tone, string> = {
155 info: 'blue',
156 ok: 'green',
157 warn: 'red',
158 danger: 'red',
159 muted: QUIET,
160 accent: 'magenta',
161}
162
163const TONE_COLORS = PALETTE === 'named' ? NAMED_TONES : THEME_TONES
164
165/** fg: the color of the tone, a theme key (a named color when PALETTE is 'named'). bg equals fg: v2
166 * draws no fills; the field stays so v1 callers compile. */
167export const TONES: Record<Tone, { fg: string; bg: string }> = {
168 info: { fg: TONE_COLORS.info, bg: TONE_COLORS.info },
169 ok: { fg: TONE_COLORS.ok, bg: TONE_COLORS.ok },
170 warn: { fg: TONE_COLORS.warn, bg: TONE_COLORS.warn },
171 danger: { fg: TONE_COLORS.danger, bg: TONE_COLORS.danger },
172 muted: { fg: TONE_COLORS.muted, bg: TONE_COLORS.muted },
173 accent: { fg: TONE_COLORS.accent, bg: TONE_COLORS.accent },
174}
175
176/**
177 * kit v3: the Svg colors of icons and bars, a mid-tone hex per tone that reads on light and dark grounds
178 * (Svg markup cannot read theme keys). warn and accent share the Claude orange: the theme's warning
179 * color is unreadable as a fill. A pill fills with CHIP_HEX instead.
180 *
181 * info #3B82F6 ok #22A35A warn #D97757 danger #E5484D accent #D97757 muted #8A8F98
182 * violet #8B5CF6
183 */
184export const TONE_HEX: Record<PillTone, string> = {
185 info: '#3B82F6',
186 ok: '#22A35A',
187 warn: '#D97757',
188 danger: '#E5484D',
189 accent: '#D97757',
190 muted: '#8A8F98',
191 violet: '#8B5CF6',
192}
193
194/**
195 * kit v3: a pill's fill per tone, darker than TONE_HEX so its white text has a contrast ratio of at
196 * least 4.5:1 (WCAG AA for small text). The ratios against white:
197 *
198 * info #2563EB 5.17 ok #15803D 5.02 warn #C2410C 5.18 danger #B91C1C 6.47 accent #C2410C 5.18
199 * muted #5B6170 6.20 violet #7C3AED 5.70
200 */
201export const CHIP_HEX: Record<PillTone, string> = {
202 info: '#2563EB',
203 ok: '#15803D',
204 warn: '#C2410C',
205 danger: '#B91C1C',
206 accent: '#C2410C',
207 muted: '#5B6170',
208 violet: '#7C3AED',
209}
210
211/** kit v3: the theme key a pill fills with, per tone (warn is 'claude': a 'warning' fill is unreadable). */
212const PILL_FILLS: Record<PillTone, string> = {
213 info: 'suggestion',
214 ok: 'success',
215 warn: 'claude',
216 danger: 'error',
217 accent: 'claude',
218 muted: QUIET,
219 violet: 'permission',
220}
221
222/** kit v3: the stroke of an icon drawn without a tone. */
223const ICON_STROKE = '#6B7280'
224/** kit v3: the track of progress(): the muted hex at quarter opacity. */
225const TRACK_HEX = '#8A8F98'
226/** kit v3: the text on a light fill (the amber JS badge); light text everywhere else. */
227const INK_DARK = '#1A1A1A'
228const INK_LIGHT = '#FFFFFF'
229
230/** kit v3: icon markup on a 16×16 grid, stroked with `currentColor`'s stand-in `S`. */
231const ICONS: Record<IconName, string> = {
232 eye: '<path d="M1.5 8S4 3.5 8 3.5 14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8z"/><circle cx="8" cy="8" r="2"/>',
233 pencil: '<path d="M11 2.5 13.5 5 5.5 13H3v-2.5z"/><path d="M9.5 4 12 6.5"/>',
234 plus: '<path d="M8 3v10M3 8h10"/>',
235 branch:
236 '<circle cx="4.5" cy="3.5" r="1.5"/><circle cx="4.5" cy="12.5" r="1.5"/><circle cx="11.5" cy="5" r="1.5"/>' +
237 '<path d="M4.5 5v6M11.5 6.5c0 2.5-2 3.5-4 3.8-1.6.2-3 .7-3 .7"/>',
238 search: '<circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5 14 14"/>',
239 folder: '<path d="M1.75 3.25h4.5l1.5 1.75h6.5v7.75H1.75z"/>',
240 'folder-open': '<path d="M1.75 12.75v-9.5h4.5l1.5 1.75h5v2"/><path d="M1.75 12.75 3.75 7h10.5l-2 5.75z"/>',
241 file: '<path d="M9 1.75H3v12.5h10v-8.5z"/><path d="M9 1.75v4h4"/>',
242 shield: '<path d="M8 1.75 13 3.75v4c0 3.2-2.2 5.4-5 6.5-2.8-1.1-5-3.3-5-6.5v-4z"/>',
243 flag: '<path d="M3 14.5V2"/><path d="M3 2.5h8.5L9.75 5.5l1.75 3H3"/>',
244 check: '<path d="M3 8.5 6 11.5 13 4.5"/>',
245 dot: '<circle cx="8" cy="8" r="3" fill="S"/>',
246 clock: '<circle cx="8" cy="8" r="6"/><path d="M8 4.5V8l2.5 1.5"/>',
247}
248
249/** kit v3: the terminal glyph of each icon. */
250const ICON_GLYPHS: Record<IconName, string> = {
251 eye: '◉',
252 pencil: '✎',
253 plus: '+',
254 branch: '⎇',
255 search: '⌕',
256 folder: '▸',
257 'folder-open': '▾',
258 file: '·',
259 shield: '⛨',
260 flag: '⚑',
261 check: '✓',
262 dot: '●',
263 clock: '◷',
264}
265
266/** kit v3: file-type badges by extension: the label, its Svg fill and its terminal tone. */
267const FILE_TYPES: { exts: string[]; label: string; fill: string; tone: Tone }[] = [
268 { exts: ['js', 'jsx', 'mjs', 'cjs'], label: 'JS', fill: '#F0C419', tone: 'accent' },
269 { exts: ['ts', 'tsx'], label: 'TS', fill: '#3178C6', tone: 'info' },
270 { exts: ['pdf'], label: 'PDF', fill: '#E5484D', tone: 'danger' },
271 { exts: ['md', 'mdx'], label: 'MD', fill: '#4A7BD0', tone: 'info' },
272 { exts: ['doc', 'docx'], label: 'DOC', fill: '#2B579A', tone: 'info' },
273 { exts: ['csv'], label: 'CSV', fill: '#22A35A', tone: 'ok' },
274 { exts: ['json'], label: 'JSON', fill: '#64748B', tone: 'muted' },
275 { exts: ['yml', 'yaml'], label: 'YML', fill: '#64748B', tone: 'muted' },
276 { exts: ['html'], label: 'HTML', fill: '#E8743B', tone: 'accent' },
277 { exts: ['css'], label: 'CSS', fill: '#4A7BD0', tone: 'info' },
278 { exts: ['py'], label: 'PY', fill: '#3776AB', tone: 'info' },
279 { exts: ['sh'], label: 'SH', fill: '#64748B', tone: 'muted' },
280 { exts: ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'], label: 'IMG', fill: '#8B5CF6', tone: 'accent' },
281 { exts: ['txt'], label: 'TXT', fill: '#64748B', tone: 'muted' },
282]
283const OTHER_FILL = '#64748B'
284
285const GLYPHS: Record<Role, string> = {
286 main: '◉',
287 recon: '◈',
288 execute: '▣',
289 verify: '✓',
290 hard: '◆',
291 reviewer: '◎',
292 other: '●',
293}
294
295const STATE_TONES: Record<DoodleState, Tone> = {
296 running: 'info',
297 done: 'ok',
298 failed: 'danger',
299 waiting: 'warn',
300 idle: 'muted',
301}
302
303const STATE_COLORS: Record<DoodleState, string> = {
304 running: TONES[STATE_TONES.running].fg,
305 done: TONES[STATE_TONES.done].fg,
306 failed: TONES[STATE_TONES.failed].fg,
307 waiting: TONES[STATE_TONES.waiting].fg,
308 idle: TONES[STATE_TONES.idle].fg,
309}
310
311// Desktop doodles (svg fills are not text colors, so they stay hex): 12×12 cells, 3 px each. B = body (by role), A = accent (by state), . = empty.
312const BODY_FILL: Record<Role, string> = {
313 main: '#6c63ff',
314 recon: '#1f9eaa',
315 execute: '#3b7ddd',
316 verify: '#2f9e55',
317 hard: '#b5523b',
318 reviewer: '#c08a1e',
319 other: '#7d8590',
320}
321
322const ACCENT_FILL: Record<DoodleState, string> = {
323 running: '#22d3ee',
324 done: '#4ade80',
325 failed: '#f87171',
326 waiting: '#facc15',
327 idle: '#e5e7eb',
328}
329
330const SPRITES: Record<Role, string[]> = {
331 // a crowned blob
332 main: [
333 '............',
334 '..A..AA..A..',
335 '..AAAAAAAA..',
336 '..BBBBBBBB..',
337 '.BBBBBBBBBB.',
338 '.BBAABBAABB.',
339 '.BBAABBAABB.',
340 '.BBBBBBBBBB.',
341 '.BBBAAAABBB.',
342 '.BBBBBBBBBB.',
343 '..BB....BB..',
344 '..BB....BB..',
345 ],
346 // a one-eyed scout drone
347 recon: [
348 '....BBBB....',
349 '..BBBBBBBB..',
350 '.BBBBBBBBBB.',
351 'BBBBAAAABBBB',
352 'BBBAAAAAABBB',
353 'BBBAA..AABBB',
354 'BBBAAAAAABBB',
355 'BBBBAAAABBBB',
356 '.BBBBBBBBBB.',
357 '..BBBBBBBB..',
358 '...B.BB.B...',
359 '..B..BB..B..',
360 ],
361 // a boxy robot with an antenna
362 execute: [
363 '.....AA.....',
364 '.....BB.....',
365 '.BBBBBBBBBB.',
366 '.BBBBBBBBBB.',
367 '.BBAABBAABB.',
368 '.BBAABBAABB.',
369 '.BBBBBBBBBB.',
370 '.BBAAAAAABB.',
371 '.BBBBBBBBBB.',
372 '...BBBBBB...',
373 '..BBB..BBB..',
374 '..BB....BB..',
375 ],
376 // a shield with a check mark
377 verify: [
378 '.BBBBBBBBBB.',
379 'BBBBBBBBBBBB',
380 'BBBBBBBBBBBB',
381 'BBBBBBBBBAAB',
382 'BBBBBBBBAABB',
383 'BAABBBBAABBB',
384 'BBAABBAABBBB',
385 '.BBAAAABBBB.',
386 '.BBBAABBBBB.',
387 '..BBBBBBBB..',
388 '...BBBBBB...',
389 '.....BB.....',
390 ],
391 // a gem golem
392 hard: [
393 '.....BB.....',
394 '....BBBB....',
395 '...BBBBBB...',
396 '..BBBBBBBB..',
397 '.BBAABBAABB.',
398 'BBBAABBAABBB',
399 'BBBBBBBBBBBB',
400 '.BBBBAABBBB.',
401 '..BBBBBBBB..',
402 '...BBBBBB...',
403 '....BBBB....',
404 '.....BB.....',
405 ],
406 // a round face with glasses
407 reviewer: [
408 '...BBBBBB...',
409 '..BBBBBBBB..',
410 '.BBBBBBBBBB.',
411 'BBBBBBBBBBBB',
412 'BAAAABBAAAAB',
413 'BA..AAAA..AB',
414 'BAAAABBAAAAB',
415 'BBBBBBBBBBBB',
416 'BBBBAAAABBBB',
417 '.BBBBBBBBBB.',
418 '..BBBBBBBB..',
419 '...BBBBBB...',
420 ],
421 // a ghost
422 other: [
423 '....BBBB....',
424 '..BBBBBBBB..',
425 '.BBBBBBBBBB.',
426 '.BBBBBBBBBB.',
427 'BBBAABBAABBB',
428 'BBBAABBAABBB',
429 'BBBBBBBBBBBB',
430 'BBBBBBBBBBBB',
431 'BBBBBBBBBBBB',
432 'BBBBBBBBBBBB',
433 'BBB.BBBB.BBB',
434 'B....BB....B',
435 ],
436}
437
438const CELL = 3
439const GRID = 12
440
441// ---------------------------------------------------------------- formatters
442
443/** '≈$0.08', '≈$1.2', '≈$12'; '$0' for zero, '<$0.01' below a cent, '—' when unknown. */
444export function money(usd: number | null | undefined): string {
445 if (typeof usd !== 'number' || !Number.isFinite(usd)) return '—'
446 const v = Math.max(0, usd)
447 if (v === 0) return '$0'
448 if (v < 0.005) return '<$0.01'
449 const cents = Math.round(v * 100) / 100
450 if (cents < 1) return '≈$' + cents.toFixed(2)
451 const dimes = Math.round(v * 10) / 10
452 if (dimes < 10) return '≈$' + dimes.toFixed(1)
453 return '≈$' + String(Math.round(v))
454}
455
456/** '0', '950', '1.5k', '37k', '1.2M', '15M'. */
457export function tokensShort(n: number): string {
458 if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0) return '0'
459 if (n < 1000) return String(Math.round(n))
460 const k = n / 1000
461 if (k < 10) {
462 const r = Math.round(k * 10) / 10
463 if (r < 10) return trimZero(r.toFixed(1)) + 'k'
464 }
465 if (Math.round(k) < 1000) return String(Math.round(k)) + 'k'
466 const m = n / 1_000_000
467 const r = Math.round(m * 10) / 10
468 if (r < 10) return trimZero(r.toFixed(1)) + 'M'
469 return String(Math.round(m)) + 'M'
470}
471
472/** '0:40', '10:05', '1:02:03'. */
473export function clock(ms: number): string {
474 const total = typeof ms === 'number' && Number.isFinite(ms) && ms > 0 ? Math.floor(ms / 1000) : 0
475 const h = Math.floor(total / 3600)
476 const m = Math.floor((total % 3600) / 60)
477 const s = total % 60
478 if (h > 0) return `${h}:${pad2(m)}:${pad2(s)}`
479 return `${m}:${pad2(s)}`
480}
481
482/** '4%', '100%'. Not capped at 100 (a budget can run over); negative and NaN read 0%. */
483export function pct(ratio: number): string {
484 if (typeof ratio !== 'number' || !Number.isFinite(ratio) || ratio <= 0) return '0%'
485 return String(Math.round(ratio * 100)) + '%'
486}
487
488/** '━━━━╌╌╌╌╌╌' for 0.4 at width 10; the ratio is clamped to 0..1. */
489export function barText(ratio: number, width: number = 10): string {
490 const w = typeof width === 'number' && Number.isFinite(width) && width >= 1 ? Math.floor(width) : 10
491 const filled = Math.round(clamp01(ratio) * w)
492 return '━'.repeat(filled) + '╌'.repeat(w - filled)
493}
494
495function clamp01(ratio: number): number {
496 if (typeof ratio !== 'number' || !Number.isFinite(ratio)) return 0
497 return Math.min(1, Math.max(0, ratio))
498}
499
500function pad2(n: number): string {
501 return n < 10 ? '0' + n : String(n)
502}
503
504function trimZero(s: string): string {
505 return s.endsWith('.0') ? s.slice(0, -2) : s
506}
507
508function toneOf(tone: unknown, fallback: Tone): Tone {
509 return typeof tone === 'string' && Object.prototype.hasOwnProperty.call(TONES, tone) ? (tone as Tone) : fallback
510}
511
512function roleOf(role: unknown): Role {
513 return typeof role === 'string' && Object.prototype.hasOwnProperty.call(GLYPHS, role) ? (role as Role) : 'other'
514}
515
516function stateOf(state: unknown): DoodleState {
517 return typeof state === 'string' && Object.prototype.hasOwnProperty.call(STATE_COLORS, state)
518 ? (state as DoodleState)
519 : 'idle'
520}
521
522/** Drops undefined and false props: some props (Button plain, autoFocus) take only `true`. */
523function clean(props: Record<string, unknown>): Record<string, unknown> {
524 const out: Record<string, unknown> = {}
525 for (const k of Object.keys(props)) {
526 const v = props[k]
527 if (v === undefined || v === false) continue
528 out[k] = v
529 }
530 return out
531}
532
533function present(children: unknown[]): unknown[] {
534 return children.filter(c => c !== null && c !== undefined && c !== false && c !== '')
535}
536
537/** The SVG document of one doodle: a 12×12 sprite at 3 px per cell, runs merged per row. */
538export function doodleSvg(role: Role, state: DoodleState = 'idle'): string {
539 const r = roleOf(role)
540 const s = stateOf(state)
541 const fills: Record<string, string> = { B: BODY_FILL[r], A: ACCENT_FILL[s] }
542 const size = GRID * CELL
543 let rects = ''
544 SPRITES[r].forEach((line, y) => {
545 let x = 0
546 while (x < GRID) {
547 const ch = line[x] ?? '.'
548 let end = x + 1
549 while (end < GRID && line[end] === ch) end++
550 const fill = fills[ch]
551 if (fill) {
552 rects += `<rect x="${x * CELL}" y="${y * CELL}" width="${(end - x) * CELL}" height="${CELL}" fill="${fill}"/>`
553 }
554 x = end
555 }
556 })
557 return (
558 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}" ` +
559 `shape-rendering="crispEdges">${rects}</svg>`
560 )
561}
562
563/** The terminal glyph of a role, as doodle() draws it and as the desktop doodle's alt text. */
564export function glyph(role: Role): string {
565 return GLYPHS[roleOf(role)]
566}
567
568// ---------------------------------------------------------------- kit v3: pure helpers and Svg
569
570/** 'ts' for 'src/a.test.TS', '' for 'Makefile', '.gitignore' or 'notes.'. Lower case, no dot. */
571export function extOf(name: string): string {
572 const base = String(name ?? '').split(/[\\/]/).pop() ?? ''
573 const dot = base.lastIndexOf('.')
574 return dot > 0 && dot < base.length - 1 ? base.slice(dot + 1).toLowerCase() : ''
575}
576
577/**
578 * A path for display: inside `root` it is relative ('.' for the root itself), inside `home` it is
579 * '~/' and the rest, anywhere else '…/' and its last two segments, so no absolute path from outside
580 * the project is drawn. A relative path is already short and comes back as given. Trailing slashes
581 * are dropped.
582 */
583export function shortPath(path: string, root: string, home: string): string {
584 const p = trimSlash(path)
585 const r = trimSlash(root)
586 const h = trimSlash(home)
587 if (!p.startsWith('/')) return p
588 if (r && p === r) return '.'
589 if (r && r !== '/' && p.startsWith(r + '/')) return p.slice(r.length + 1)
590 if (h && p === h) return '~'
591 if (h && h !== '/' && p.startsWith(h + '/')) return '~/' + p.slice(h.length + 1)
592 return '…/' + p.split('/').filter(Boolean).slice(-2).join('/')
593}
594
595function trimSlash(s: string): string {
596 const t = String(s ?? '')
597 return t.length > 1 ? t.replace(/\/+$/, '') || '/' : t
598}
599
600const SVG_CACHE = new Map<string, string>()
601const SVG_CACHE_MAX = 512
602
603/** Builds each distinct drawing once; the cache is cleared when it grows past its bound. */
604function cached(key: string, build: () => string): string {
605 const hit = SVG_CACHE.get(key)
606 if (hit !== undefined) return hit
607 if (SVG_CACHE.size >= SVG_CACHE_MAX) SVG_CACHE.clear()
608 const svg = build()
609 SVG_CACHE.set(key, svg)
610 return svg
611}
612
613function xml(s: string): string {
614 return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
615}
616
617/** Dark text on a light fill (relative luminance above one half), light text otherwise. */
618function inkOn(hex: string): string {
619 const n = parseInt(hex.slice(1), 16)
620 const lin = (c: number) => {
621 const v = c / 255
622 return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4)
623 }
624 const l = 0.2126 * lin((n >> 16) & 255) + 0.7152 * lin((n >> 8) & 255) + 0.0722 * lin(n & 255)
625 return l > 0.5 ? INK_DARK : INK_LIGHT
626}
627
628function pillToneOf(tone: unknown, fallback: PillTone): PillTone {
629 return typeof tone === 'string' && Object.prototype.hasOwnProperty.call(TONE_HEX, tone) ? (tone as PillTone) : fallback
630}
631
632function iconOf(name: unknown): IconName {
633 return typeof name === 'string' && Object.prototype.hasOwnProperty.call(ICONS, name) ? (name as IconName) : 'dot'
634}
635
636const FONT = 'font-family="system-ui,-apple-system,Segoe UI,sans-serif"'
637
638/** A label in a rounded rect: the shape of a pill (height 18) and of a type badge (height 16). */
639function labelSvg(text: string, fill: string, width: number, height: number, rx: number, size: number, ink = inkOn(fill)): string {
640 return (
641 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">` +
642 `<rect width="${width}" height="${height}" rx="${rx}" fill="${fill}"/>` +
643 `<text x="${width / 2}" y="${height / 2}" text-anchor="middle" dominant-baseline="central" ${FONT} ` +
644 `font-size="${size}" font-weight="700" fill="${ink}">${xml(text)}</text></svg>`
645 )
646}
647
648/** A pill's height in CSS pixels; its corners are fully rounded (rx = height / 2). */
649const PILL_HEIGHT = 18
650
651/** A pill's width in CSS pixels: about 6.5 per character of 11 px bold text plus 7 each side. */
652function pillWidth(text: string): number {
653 return Math.max(PILL_HEIGHT, Math.round(text.length * 6.5 + 14))
654}
655
656/** The Svg of a pill: a fully rounded rect in the tone's CHIP_HEX fill, white text. */
657export function pillSvg(text: string, tone: Tone | 'violet' = 'muted'): string {
658 const t = pillToneOf(tone, 'muted')
659 const s = String(text)
660 return cached(`pill|${t}|${s}`, () => labelSvg(s, CHIP_HEX[t], pillWidth(s), PILL_HEIGHT, PILL_HEIGHT / 2, 11, INK_LIGHT))
661}
662
663function iconSvg(name: IconName, hex: string): string {
664 return cached(`icon|${name}|${hex}`, () =>
665 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="none" ` +
666 `stroke="${hex}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">` +
667 `${ICONS[name].replace(/"S"/g, `"${hex}"`)}</svg>`,
668 )
669}
670
671function fileType(fileName: string): { label: string; fill: string; tone: Tone } | null {
672 const ext = extOf(fileName)
673 if (!ext) return null
674 const known = FILE_TYPES.find(t => t.exts.includes(ext))
675 return known ?? { label: ext.slice(0, 3).toUpperCase(), fill: OTHER_FILL, tone: 'muted' }
676}
677
678/** A type badge is 16 high and about 7 px per letter plus 8 wide. */
679function badgeWidth(label: string): number {
680 return Math.max(16, label.length * 7 + 8)
681}
682
683function badgeSvg(label: string, fill: string): string {
684 return cached(`badge|${label}|${fill}`, () => labelSvg(label, fill, badgeWidth(label), 16, 3, 10))
685}
686
687/** A track and a fill, 6 high. Without a width the markup is 100% wide and stretches to the slot. */
688function progressSvg(ratio: number, hex: string, width: number | undefined): string {
689 const full = width ?? 100
690 const fill = Math.round(clamp01(ratio) * full * 10) / 10
691 return cached(`progress|${fill}|${hex}|${width ?? ''}`, () => {
692 const size = width === undefined ? 'width="100%" height="6" preserveAspectRatio="none"' : `width="${width}" height="6"`
693 return (
694 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${full} 6" ${size}>` +
695 `<rect width="${full}" height="6" rx="3" fill="${TRACK_HEX}" opacity="0.25"/>` +
696 (fill > 0 ? `<rect width="${fill}" height="6" rx="3" fill="${hex}"/>` : '') +
697 `</svg>`
698 )
699 })
700}
701
702// ---------------------------------------------------------------- the kit
703
704const MAX_CHIPS = 4
705
706export function makeKit(ui: Elements, surface: Surface, opts: KitOptions = {}): Kit {
707 const desktop = surface === 'desktop'
708 const pillMode = opts.pill ?? PILL
709
710 const el = (type: string, props: Record<string, unknown>, children?: unknown[]): unknown => {
711 const make = ui[type]
712 const p = clean(props)
713 if (children !== undefined) p.children = present(children)
714 return make(p)
715 }
716 const T = (s: string, props: Record<string, unknown> = {}): unknown => el('Text', props, [s])
717 const color = (tone: Tone | undefined): string | undefined => (tone ? TONES[tone].fg : undefined)
718 const pieces = (part: unknown[] | string, right: boolean): unknown[] =>
719 (typeof part === 'string' ? [part] : part).map(c =>
720 typeof c === 'string' || typeof c === 'number'
721 ? T(String(c), { color: right ? QUIET : undefined, wrap: 'truncate' })
722 : c,
723 )
724 const frame = (children: unknown[], tone: Tone | undefined): unknown =>
725 el('Box', { flexDirection: 'column', borderStyle: 'round', borderColor: color(tone) ?? QUIET, paddingX: 1 }, children)
726 const chip = (text: string, tone: Tone): unknown =>
727 T('● ' + text, { color: TONES[tone].fg, bold: tone !== 'muted', wrap: 'truncate' })
728 // kit v3: Svg wherever the table has it (desktop, mobile, vscode); the terminal draws text.
729 const svgOk = surface !== 'terminal' && typeof ui.Svg === 'function'
730 const svg = (source: string, alt: string, width?: number, height?: number): unknown =>
731 el('Svg', { source, alt: alt || ' ', width, height })
732 /** The theme key of a v3 tone on the terminal: the v2 tone's color, violet 'permission'. */
733 const fg = (tone: PillTone): string => (tone === 'violet' ? PILL_FILLS.violet : TONES[tone].fg)
734
735 const kit: Kit = {
736 surface,
737
738 hero(p) {
739 const tone = p.tone ? toneOf(p.tone, 'info') : undefined
740 const title = (p.icon ? p.icon + ' ' : '') + p.title
741 return frame(
742 [
743 p.kicker ? T(p.kicker, { color: QUIET, wrap: 'truncate' }) : null,
744 T(title, { bold: true, color: color(tone), wrap: 'truncate' }),
745 p.sub ? T(p.sub, { color: QUIET, wrap: 'wrap' }) : null,
746 ],
747 tone,
748 )
749 },
750
751 card(p) {
752 const tone = p.tone ? toneOf(p.tone, 'info') : undefined
753 const head =
754 p.title || p.right
755 ? el('Box', { flexDirection: 'row', justifyContent: 'space-between', columnGap: 1 }, [
756 p.title ? T(p.title, { bold: true, wrap: 'truncate' }) : null,
757 p.right ? T(p.right, { color: QUIET, wrap: 'truncate' }) : null,
758 ])
759 : null
760 return frame([head, ...(Array.isArray(p.children) ? p.children : [])], tone)
761 },
762
763 section(label, right) {
764 return el('Box', { flexDirection: 'row', justifyContent: 'space-between', columnGap: 1 }, [
765 T(String(label), { bold: true, wrap: 'truncate' }),
766 right ? T(right, { color: QUIET, wrap: 'truncate' }) : null,
767 ])
768 },
769
770 tiles(items) {
771 return el(
772 'Box',
773 { flexDirection: 'row', flexWrap: 'wrap', columnGap: 1, rowGap: 1 },
774 items.map(it => {
775 const tone = it.tone ? toneOf(it.tone, 'info') : undefined
776 return el(
777 'Box',
778 { flexDirection: 'column', flexGrow: 1, borderStyle: 'single', borderColor: QUIET, paddingX: 1 },
779 [
780 T(it.label, { color: QUIET, wrap: 'truncate' }),
781 T(it.value, { bold: true, color: color(tone), wrap: 'truncate' }),
782 ],
783 )
784 }),
785 )
786 },
787
788 badge(text, tone) {
789 return chip(text, toneOf(tone, 'muted'))
790 },
791
792 legend(items) {
793 const parts: unknown[] = []
794 items.forEach((it, i) => {
795 if (i > 0) parts.push(' ')
796 parts.push(T('●', { color: TONES[toneOf(it.tone, 'muted')].fg }))
797 parts.push(' ' + it.label)
798 })
799 return el('Text', { wrap: 'wrap' }, parts)
800 },
801
802 bar(ratio, opts = {}) {
803 const t = toneOf(opts.tone, 'info')
804 return el('Text', { wrap: 'truncate' }, [
805 T(barText(ratio, opts.width ?? 10), { color: TONES[t].fg }),
806 T(' ' + pct(ratio), { color: QUIET }),
807 ])
808 },
809
810 row(p) {
811 const left = pieces(p.left, false)
812 const right = p.right === undefined || p.right === '' ? [] : pieces(p.right, true)
813 const alignItems = p.align === 'center' ? 'center' : undefined
814 return el('Box', { key: p.key, flexDirection: 'row', justifyContent: 'space-between', alignItems, columnGap: 1 }, [
815 el('Box', { flexDirection: 'row', flexShrink: 1, alignItems, columnGap: 1 }, left),
816 right.length ? el('Box', { flexDirection: 'row', alignItems, columnGap: 1 }, right) : null,
817 ])
818 },
819
820 kv(label, value, tone) {
821 const t = tone ? toneOf(tone, 'info') : undefined
822 return el('Text', { wrap: 'truncate' }, [
823 T(label + ' ', { color: QUIET }),
824 T(value, { bold: true, color: color(t) }),
825 ])
826 },
827
828 buttons(items) {
829 return el(
830 'Box',
831 { flexDirection: 'row', flexWrap: 'wrap', columnGap: 2, rowGap: 1 },
832 items.map(b =>
833 el('Button', {
834 key: b.key,
835 label: b.label,
836 hotkey: b.hotkey,
837 onPress: b.onPress,
838 plain: b.plain === true ? true : undefined,
839 variant: b.variant === 'primary' || b.variant === 'secondary' ? b.variant : undefined,
840 autoFocus: b.autoFocus === true ? true : undefined,
841 }),
842 ),
843 )
844 },
845
846 doodle(role, state = 'idle') {
847 const r = roleOf(role)
848 const s = stateOf(state)
849 if (desktop && typeof ui.Svg === 'function') {
850 return el('Svg', { alt: GLYPHS[r], width: GRID * CELL, height: GRID * CELL, source: doodleSvg(r, s) })
851 }
852 return T(GLYPHS[r], { color: STATE_COLORS[s], bold: s === 'running' })
853 },
854
855 stack(children, gap = 0) {
856 return el('Box', { flexDirection: 'column', rowGap: gap > 0 ? Math.floor(gap) : undefined }, children)
857 },
858
859 page(children) {
860 return el('Box', { flexDirection: 'column', rowGap: 1 }, Array.isArray(children) ? children : [])
861 },
862
863 list(children) {
864 return el('Box', { flexDirection: 'column', rowGap: 1 }, Array.isArray(children) ? children : [])
865 },
866
867 text(s, props = {}) {
868 return el('Text', props, [String(s)])
869 },
870
871 line(label, chips, right) {
872 const drawn = (Array.isArray(chips) ? chips : [])
873 .filter(c => c !== null && c !== undefined && c !== false && c !== '')
874 .slice(0, MAX_CHIPS)
875 .map(c => {
876 if (typeof c === 'string') return chip(c, 'muted')
877 if (isChipSpec(c)) return chip(c.text, toneOf(c.tone, 'muted'))
878 return c
879 })
880 const quiet = (Array.isArray(right) ? right.filter(Boolean).join(' · ') : right ?? '').trim()
881 return el('Box', { flexDirection: 'row', justifyContent: 'space-between', columnGap: 2 }, [
882 el('Box', { flexDirection: 'row', flexShrink: 1, columnGap: 2 }, [
883 T(String(label), { bold: true, wrap: 'truncate' }),
884 ...drawn,
885 ]),
886 quiet ? T(quiet, { color: QUIET, wrap: 'truncate' }) : null,
887 ])
888 },
889
890 // ------------------------------------------------------------ kit v3
891
892 pill(text, tone) {
893 const t = pillToneOf(tone, 'muted')
894 const s = String(text)
895 if (!svgOk) return t === 'violet' ? T('● ' + s, { color: fg(t), bold: true, wrap: 'truncate' }) : chip(s, t)
896 if (pillMode === 'svg') return svg(pillSvg(s, t), s, pillWidth(s), PILL_HEIGHT)
897 return T(` ${s} `, { backgroundColor: PILL_FILLS[t], color: 'inverseText', bold: true, wrap: 'truncate' })
898 },
899
900 header(label, right) {
901 return el('Box', { flexDirection: 'row', justifyContent: 'space-between', columnGap: 1 }, [
902 T(String(label).toUpperCase(), { bold: true, color: QUIET, wrap: 'truncate' }),
903 right ? T(String(right).toUpperCase(), { color: QUIET, wrap: 'truncate' }) : null,
904 ])
905 },
906
907 icon(name, tone) {
908 const n = iconOf(name)
909 const t = tone === undefined ? undefined : pillToneOf(tone, 'muted')
910 if (svgOk) return svg(iconSvg(n, t ? TONE_HEX[t] : ICON_STROKE), n, 16, 16)
911 return T(ICON_GLYPHS[n], { color: t ? fg(t) : QUIET })
912 },
913
914 typeBadge(fileName) {
915 const type = fileType(fileName)
916 if (!type) return null
917 if (svgOk) {
918 return svg(badgeSvg(type.label, type.fill), type.label, badgeWidth(type.label), 16)
919 }
920 return T(type.label, { bold: true, color: TONES[type.tone].fg })
921 },
922
923 progress(ratio, opts = {}) {
924 if (!svgOk) return kit.bar(ratio, opts)
925 const t = toneOf(opts.tone, 'info')
926 const width = typeof opts.width === 'number' && Number.isFinite(opts.width) && opts.width >= 1 ? Math.floor(opts.width) : undefined
927 // Without a width the Svg gets no width prop and markup 100% wide: the user's look confirms the
928 // desktop draws it the full width of its slot.
929 return svg(progressSvg(ratio, TONE_HEX[t], width), pct(ratio), width, 6)
930 },
931
932 nav(items, currentKey) {
933 return el(
934 'Box',
935 { key: 'nav', flexDirection: 'row', flexWrap: 'wrap', columnGap: 2, rowGap: 1 },
936 (Array.isArray(items) ? items : []).map(it => {
937 if (it.key === currentKey) return T(String(it.label), { bold: true, color: TONES.accent.fg, wrap: 'truncate' })
938 if (typeof it.onPress !== 'function') return T(String(it.label), { color: QUIET, wrap: 'truncate' })
939 return el('Button', { key: it.key, label: String(it.label), hotkey: it.hotkey, onPress: it.onPress, plain: true })
940 }),
941 )
942 },
943 }
944 return kit
945}
946
947function isChipSpec(c: unknown): c is { text: string; tone?: Tone } {
948 return (
949 typeof c === 'object' &&
950 c !== null &&
951 !('type' in c) &&
952 typeof (c as { text?: unknown }).text === 'string'
953 )
954}
955hooks/pr.ts 523 lines1// lk-pr-watch: the pure parts. Parsers for `gh pr view --json` and the GraphQL poll, the diff
2// between two polls, the band text, the poll cadence, config merging and the /pr command parser.
3// Nothing here touches $, so tests call these directly.
4//
5// Borrowed (MIT, Copyright (c) 2026 Seza Akgün, github.com/sezaakgun/cc-pr-tracker): the GraphQL
6// query shape, toCheck's buckets, the lineage key, latestPerName, clean() and linkable(). The
7// query adds completedAt so the pane can show a duration.
8
9import type { Bucket, Check, Config, NotifyKind, Snapshot, Watched } from '../types'
10
11export const PR_URL = /https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)/
12/** A prompt that is nothing but PR URLs (one or more, any whitespace). */
13export const ONLY_URLS = new RegExp(`^\\s*(${PR_URL.source}\\S*\\s*)+$`)
14
15export const VIEW_FIELDS = 'number,url,title,state,isDraft,mergeable,reviewDecision,headRefName,baseRefName'
16
17/** One call per PR: state, merge state, review decision and the check rollup of the head commit. */
18export const QUERY = `query($o: String!, $r: String!, $n: Int!) {
19 repository(owner: $o, name: $r) { pullRequest(number: $n) {
20 number title state isDraft mergeable mergeStateStatus reviewDecision
21 commits(last: 1) { nodes { commit { statusCheckRollup { contexts(first: 100) { nodes {
22 __typename
23 ... on CheckRun { name status conclusion detailsUrl startedAt completedAt checkSuite { app { slug } workflowRun { event workflow { name } } } }
24 ... on StatusContext { context state targetUrl createdAt }
25 } } } } } }
26 } }
27}`
28
29export const DEFAULTS: Config = {
30 pollSec: 120,
31 hotPollSec: 30,
32 notifyOn: ['failed', 'green', 'review', 'merged'],
33 appendNoteOnFailure: true,
34}
35
36/** How long after a push polling stays hot. */
37export const HOT_MS = 20 * 60_000
38
39const KINDS: readonly NotifyKind[] = ['failed', 'green', 'review', 'merged']
40
41// ---------- small guards ----------
42
43const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
44const str = (v: unknown): string => (typeof v === 'string' ? v : '')
45// GitHub text goes to the terminal as-is, so control characters are dropped first
46// eslint-disable-next-line no-control-regex
47export const clean = (s: string): string => s.replace(/[\x00-\x1f\x7f]/g, '')
48
49/** A Link refuses the whole tree unless its href is canonical https (or http://localhost). */
50export function linkable(href: string): boolean {
51 try {
52 const u = new URL(href)
53 return u.href === href && (u.protocol === 'https:' || (u.protocol === 'http:' && u.hostname === 'localhost'))
54 } catch {
55 return false
56 }
57}
58
59function parseJson(text: string): unknown {
60 try {
61 return JSON.parse(text)
62 } catch {
63 return undefined
64 }
65}
66
67// ---------- PR URLs ----------
68
69export type PrRef = { owner: string; repo: string; number: number; url: string; id: string }
70
71export function refOf(owner: string, repo: string, number: number): PrRef {
72 return { owner, repo, number, url: `https://github.com/${owner}/${repo}/pull/${number}`, id: `${owner}/${repo}#${number}` }
73}
74
75/** The first GitHub PR URL in the text, or null. */
76export function parsePrUrl(text: string): PrRef | null {
77 const m = PR_URL.exec(typeof text === 'string' ? text : '')
78 if (!m) return null
79 const n = Number(m[3])
80 return Number.isInteger(n) && n > 0 ? refOf(m[1], m[2], n) : null
81}
82
83/** Every distinct PR URL in a prompt that is only PR URLs; [] for any other prompt. */
84export function bareUrls(text: string): PrRef[] {
85 if (typeof text !== 'string' || !ONLY_URLS.test(text)) return []
86 const seen = new Set<string>()
87 const out: PrRef[] = []
88 for (const m of text.matchAll(new RegExp(PR_URL.source, 'g'))) {
89 const r = parsePrUrl(m[0])
90 if (r && !seen.has(r.id)) {
91 seen.add(r.id)
92 out.push(r)
93 }
94 }
95 return out
96}
97
98// ---------- gh pr view ----------
99
100export type PrView = {
101 number: number
102 url: string
103 title: string
104 state: string
105 isDraft: boolean
106 mergeable: string
107 reviewDecision: string
108 headRefName: string
109 baseRefName: string
110}
111
112/** Parses `gh pr view --json <VIEW_FIELDS>` output; null when it is not a PR. */
113export function parsePrView(stdout: string): PrView | null {
114 const j = parseJson(stdout)
115 if (!isObj(j)) return null
116 const number = typeof j.number === 'number' ? j.number : Number(j.number)
117 const url = str(j.url)
118 if (!Number.isInteger(number) || number <= 0 || !parsePrUrl(url)) return null
119 return {
120 number,
121 url,
122 title: clean(str(j.title)),
123 state: str(j.state) || 'OPEN',
124 isDraft: j.isDraft === true,
125 mergeable: str(j.mergeable) || 'UNKNOWN',
126 reviewDecision: str(j.reviewDecision),
127 headRefName: str(j.headRefName),
128 baseRefName: str(j.baseRefName),
129 }
130}
131
132/** True when gh says the branch simply has no PR (as opposed to auth or network trouble). */
133export function isNoPr(stderr: string): boolean {
134 return /no (open )?pull requests? found/i.test(typeof stderr === 'string' ? stderr : '')
135}
136
137// ---------- GraphQL ----------
138
139type Context = Record<string, unknown>
140
141const sub = (o: unknown, k: string): Record<string, unknown> | undefined => {
142 const v = isObj(o) ? o[k] : undefined
143 return isObj(v) ? v : undefined
144}
145
146const lineage = (c: Context): string => {
147 const suite = sub(c, 'checkSuite')
148 const run = sub(suite, 'workflowRun')
149 return [str(c.__typename), str(sub(suite, 'app')?.slug), str(sub(run, 'workflow')?.name), str(run?.event), str(c.name) || str(c.context)].join('\u0000')
150}
151
152const startOf = (c: Context): string => str(c.startedAt) || str(c.createdAt) || (c.status === 'COMPLETED' ? '' : '~')
153
154/** Keeps the latest run per check lineage (a re-run replaces the old one). */
155export function latestPerName(contexts: readonly Context[]): Context[] {
156 const latest = new Map<string, Context>()
157 for (const c of contexts) {
158 if (!isObj(c)) continue
159 const key = lineage(c)
160 const seen = latest.get(key)
161 if (!seen || startOf(c) >= startOf(seen)) latest.set(key, c)
162 }
163 return [...latest.values()]
164}
165
166/** The same buckets `gh pr checks` derives. */
167export function toCheck(c: Context): Check {
168 const key = lineage(c)
169 if (c.__typename === 'StatusContext') {
170 const s = str(c.state)
171 const bucket: Bucket = s === 'SUCCESS' ? 'pass' : s === 'PENDING' || s === 'EXPECTED' ? 'pending' : 'fail'
172 return { key, name: clean(str(c.context)), bucket, link: str(c.targetUrl), startedAt: str(c.createdAt), completedAt: '' }
173 }
174 const conclusion = str(c.conclusion)
175 const bucket: Bucket =
176 c.status !== 'COMPLETED'
177 ? 'pending'
178 : conclusion === 'SUCCESS' || conclusion === 'NEUTRAL'
179 ? 'pass'
180 : conclusion === 'SKIPPED'
181 ? 'skipping'
182 : conclusion === 'CANCELLED'
183 ? 'cancel'
184 : 'fail'
185 return { key, name: clean(str(c.name)), bucket, link: str(c.detailsUrl), startedAt: str(c.startedAt), completedAt: str(c.completedAt) }
186}
187
188export type GraphqlParse = { snap: Snapshot } | { error: string }
189
190/** Parses the `gh api graphql` response for QUERY. */
191export function parseGraphql(stdout: string): GraphqlParse {
192 const j = parseJson(stdout)
193 if (!isObj(j)) return { error: 'gh returned no JSON' }
194 if (Array.isArray(j.errors) && j.errors.length) {
195 const first = j.errors[0]
196 return { error: clean(str(isObj(first) ? first.message : '')) || 'GraphQL error' }
197 }
198 const pr = sub(sub(sub(j, 'data'), 'repository'), 'pullRequest')
199 if (!pr) return { error: 'PR not found' }
200 const commits = sub(pr, 'commits')
201 const nodes = Array.isArray(commits?.nodes) ? commits.nodes : []
202 const last = nodes[nodes.length - 1]
203 const rollup = sub(sub(last, 'commit'), 'statusCheckRollup')
204 const ctx = sub(rollup, 'contexts')
205 const raw = Array.isArray(ctx?.nodes) ? (ctx.nodes as Context[]) : []
206 const number = typeof pr.number === 'number' ? pr.number : Number(pr.number)
207 return {
208 snap: {
209 number: Number.isInteger(number) ? number : 0,
210 title: clean(str(pr.title)),
211 state: str(pr.state) || 'OPEN',
212 isDraft: pr.isDraft === true,
213 mergeable: str(pr.mergeable) || 'UNKNOWN',
214 mergeStateStatus: str(pr.mergeStateStatus) || 'UNKNOWN',
215 reviewDecision: str(pr.reviewDecision),
216 checks: latestPerName(raw).map(toCheck),
217 },
218 }
219}
220
221/** The argv for one poll. -f keeps owner and repo as strings; -F sends the number as an Int. */
222export function graphqlArgv(w: { owner: string; repo: string; number: number }): string[] {
223 return ['gh', 'api', 'graphql', '-f', `query=${QUERY}`, '-f', `o=${w.owner}`, '-f', `r=${w.repo}`, '-F', `n=${w.number}`]
224}
225
226// ---------- counts, diff ----------
227
228export type Counts = { total: number; pass: number; fail: number; pending: number; skipped: number }
229
230export function countsOf(snap: Snapshot | null): Counts {
231 const c: Counts = { total: 0, pass: 0, fail: 0, pending: 0, skipped: 0 }
232 for (const k of snap?.checks ?? []) {
233 c.total += 1
234 if (k.bucket === 'pass') c.pass += 1
235 else if (k.bucket === 'fail' || k.bucket === 'cancel') c.fail += 1
236 else if (k.bucket === 'pending') c.pending += 1
237 else c.skipped += 1
238 }
239 return c
240}
241
242/** Every check finished and none failed (skipped checks do not count against). */
243export function isGreen(snap: Snapshot | null): boolean {
244 const c = countsOf(snap)
245 return c.total > 0 && c.fail === 0 && c.pending === 0 && c.pass > 0
246}
247
248export type Transition =
249 | { kind: 'failed'; check: Check }
250 | { kind: 'green' }
251 | { kind: 'review'; from: string; to: string }
252 | { kind: 'merged' }
253
254/** What changed between two polls. The first poll (prev null) reports nothing. */
255export function diff(prev: Snapshot | null, next: Snapshot): Transition[] {
256 if (!prev) return []
257 const out: Transition[] = []
258 if (next.state === 'MERGED' && prev.state !== 'MERGED') out.push({ kind: 'merged' })
259 const before = new Map(prev.checks.map(c => [c.key, c.bucket]))
260 for (const c of next.checks) {
261 const was = before.get(c.key)
262 if ((c.bucket === 'fail' || c.bucket === 'cancel') && was !== 'fail' && was !== 'cancel') out.push({ kind: 'failed', check: c })
263 }
264 if (isGreen(next) && !isGreen(prev)) out.push({ kind: 'green' })
265 if (next.reviewDecision !== prev.reviewDecision) out.push({ kind: 'review', from: prev.reviewDecision, to: next.reviewDecision })
266 return out
267}
268
269// ---------- words ----------
270
271export function reviewText(decision: string): string {
272 if (decision === 'APPROVED') return 'approved'
273 if (decision === 'CHANGES_REQUESTED') return 'changes requested'
274 if (decision === 'REVIEW_REQUIRED') return 'review required'
275 return 'no review'
276}
277
278export function mergeText(snap: Snapshot): string {
279 if (snap.state === 'MERGED') return 'merged'
280 if (snap.state === 'CLOSED') return 'closed'
281 if (snap.isDraft) return 'draft'
282 if (snap.mergeable === 'CONFLICTING') return 'conflicts'
283 if (snap.mergeable === 'MERGEABLE') return snap.mergeStateStatus === 'BEHIND' ? 'mergeable (behind)' : 'mergeable'
284 return 'mergeability unknown'
285}
286
287/** A PR's level, named by the kit tone it is drawn in. */
288export type Level = 'ok' | 'warn' | 'danger'
289
290/** A kit tone name (the kit's Tone, without importing the kit into the pure parts). */
291export type ToneName = 'info' | 'ok' | 'warn' | 'danger' | 'muted' | 'accent'
292
293export function levelOf(snap: Snapshot | null): Level {
294 if (!snap) return 'warn'
295 if (snap.state === 'MERGED') return 'ok'
296 if (snap.state === 'CLOSED') return 'danger'
297 const c = countsOf(snap)
298 if (c.fail > 0 || snap.reviewDecision === 'CHANGES_REQUESTED' || snap.mergeable === 'CONFLICTING') return 'danger'
299 if (c.pending > 0 || snap.isDraft || snap.mergeable !== 'MERGEABLE' || snap.reviewDecision === 'REVIEW_REQUIRED') return 'warn'
300 return 'ok'
301}
302
303/** `PR #42 ● checks 3/5 ✓ 1 ✗ · review: changes requested · mergeable` */
304export function bandText(w: Watched): string {
305 const head = `PR #${w.number}`
306 const s = w.snap
307 if (!s) return `${head} ● ${w.error ? `gh failed: ${w.error}` : 'loading…'}`
308 const c = countsOf(s)
309 const checks = c.total === 0 ? 'no checks' : `checks ${c.pass}/${c.total} ✓${c.fail ? ` ${c.fail} ✗` : ''}${c.pending ? ` ${c.pending} …` : ''}`
310 return `${head} ● ${checks} · review: ${reviewText(s.reviewDecision)} · ${mergeText(s)}${w.error ? ' · refresh failed' : ''}`
311}
312
313/** The PR the band shows: the current branch's first, else the first added. */
314export function primaryOf(prs: readonly Watched[]): Watched | undefined {
315 return prs.find(p => p.source === 'branch') ?? prs[0]
316}
317
318/** The tone of a review decision's chip. */
319export const REVIEW_TONE: Record<string, ToneName> = { APPROVED: 'ok', CHANGES_REQUESTED: 'danger', REVIEW_REQUIRED: 'warn' }
320
321/** The tone of mergeText's words. */
322function mergeTone(snap: Snapshot): ToneName {
323 if (snap.state === 'MERGED') return 'ok'
324 if (snap.state === 'CLOSED' || snap.mergeable === 'CONFLICTING') return 'danger'
325 if (snap.isDraft || snap.mergeable !== 'MERGEABLE') return 'muted'
326 return snap.mergeStateStatus === 'BEHIND' ? 'warn' : 'ok'
327}
328
329/**
330 * The band's parts, drawn as kit.line(label, chips, right): `PR #42 ● checks 3/5 ✓ 1 ✗ ● changes
331 * requested ● mergeable +1 more`. Each chip carries its own tone; the right side is drawn in
332 * the default text color when the PR is urgent (level danger), else quiet.
333 */
334export type BandParts = { label: string; chips: { text: string; tone: ToneName }[]; right: string[]; urgent: boolean }
335
336/** The band for the primary PR (the branch's, else the first added), or null when none is watched. */
337export function bandParts(prs: readonly Watched[]): BandParts | null {
338 const p = primaryOf(prs)
339 if (!p) return null
340 const label = `PR #${p.number}`
341 const more = prs.length > 1 ? [`+${prs.length - 1} more`] : []
342 const s = p.snap
343 if (!s) {
344 const chips = [p.error ? { text: 'gh failed', tone: 'danger' as const } : { text: 'loading…', tone: 'muted' as const }]
345 return { label, chips, right: [...(p.error ? [p.error] : []), ...more], urgent: Boolean(p.error) }
346 }
347 const c = countsOf(s)
348 const checks =
349 c.total === 0
350 ? { text: 'no checks', tone: 'muted' as const }
351 : {
352 text: `checks ${c.pass}/${c.total} ✓${c.fail ? ` ${c.fail} ✗` : ''}${c.pending ? ` ${c.pending} …` : ''}`,
353 tone: c.fail ? ('danger' as const) : c.pending ? ('warn' as const) : ('ok' as const),
354 }
355 const review = { text: reviewText(s.reviewDecision), tone: REVIEW_TONE[s.reviewDecision] ?? 'muted' }
356 const merge = { text: mergeText(s), tone: mergeTone(s) }
357 const level = levelOf(s)
358 return { label, chips: [checks, review, merge], right: [...(p.error ? ['refresh failed'] : []), ...more], urgent: level === 'danger' }
359}
360
361export function toastText(w: Watched, t: Transition): string {
362 const head = `PR #${w.number}`
363 if (t.kind === 'failed') return `${head}: check "${t.check.name}" failed`
364 if (t.kind === 'green') return `${head}: all checks green`
365 if (t.kind === 'merged') return `${head} merged`
366 return `${head}: review ${reviewText(t.to)}`
367}
368
369/** The one-line note Claude reads on its next turn. Check names are GitHub data, quoted. */
370export function failureNote(w: Watched, c: Check): string {
371 const name = JSON.stringify(c.name.slice(0, 100))
372 return `[pr-watch: automated notice, not typed by the user] Check ${name} failed on PR #${w.number} (${w.owner}/${w.repo})${c.link ? `: ${c.link}` : ''}`
373}
374
375/** "1m 23s" between two ISO times; '' when either is missing. */
376export function durationOf(c: Check, now: number): string {
377 const a = Date.parse(c.startedAt)
378 if (!Number.isFinite(a)) return ''
379 const b = c.completedAt ? Date.parse(c.completedAt) : now
380 if (!Number.isFinite(b) || b < a) return ''
381 const s = Math.round((b - a) / 1000)
382 if (s < 60) return `${s}s`
383 const m = Math.floor(s / 60)
384 if (m < 60) return `${m}m ${s % 60}s`
385 return `${Math.floor(m / 60)}h ${m % 60}m`
386}
387
388/** Each bucket's glyph and the kit tone it is drawn in. */
389export const ICON: Record<Bucket, [string, ToneName]> = {
390 pass: ['✓', 'ok'],
391 fail: ['✗', 'danger'],
392 pending: ['●', 'warn'],
393 skipping: ['○', 'muted'],
394 cancel: ['⊘', 'danger'],
395}
396
397/** Plain text for /pr text. */
398export function summaryText(prs: readonly Watched[], now: number): string {
399 if (!prs.length) return 'pr-watch: no PR watched. Push a branch with a PR, paste a PR URL, or run /pr add <url|number>.'
400 const out: string[] = []
401 for (const w of prs) {
402 out.push(`${w.id} ${w.url}`)
403 const s = w.snap
404 if (!s) {
405 out.push(` ${w.error ? `gh failed: ${w.error}` : 'not polled yet'}`)
406 continue
407 }
408 out.push(` ${s.title}`)
409 out.push(` ${bandText(w)}`)
410 for (const c of s.checks) {
411 const d = durationOf(c, now)
412 out.push(` ${ICON[c.bucket][0]} ${c.name} ${c.bucket}${d ? ` ${d}` : ''}${c.link ? ` ${c.link}` : ''}`)
413 }
414 if (w.error) out.push(` last refresh failed: ${w.error}`)
415 }
416 return out.join('\n')
417}
418
419// ---------- cadence ----------
420
421/** Milliseconds between polls: hotPollSec while hot and a check is pending, else pollSec. */
422export function cadenceMs(cfg: Config, prs: readonly Watched[], now: number, hotUntil: number): number {
423 const pending = prs.some(p => countsOf(p.snap).pending > 0 || p.snap === null)
424 return (now < hotUntil && pending ? cfg.hotPollSec : cfg.pollSec) * 1000
425}
426
427export function isDue(cfg: Config, prs: readonly Watched[], now: number, hotUntil: number, lastPoll: number): boolean {
428 return prs.length > 0 && now - lastPoll >= cadenceMs(cfg, prs, now, hotUntil)
429}
430
431// ---------- Bash commands that matter ----------
432
433/** gh pr create, git push or gh pr merge: look for the branch's PR again. */
434export function isDiscoveryCommand(cmd: string): boolean {
435 return typeof cmd === 'string' && /\bgh\s+pr\s+(create|merge)\b|\bgit\s+push\b/.test(cmd)
436}
437
438/** gh pr create or git push: CI starts, so poll fast for a while. */
439export function isPushCommand(cmd: string): boolean {
440 return typeof cmd === 'string' && /\bgh\s+pr\s+create\b|\bgit\s+push\b/.test(cmd)
441}
442
443// ---------- config ----------
444
445function secs(v: unknown, lo: number, hi: number): number | undefined {
446 const n = typeof v === 'number' ? v : typeof v === 'string' && v.trim() !== '' ? Number(v) : NaN
447 return Number.isFinite(n) ? Math.min(hi, Math.max(lo, Math.round(n))) : undefined
448}
449
450/** Keeps the valid fields of one config file. */
451export function sanitize(raw: unknown): Partial<Config> {
452 if (!isObj(raw)) return {}
453 const out: Partial<Config> = {}
454 const p = secs(raw.pollSec, 15, 3600)
455 if (p !== undefined) out.pollSec = p
456 const h = secs(raw.hotPollSec, 10, 3600)
457 if (h !== undefined) out.hotPollSec = h
458 if (Array.isArray(raw.notifyOn)) out.notifyOn = KINDS.filter(k => (raw.notifyOn as unknown[]).includes(k))
459 if (typeof raw.appendNoteOnFailure === 'boolean') out.appendNoteOnFailure = raw.appendNoteOnFailure
460 return out
461}
462
463export function mergeConfig(user: unknown, project: unknown): Config {
464 return { ...DEFAULTS, ...sanitize(user), ...sanitize(project) }
465}
466
467// ---------- /pr ----------
468
469export type PrCommand =
470 | { kind: 'open' }
471 | { kind: 'text' }
472 | { kind: 'refresh' }
473 | { kind: 'add'; ref: PrRef }
474 | { kind: 'add-number'; number: number }
475 | { kind: 'rm'; target: string }
476 | { kind: 'band'; on: boolean }
477 | { kind: 'help'; reason: string }
478
479export const HELP =
480 'Usage: /pr (open the pane) · /pr add <url|number> · /pr rm <number|owner/repo#n> · /pr refresh · /pr band on|off · /pr text'
481
482export function parseCommand(args: string): PrCommand {
483 const words = (typeof args === 'string' ? args : '').trim().split(/\s+/).filter(Boolean)
484 const verb = (words[0] ?? '').toLowerCase()
485 const arg = words.slice(1).join(' ')
486 if (verb === '') return { kind: 'open' }
487 if (verb === 'text') return { kind: 'text' }
488 if (verb === 'refresh') return { kind: 'refresh' }
489 if (verb === 'add') {
490 const ref = parsePrUrl(arg)
491 if (ref) return { kind: 'add', ref }
492 const n = Number(arg.replace(/^#/, ''))
493 if (arg && Number.isInteger(n) && n > 0) return { kind: 'add-number', number: n }
494 return { kind: 'help', reason: `Not a PR URL or number: "${arg}".` }
495 }
496 if (verb === 'rm' || verb === 'remove') {
497 if (!arg) return { kind: 'help', reason: 'Name the PR to stop watching.' }
498 return { kind: 'rm', target: arg }
499 }
500 if (verb === 'band') {
501 const v = arg.toLowerCase()
502 if (v === 'on' || v === 'off') return { kind: 'band', on: v === 'on' }
503 return { kind: 'help', reason: 'Use /pr band on or /pr band off.' }
504 }
505 const ref = parsePrUrl(verb)
506 if (ref) return { kind: 'add', ref }
507 return { kind: 'help', reason: `Unknown subcommand "${verb}".` }
508}
509
510/** Which watched PRs a /pr rm target names: a number, #number, owner/repo#n or a URL. */
511export function matchTarget(prs: readonly Watched[], target: string): Watched[] {
512 const t = target.trim()
513 const ref = parsePrUrl(t)
514 if (ref) return prs.filter(p => p.id === ref.id)
515 if (t.includes('#') && t.includes('/')) return prs.filter(p => p.id === t)
516 const n = Number(t.replace(/^#/, ''))
517 return Number.isInteger(n) ? prs.filter(p => p.number === n) : []
518}
519
520export function newWatched(ref: PrRef, source: Watched['source']): Watched {
521 return { id: ref.id, url: ref.url, owner: ref.owner, repo: ref.repo, number: ref.number, source, snap: null, error: '', polledAt: 0 }
522}
523types/index.d.ts 81 lines1/** A transition kind the mod can toast for (config `notifyOn`). */
2export type NotifyKind = 'failed' | 'green' | 'review' | 'merged'
3
4/** The merged settings: defaults, then ~/.claude/pr-watch.json, then <cwd>/.claude/pr-watch.json. */
5export type Config = {
6 /** Seconds between polls when nothing is pending or the hot window is over. */
7 pollSec: number
8 /** Seconds between polls while a check is pending inside the hot window after a push. */
9 hotPollSec: number
10 /** Which transitions toast. */
11 notifyOn: NotifyKind[]
12 /** Give Claude a one-line note when a check newly fails. */
13 appendNoteOnFailure: boolean
14}
15
16/** pass / fail / pending / skipping / cancel, the buckets `gh pr checks` uses. */
17export type Bucket = 'pass' | 'fail' | 'pending' | 'skipping' | 'cancel'
18
19/** One check (the latest run per check name). */
20export type Check = {
21 /** Lineage key: type, app, workflow, event and name. */
22 key: string
23 name: string
24 bucket: Bucket
25 /** The run's URL, or ''. */
26 link: string
27 /** ISO time the run started, or ''. */
28 startedAt: string
29 /** ISO time the run completed, or ''. */
30 completedAt: string
31}
32
33/** What one GraphQL poll returned for a PR. */
34export type Snapshot = {
35 number: number
36 title: string
37 /** OPEN | CLOSED | MERGED */
38 state: string
39 isDraft: boolean
40 /** MERGEABLE | CONFLICTING | UNKNOWN */
41 mergeable: string
42 /** CLEAN, BLOCKED, BEHIND, DIRTY, UNSTABLE, ... */
43 mergeStateStatus: string
44 /** APPROVED | CHANGES_REQUESTED | REVIEW_REQUIRED | '' */
45 reviewDecision: string
46 checks: Check[]
47}
48
49/** One watched PR. */
50export type Watched = {
51 /** owner/repo#number */
52 id: string
53 url: string
54 owner: string
55 repo: string
56 number: number
57 /** 'branch': found for the current branch; 'user': added by URL or /pr add. */
58 source: 'branch' | 'user'
59 /** The last good poll, or null before the first one. */
60 snap: Snapshot | null
61 /** The last poll's error, or ''. */
62 error: string
63 /** Clock time of the last poll, or 0. */
64 polledAt: number
65}
66
67declare module 'claude-code' {
68 interface PluginState {
69 'lk-pr-watch': {
70 prs: Watched[]
71 config: Config
72 band: boolean
73 cwd: string
74 /** Clock time until which polling runs at hotPollSec while a check is pending. */
75 hotUntil: number
76 /** Clock time of the last poll round. */
77 lastPoll: number
78 }
79 }
80}
81