SLOPSHOPPER

loopkeeper PR Watch

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.

newpanebandguardcommandtoast
★ 1v0.3.1MITupdated 2026-10-12connectwithub/loopkeeper/plugins/lk-pr-watch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · lk-pr-watch
│ ┃ PR watch ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ pull requests │ ⏺ Read(src/auth.ts) │ ┃ │ No PR watched │ ⎿ Read 6 lines │ ┃ │ Push a branch with a PR, paste a PR URL, │ ⏺ Update(src/auth.ts) │ ┃ │ or run /pr add <url|number>. │ ⎿ Added 2 lines, removed 1 line │ ┃ ╰──────────────────────────────────────────╯ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ r: Refresh now c: Close │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /pr │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · PR watch
╭──────────────────────────────────────────────────────────╮ │ pull requests │ │ No PR watched │ │ Push a branch with a PR, paste a PR URL, or run /pr add │ │ <url|number>. │ ╰──────────────────────────────────────────────────────────╯ r: Refresh now c: Close
README

lk-pr-watch

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

  • You push from the session and wait on CI before you can merge.
  • You work on something else while a PR is in review and want a toast when the review decision changes, instead of reloading GitHub.
  • You follow several PRs at once: paste their URLs as a prompt and they are all watched in one pane.

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:

  • A band above the prompt, one kit line, only while a PR is watched: 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.
  • A /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).
  • Toasts on transitions: a check failed, all checks green, the review decision changed, the PR merged.
  • A note to Claude when a check newly fails, through $.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.

Requirements

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.

Install

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.

How PRs get watched

  • The current branch's PR: found with 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.
  • A bare PR URL as a prompt: paste 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>.

Polling

One gh api graphql call per watched PR, in the background; no turn waits on it.

  • Every pollSec seconds (default 120).
  • Every 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.

Pane

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

Commands

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

Configure

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

Files

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

What it runs and touches

From claude plugin validate --strict . and the code:

  • Processes: 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.
  • Files: reads ~/.claude/pr-watch.json and <cwd>/.claude/pr-watch.json; writes none.
  • Stored data: one key, band (the /pr band on|off choice), in the plugin's store.
  • Environment: reads HOME.
  • Model calls: none. A failed check adds one user-role note to the transcript; Claude reads it on its next turn.
  • Other: toasts, the clipboard (Copy URL), a 10 second timer.

Notes for directory reviewers

The directory's validator listed these items for this plugin. Each bullet says what the code does and where.

  • Programs it runs (policy holds 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.

  • Network use. The plugin has no network code of its own. It reaches GitHub only through the user's installed 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).
  • Local data and the conversation (policy holds 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.
  • Fields in 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 (warning ICON_MISSING). Added at .claude-plugin/icon.png (512 × 512); its source is docs/assets/icons/lk-pr-watch.svg in the repo.
  • Calls named in text (note 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.
  • Its command (note MOD_REGISTERS_SURFACES). It registers one slash command, /pr, at session start (line 383). It registers no tools or agents.
  • Classic hook (note 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.
  • Hooks on events that are also calls (note 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.

Borrowed from

cc-pr-tracker by Seza Akgün, MIT licence, Copyright (c) 2026 Seza Akgün:

  • the single gh api graphql query per PR (merge state, review decision, the head commit's check rollup), here with completedAt added and isRequired left out
  • toCheck's buckets, the lineage key and latestPerName (latest run per check name), clean() and linkable()
  • diffing each poll against the previous one, the bare-URL prompt answered with { drop }, the $.session.append note, and the attempt() wrapper for calls newer than a build

The MIT notice applies to those parts. The rest is written for this add-on.

Limits

  • Counts every check, required or not: the query does not ask for isRequired, so an optional failing check turns the checks chip to the error color.
  • Checks beyond the first 100 contexts on the head commit are not seen.
  • One GraphQL call per PR per poll counts against your GitHub API rate limit; with many PRs watched, raise pollSec.
  • The watch list lives in $.state: it survives a hot reload but not a new session, and /clear starts it again from the branch's PR.
  • Discovery after Bash matches on the command text, so git push inside a script file Claude runs is not seen. Use /pr refresh.
  • The pane shows durations of running checks against the local clock. The branch name is not kept, so the hero says "this branch" rather than naming it.
  • 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.
Source 4 files
hooks/register.ts 585 lines
1// 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}
585
hooks/kit.ts 955 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
955
hooks/pr.ts 523 lines
1// 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}
523
types/index.d.ts 81 lines
1/** 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