Shows the current branch's PR on the prompt hint row (title and its state: Draft, Ready or Merged); hover the hint row to preview a card of the spec…

Show the pull request of your current branch right on the prompt hint row, with a card (hover the hint row to preview it, click to pin it open) showing the spec, the integration branch and how far every ticket has come.
Requires Claude Code ≥ 2.1.287 and the GitHub CLI (
gh) authenticated for the repo.
When the branch your session is on has an open pull request (or, with none open, a merged one), the hint row (the line with bypass permissions on, accept edits on, ...) gets the PR and its state appended on the same row:
▸▸ bypass permissions on · PR #15 Add dark mode — settings page and editor · Ready
The state is the PR's own, the same for a single-ticket and a multi-ticket delivery: Draft (yellow, being built) → Ready (magenta, waiting for acceptance) → Merged (green, done; shown only while the branch still sits on the commit that was merged, so a long-lived dev that moved on after a dev → main release shows nothing). The mode phrase keeps its colour, the PR title takes all remaining width, and on narrow rows the state drops first, then the title shrinks.
Hover the hint row to preview a card above the prompt; it hides when the pointer leaves. Click the ▸ just before PR #N (padded to three cells so it is easy to hit) to pin it open (it turns into ▾), click it again to unpin:
╭──────────────────────────────────────────────────────────────────────────╮
│ PR #15 Add dark mode — settings page and editor ↻ refresh │
│ Spec #12 Dark mode · integration branch dev ← spec/12-dark-mode │
│ CI ✓3/3 · Ready │
│ ● #14 in progress Theme toggle · feat/14-theme-toggle · 2 commits behind │
│ ● #16 not started Save settings │
│ ● #13 merged Color names · feat/13-color-names │
│ ● #11 accepted Dark palette │
│ Open PR · fetched 2 min ago │
╰──────────────────────────────────────────────────────────────────────────╯
Top to bottom: the PR title, the Spec and the integration branch (base ← head; with no Spec, a single ticket takes the Spec's place as Ticket #N · title, several tickets show the branches alone), CI and the PR state, one line per ticket (nothing is dropped), then the PR link and when pr-hint last fetched from GitHub (fetched, the only time shown). The ↻ refresh button at the card's top right refreshes by hand (see Refresh). Tickets are ordered in progress, not started, merged, accepted. A leading [mod] scope tag on a ticket title (as in [pr-hint] Edit form) is left off the card. A ↻N after CI counts checks still running.
The PR shown always belongs to where the session is: the repository root plus the branch. A cd into a subfolder of the same repository changes nothing; a cd to another repository, a git checkout of another branch, or a /clear elsewhere hides the old PR at once and reads the new place's (see Refresh). Only a PR whose head branch lives in this repository counts; a fork's PR of the same branch name is ignored. With no PR, or only a closed one, the band and the hint row stay as Claude Code draws them, except when the ← N agents pill is present: then the pill is removed and that frame's line is redrawn from the text (with a PR the pill is hidden to make room).
The issues the PR closes (the ones GitHub links to it, else the Closes #N lines of its body) are read. The one labelled spec is the Spec; it is shown on its own line, not as a ticket. The rest are tickets.
From the local git branches plus the PR's commit headlines (no git fetch, no model calls), first match wins:
| Status | Rule |
|---|---|
| accepted (green dot) | the issue is closed, or its acceptance table (a table whose header has State and Rounds) is all ✓ |
| in progress | a ticket branch (*/<N>-* or <N>-*) is the PR head, or is ahead of it |
| merged | a PR commit headline names the ticket: <type>(#N): …, <type>(<scope>):… #N (the number ends the headline), or a Merge … headline naming a /N- branch. A branch that is merged alone does not count, and neither does a passing mention such as chore: absorb #N |
| not started | none of the above |
A ticket's status colours its own line on the card only; nothing is counted on the hint row. N commits behind tells how many commits the ticket's branch has that the PR head does not.
Two tiers, each on its own timer, never overlapping (a tick that finds another refresh running is skipped). The 20 s tier redraws only when the refs it reads changed; every gh fetch redraws, because its fetched time moves. Session start and the end of every turn run a full refresh (PR, tickets and git status); one asked for while a full refresh is already running waits for that one and shares its answer instead of starting another.
| Every | Reads | Recomputes ticket statuses when |
|---|---|---|
| 20 s | local git only, no network: the repository root, the checked-out branch and git for-each-ref | the branches and their commits differ from the last look: local merges, new commits, deleted branches. When the root or branch differs from where the last fetch ran (a PR found or not), it triggers a full refresh, so a git checkout onto a branch that has a PR shows it within 20 s, not 5 min |
| 5 min | one gh api graphql request: the PR (resolved by gh from the session directory's repository and current branch), its CI and commits, and every closing issue | always (a full refresh) |
Only a PR into a non-default branch, which GitHub links to no issue, costs a second request: its Closes #N issues by number. Need fresher data sooner? Press the button, or end a turn.
A failed fetch (gh exits non-zero, times out, answers something unreadable or with GraphQL errors) is not "no PR": the PR already shown stays, with its old fetched time. Only a successful answer with no open PR, and no merged PR the branch still sits on, clears it.
Manual refresh: press ↻ refresh on the card for a full refresh (it joins a running one; the button reads refreshing… meanwhile and ignores extra presses). When it ends a toast says what changed (PR #23 updated: Draft → Ready · CI ✓1/1), PR #23 is up to date, No PR on this branch, or Fetch failed, try again later, even if the card has been closed. The footer's fetched time is the last successful answer from GitHub; the 20 s git recompute does not move it.
Git status uses git branch -a and git rev-list --count; everything runs in the session's directory.
Open /config and pick pr-hint.
| Setting | Default | Meaning |
|---|---|---|
| Language | auto | auto follows Claude Code's language setting, then the locale; en or zh forces one. English is the default. |
In Claude Code (2.1.287 or later):
/plugin marketplace add nokiy/claude-code-mods
/plugin install pr-hint@nokiy-mods
The first command adds this repository as a plugin marketplace (once); the second installs the mod from it. Start a new session afterwards.
MIT
hooks/register.tsx 384 lines1// Hooks entry of pr-hint; refreshes PR data and draws the PromptHint line plus its hover-preview, click-to-pin card.
2// The engine's `$` and the PR atom stay in this file (the validator follows them nowhere else); text and parsing live in card.ts, graphql.ts, parse.ts and strings.ts; tests are in ../tests.
3import { atom, read, update } from 'claude-code';
4import type { EngineInterface, Register } from 'claude-code';
5import { cardLines, hintLayout, hintSpans, refreshText, stateChip, withoutAgents } from './card';
6import { PR_ARGS, PR_QUERY, REPO_ARGS, isCurrentPr, issuesQuery, parseGraphql, parseIssues, splitIssues } from './graphql';
7import type { PrJson } from './graphql';
8import { closingNumbers, isInside, mergedByCommits, parsePr, pickShown, prHead, sameWhere, ticketBranches, ticketStatus, width } from './parse';
9import { pickLang, strings } from './strings';
10import type { Strings } from './strings';
11import type { PrData, PrTicket, Where } from '../types';
12
13// The engine keeps stored values across a reload or upgrade; the shape tag (bump it whenever PrData's shape changes) reads an old-shaped PrData as absent.
14const pr = atom({ plugin: 'pr-hint', key: 'pr' } as const, null, { shape: 'pr-v2' });
15// Whether the card is pinned open (a press on the hint row's pin toggles it).
16const pinned = atom({ plugin: 'pr-hint', key: 'pinned' } as const, false);
17// Whether a manual ↻ refresh is running (the card's button shows it and ignores presses).
18const refreshing = atom({ plugin: 'pr-hint', key: 'refreshing' } as const, false);
19
20// Shared hover scope: the hint row lights it, the AbovePrompt card is revealed by it.
21const SCOPE = 'pr-hint-card';
22
23// Module-level on purpose: the validator wants `$` passed only to top-level functions of this file; a reload drops it all.
24// full: PR, tickets and git, one gh request · git: local refs only.
25type Mode = 'full' | 'git';
26// The refresh in flight and its mode: a tick that finds one is skipped, a full request joins (waits for) a running full one.
27let running: { mode: Mode; done: Promise<void> } | null = null;
28// Set synchronously by the ↻ press, before any await, so two presses cannot both start.
29let isManual = false;
30// Whether the last full fetch got no usable answer from gh (then the data it left is the previous one).
31let fetchFailed = false;
32// The two tier timers of this load; a repeated session.start cancels them before making new ones.
33let timers: Array<{ cancel: () => void }> = [];
34// `git for-each-ref` output at the last status computation; the git tier recomputes only when it differs.
35let refSnap: string | null = null;
36// Where the session was last seen (render and 20s tier read it; the render compares PR data against it),
37// and where the last full fetch ran, PR found or not: the 20s tier refetches when the two differ.
38let curWhere: Where | null = null;
39let fetchedWhere: Where | null = null;
40// The open PR as last read: where, its JSON, the tickets as gh gave them (`raw`) and with their git status (`tickets`).
41// The git tier recomputes from this without asking gh. `fetchedAt` is when gh last answered; the git recompute keeps it.
42type Cache = { where: Where; json: PrJson; raw: PrTicket[]; tickets: PrTicket[]; spec: PrData['spec']; fetchedAt: number };
43let cache: Cache | null = null;
44
45// The UI strings: the language option is read once per load (it needs the session's settings and LANG), English until then.
46let langOption: unknown = 'auto';
47let t: Strings = strings('en');
48let langLoad: Promise<void> | undefined;
49const ensureLang = ($: EngineInterface) => (langLoad ??= (async () => {
50 t = strings(pickLang(langOption, (await $.settings.read()).language, await $.env.get('LANG')));
51})());
52
53// Local git only: which of the PR head's refs resolves, local first.
54async function resolveHead($: EngineInterface, head: string): Promise<string | null> {
55 for (const ref of [head, `origin/${head}`]) {
56 const r = await $.process.run(['git', 'rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
57 if (r.exitCode === 0) return ref;
58 }
59 return null;
60}
61
62// Sets each ticket's status from the PR's commit headlines, branch names and `rev-list --count head..branch`.
63async function applyStatuses($: EngineInterface, tickets: PrTicket[], headRef: string, headlines: string[]): Promise<PrTicket[]> {
64 const head = await resolveHead($, headRef);
65 const list = head === null ? null : await $.process.run(['git', 'branch', '-a', '--format=%(refname:short)']);
66 const names = list && list.exitCode === 0 ? list.stdout.split('\n').map(s => s.trim()).filter(Boolean) : [];
67 const out: PrTicket[] = [];
68 const merged = mergedByCommits(headlines, tickets.map(tk => tk.number));
69 for (const tk of tickets) {
70 const found: Array<{ b: string; n: number }> = [];
71 for (const b of head === null ? [] : ticketBranches(tk.number, names)) {
72 const c = await $.process.run(['git', 'rev-list', '--count', `${head}..${b}`]);
73 const n = parseInt(c.stdout.trim(), 10);
74 if (c.exitCode === 0 && Number.isFinite(n)) found.push({ b, n });
75 }
76 const ahead = Math.max(0, ...found.map(f => f.n));
77 out.push({
78 ...tk,
79 status: ticketStatus(tk.state, tk.progress, {
80 isHead: ticketBranches(tk.number, [headRef]).length > 0,
81 counts: found.map(f => f.n),
82 isMerged: merged.has(tk.number),
83 }),
84 branch: pickShown(found),
85 ahead,
86 });
87 }
88 return out;
89}
90
91// Local git only: where the session stands (directory, repository root, branch); null when any read fails.
92async function readWhere($: EngineInterface): Promise<Where | null> {
93 try {
94 const cwd = await $.session.cwd();
95 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel']);
96 const head = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD']);
97 return top.exitCode === 0 && head.exitCode === 0 ? { cwd, root: top.stdout.trim(), branch: head.stdout.trim() } : null;
98 } catch {
99 return null;
100 }
101}
102
103// Local git only: the heads and remotes with their commits; null when git fails.
104async function readRefs($: EngineInterface): Promise<string | null> {
105 const r = await $.process.run(['git', 'for-each-ref', '--format=%(refname) %(objectname)', 'refs/heads', 'refs/remotes']);
106 return r.exitCode === 0 ? r.stdout : null;
107}
108
109// Statuses from local git plus the PR's commits; remembers the ref snapshot they were computed at.
110async function settle($: EngineInterface, raw: PrTicket[], json: PrJson, refs: string | null): Promise<PrTicket[]> {
111 refSnap = refs ?? refSnap;
112 const { headRef, headlines } = prHead(json);
113 return applyStatuses($, raw, headRef, headlines);
114}
115
116// Never throws. One `gh api graphql` request brings the PR and its closing issues; only a PR into a non-default
117// branch (GitHub links no issue) costs a second one for its `Closes #N` issues. Only a usable answer without a
118// current PR of this repository (open, or merged at the branch's own commit) clears the data. undefined = gh gave no usable answer (failed, timed out, bad JSON,
119// GraphQL errors): the data read at this same place stays; data from another place is dropped, it is never drawn.
120async function fetchPr($: EngineInterface): Promise<PrData | null | undefined> {
121 // Runs in the session's working directory by default; the data is tagged with where it was read, before gh runs.
122 const where = await readWhere($);
123 if (where !== null) curWhere = fetchedWhere = where;
124 const fail = () => {
125 fetchFailed = true;
126 if (cache !== null && sameWhere(cache.where, where)) return undefined;
127 return (cache = null);
128 };
129 try {
130 if (where === null) return fail();
131 const r = await $.process.run(['gh', ...PR_ARGS, '-f', `query=${PR_QUERY}`]);
132 const answer = r.exitCode === 0 ? parseGraphql(r.stdout) : null;
133 if (answer === null || !answer.ok) return fail();
134 // The OPEN PR, else the branch's latest MERGED one while the branch still sits on its merged commit; closed reads as no PR.
135 const json = answer.pr;
136 const refs = await readRefs($);
137 if (json === null || !isCurrentPr(json, refs, where.branch)) {
138 fetchFailed = false;
139 return (cache = null);
140 }
141
142 let issues = json.closingIssuesReferences;
143 const nums = closingNumbers(json);
144 if (issues.length === 0 && nums.length > 0) {
145 const more = await $.process.run(['gh', ...REPO_ARGS, '-f', `query=${issuesQuery(nums)}`]);
146 if (more.exitCode !== 0) return fail();
147 issues = parseIssues(more.stdout);
148 }
149 const { raw, spec } = splitIssues(issues);
150 cache = { where, json, raw, spec, tickets: await settle($, raw, json, refs), fetchedAt: await $.clock.now() };
151 fetchFailed = false;
152 return parsePr(json, where, cache.tickets, spec, cache.fetchedAt);
153 } catch {
154 return fail();
155 }
156}
157
158// One tier's work. undefined = nothing changed, leave the atom alone.
159async function step($: EngineInterface, mode: Mode): Promise<PrData | null | undefined> {
160 if (mode === 'full') return fetchPr($);
161 try {
162 // Local reads only; a failed read leaves everything as it was.
163 const where = await readWhere($);
164 if (where === null) return undefined;
165 curWhere = where;
166 // Another repository or branch than the last fetch's (PR found or not): read the PR anew; the render already hides the old one.
167 if (!sameWhere(where, fetchedWhere)) {
168 void refresh($, 'full');
169 return undefined;
170 }
171 const c = cache;
172 if (c === null) return undefined;
173 const refs = await readRefs($);
174 if (refs === null || refs === refSnap) return undefined;
175 // A branch that moved off a merged PR's commit has no PR any more (no gh call needed).
176 refSnap = refs;
177 if (!isCurrentPr(c.json, refs, c.where.branch)) return (cache = null);
178 c.tickets = await settle($, c.raw, c.json, refs);
179 return parsePr(c.json, c.where, c.tickets, c.spec, c.fetchedAt);
180 } catch {
181 return undefined;
182 }
183}
184
185async function run($: EngineInterface, mode: Mode): Promise<void> {
186 try {
187 const next = await step($, mode);
188 if (next === undefined) return;
189 const prev = await read($, pr);
190 if (JSON.stringify(prev) !== JSON.stringify(next)) await update($, pr, () => next);
191 } catch {
192 fetchFailed = true;
193 }
194}
195
196// One refresh at a time. A git tick that finds one running is skipped; a full request that finds a full one running
197// waits for that one and shares its answer instead of queueing another (a git tick in flight is waited out, then it runs).
198async function refresh($: EngineInterface, mode: Mode): Promise<void> {
199 while (running !== null) {
200 if (mode === 'git') return;
201 const { mode: was, done } = running;
202 await done;
203 if (was === 'full') return;
204 }
205 // `running` is set before this function's first await, so two callers cannot both start.
206 const done = run($, mode).finally(() => { running = null; });
207 running = { mode, done };
208 await done;
209}
210
211// The ↻ press: one full refresh that really finishes, then a toast saying what changed, or that the fetch failed.
212// Further presses while it runs are ignored; the toast shows whether or not the card is still open.
213async function manualRefresh($: EngineInterface): Promise<void> {
214 if (isManual) return;
215 isManual = true;
216 try {
217 await update($, refreshing, () => true);
218 const before = await read($, pr);
219 await refresh($, 'full');
220 $.ui.toast(fetchFailed ? t.failed : refreshText(before, await read($, pr), t), { timeoutMs: 5000 });
221 } catch {
222 $.ui.toast(t.failed, { timeoutMs: 5000 });
223 } finally {
224 isManual = false;
225 await update($, refreshing, () => false).catch(() => undefined);
226 }
227}
228
229// The PR to draw: null when there is none or it was read elsewhere. Elsewhere = the session's directory is outside the
230// repository root it was read in, or the root or branch last seen locally (fetch, 20s tier) differs (/clear, cd, repo and
231// branch switches); then `isStale` says the atom is out of date. A subfolder of the same repo is still here.
232// A failed cwd read only hides (no refresh asked). Never throws. The only cwd check at render: git runs in fetch and the 20s tier.
233async function currentPr($: EngineInterface): Promise<{ data: PrData | null; isStale: boolean }> {
234 const data = await read($, pr);
235 if (data === null) return { data: null, isStale: false };
236 try {
237 const cwd = await $.session.cwd();
238 const { where } = data;
239 // Also inside the directory it was read from: git's root may be a symlink-resolved path the session's cwd is not spelled in.
240 const isHere = (isInside(cwd, where.root) || isInside(cwd, where.cwd)) && sameWhere(where, curWhere);
241 return isHere ? { data, isStale: false } : { data: null, isStale: true };
242 } catch {
243 return { data: null, isStale: false };
244 }
245}
246
247export const register: Register = (on, options) => {
248 langOption = options.language;
249 langLoad = undefined;
250 t = strings('en');
251
252 on('session.start', async ($, e, next) => {
253 void refresh($, 'full');
254 // One timer per tier: a second session.start replaces them instead of stacking.
255 for (const tm of timers) tm.cancel();
256 timers = [
257 $.clock.every(20_000, () => void refresh($, 'git')),
258 $.clock.every(300_000, () => void refresh($, 'full')),
259 ];
260 return next(e);
261 });
262
263 on('turn.complete', async ($, e, next) => {
264 void refresh($, 'full');
265 return next(e);
266 });
267
268 // Hint row: the engine's hint, a pin Button (` ▸ ` / ` ▾ `, the click that pins the card),
269 // then `PR #N` (cyan, bold), the title and the PR state chip. Only a Button takes a press
270 // (Box and Text have no onPress) and a plain Button's hit area is its label cells, so the pin
271 // is padded to three cells and `PR #N` keeps its colour. The Box is the hover handle: the card shares its `scope`.
272 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
273 const { data, isStale } = await currentPr($);
274 // Only this row asks for the refresh of a stale PR; the card just hides, so a cd costs one full refresh.
275 if (isStale) void refresh($, 'full');
276 const hint = withoutAgents(e.props.hint);
277 if (data === null) {
278 // No PR: the engine's line stays live unless it carries the agents pill, which is always hidden.
279 return hint === e.props.hint ? next(e) : next({ ...e, props: { ...e.props, hint } });
280 }
281 await ensureLang($);
282
283 const { Box, Button, Text } = $.ui.resolve(e);
284 const columns = e.viewport?.columns ?? 80;
285 const isPinned = (await read($, pinned)) === true;
286 // Room for the PR group: the row less the hint text, the " ·" separator (2), the padded pin (3) and a 2-cell margin.
287 const layout = hintLayout(data, columns - width(hint) - 3 - 2 - 2, t);
288 const chip = stateChip(data, t);
289
290 return (
291 <Box key="pr-hint" flexDirection="row" hover={{ scope: SCOPE }}>
292 <Box flexShrink={0}>
293 <Text key="hint">
294 {hintSpans(hint).map((p, i) => (
295 <Text key={`h${i}`} color={p.color} dimColor={p.dim}>{p.text}</Text>
296 ))}
297 <Text dimColor>{' ·'}</Text>
298 </Text>
299 </Box>
300 <Box flexShrink={0}>
301 <Button
302 key="pin"
303 label={isPinned ? ' ▾ ' : ' ▸ '}
304 plain
305 hover={{ color: 'cyan', bold: true }}
306 onPress={() => update($, pinned, p => !p)}
307 />
308 </Box>
309 <Box flexShrink={0}>
310 <Text key="pr-num" color="cyan" bold>{`PR #${data.number}`}</Text>
311 </Box>
312 {/* Only the title shrinks: the row's real width (the engine's own pills included) decides
313 where it is cut, so the state chip after it always stays whole. */}
314 <Box flexShrink={1}>
315 <Text key="pr-title" wrap="truncate-end">{` ${layout.hasSummary ? data.title : layout.title}`}</Text>
316 </Box>
317 {layout.hasSummary ? (
318 <Box flexShrink={0}>
319 <Text key="pr-sum">
320 <Text dimColor>{' · '}</Text>
321 <Text color={chip.color} bold>{chip.text}</Text>
322 </Text>
323 </Box>
324 ) : null}
325 </Box>
326 );
327 });
328
329 // The detail card lives in the AbovePrompt band (an absolute Box under PromptHint is clipped by the bottom slot),
330 // hidden until the hint row's scope is hovered and always shown while pinned; the band scrolls when taller than maxRows.
331 // The rest of the chain (agent-monitor's rows, the engine band) is drawn under the card, outside its hidden box.
332 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
333 const { data } = await currentPr($);
334 if (data === null || e.props.hasSurvey) return next(e);
335 await ensureLang($);
336
337 const { Box, Button, Text, Link } = $.ui.resolve(e);
338 // Border (2) + paddingX (2) leave this many cells for text.
339 const inner = Math.max(20, e.props.bodyColumns - 4);
340 const isPinned = (await read($, pinned)) === true;
341 const isRefreshing = (await read($, refreshing)) === true;
342 // Padded by a cell each side, like the pin: a plain Button's hit area is its label cells.
343 const label = ` ${isRefreshing ? t.refreshing : t.refresh} `;
344 // The title wraps in what the button (plus a 1-cell gap) leaves of the first row.
345 const card = cardLines(data, await $.clock.now(), inner, t, Math.max(10, inner - width(label) - 1));
346 const [head, ...rest] = card.lines;
347 const row = (l: NonNullable<typeof head>, i: number) => (
348 <Text key={String(i)} wrap="truncate-end">
349 {l.parts.map((p, j) => (
350 <Text key={String(j)} color={p.color} bold={p.bold} dimColor={p.dim}>{p.text}</Text>
351 ))}
352 </Text>
353 );
354 const detail = (
355 <Box
356 flexDirection="column"
357 borderStyle="round"
358 paddingX={1}
359 {...(isPinned ? {} : { display: 'none' as const, hover: { scope: SCOPE, display: 'flex' as const } })}
360 >
361 {/* First row: the PR title shrinks and truncates, the ↻ refresh button keeps its full width at the right. */}
362 <Box flexDirection="row">
363 <Box flexShrink={1} flexGrow={1}>{row(head!, 0)}</Box>
364 <Box key="refresh-box" flexShrink={0} marginLeft={1}>
365 <Button
366 key="refresh"
367 label={label}
368 plain
369 hover={{ color: 'cyan', bold: true }}
370 onPress={() => manualRefresh($)}
371 />
372 </Box>
373 </Box>
374 {rest.map((l, i) => row(l, i + 1))}
375 <Text wrap="truncate-end">
376 <Link href={data.url} label={t.openPr} />
377 <Text>{card.footer}</Text>
378 </Text>
379 </Box>
380 );
381 return <Box flexDirection="column">{detail}{await next(e)}</Box>;
382 });
383}
384hooks/card.ts 190 lines1// Pure text for the AbovePrompt detail card (full hierarchy, no row cap) and the hint row's spans and layout.
2import type { PrData, PrTicket, TicketStatus } from '../types';
3import { prState, relTime, shortTitle, sortTickets, truncate, width, wrapCells } from './parse';
4import type { PrState } from './parse';
5import type { Strings } from './strings';
6
7/** One coloured run inside a line. */
8export type CardPart = { text: string; color?: string; bold?: boolean; dim?: boolean };
9/** `text` is the whole line; `parts` are its coloured runs (they concatenate to `text`). */
10export type CardLine = { text: string; parts: CardPart[] };
11
12export const STATUS_COLOR: Record<TicketStatus, string> = {
13 todo: 'gray',
14 doing: 'yellow',
15 merged: 'blueBright',
16 done: 'green',
17};
18
19/** The PR state chip: building, waiting on the owner's acceptance, done. */
20export const STATE_COLOR: Record<PrState, string> = {
21 draft: 'yellow',
22 ready: 'magenta',
23 merged: 'green',
24};
25
26/** The chip's text and colour for one PR. */
27export const stateChip = (pr: PrData, s: Strings): { text: string; color: string } => {
28 const state = prState(pr);
29 return { text: s.prState[state], color: STATE_COLOR[state] };
30};
31
32const line = (parts: CardPart[]): CardLine => ({ text: parts.map(p => p.text).join(''), parts });
33
34// A ticket title's lead before the first ` · `, split into the shared part and its ordinal:
35// `深色模式 ② · 编辑器配色` → base `深色模式`, ordinal `②`, rest `编辑器配色`.
36const LEAD = /^(.+?)\s*([①-⑳]|\d+[a-z]?)?\s*·\s*(.+)$/u;
37
38// A leading `[mod] ` tag on a ticket title (the tracker's scope prefix) is noise on the card.
39const TAG = /^\s*\[[^\]]*\]\s*/;
40
41/**
42 * Drops a leading `[mod]` tag; then, when every ticket (two or more) shares the same lead (the
43 * Spec's subject), drops that too and keeps the ordinal and what this ticket does: `② 编辑器配色`.
44 */
45export function ticketSubjects(tickets: readonly PrTicket[]): Map<number, string> {
46 const parsed = tickets.map(t => {
47 const title = t.title.replace(TAG, '');
48 return { n: t.number, title, m: LEAD.exec(title) };
49 });
50 const bases = new Set(parsed.map(p => p.m?.[1]?.trim() ?? null));
51 const shared = tickets.length >= 2 && bases.size === 1 && !bases.has(null);
52 return new Map(parsed.map(p => [p.n, shared && p.m ? `${p.m[2] ? `${p.m[2]} ` : ''}${p.m[3]}` : p.title]));
53}
54
55// `● #9 merged <subject>`, then the branch (dim) and, when ahead, `N commits behind`.
56// The status alone says where a ticket stands (accepted turns the dot green); no per-row counts.
57// The title takes the width the rest of the line leaves inside `inner`.
58const ticketLine = (t: PrTicket, subject: string, s: Strings, inner: number): CardLine => {
59 const color = STATUS_COLOR[t.status];
60 const head = ` #${t.number} ${s.status[t.status]} `;
61 const branch = t.branch ? ` · ${t.branch}` : '';
62 const behind = t.ahead > 0 ? ` · ${s.behind(t.ahead)}` : '';
63 const room = Math.max(12, inner - 1 - width(head) - width(branch) - width(behind));
64 return line([
65 { text: '●', color },
66 { text: ` #${t.number} ` },
67 { text: s.status[t.status], color },
68 { text: ` ${shortTitle(subject, room)}` },
69 ...(branch ? [{ text: branch, dim: true }] : []),
70 ...(behind ? [{ text: behind, color: 'yellow' }] : []),
71 ]);
72};
73
74const ciText = (ci: PrData['ci'], s: Strings): string =>
75 ci.total === 0 ? s.noCi : `CI ✓${ci.ok}/${ci.total}${ci.fail > 0 ? ` ✗${ci.fail}` : ''}${ci.pending > 0 ? ` ↻${ci.pending}` : ''}`;
76
77/**
78 * The toast after a manual refresh: what changed between `prev` and `next` (title, PR state, CI,
79 * number of tickets), "up to date" when nothing did, or that the PR is gone.
80 */
81export function refreshText(prev: PrData | null, next: PrData | null, s: Strings): string {
82 if (next === null) return s.gone;
83 if (prev === null) return s.upToDate(next.number);
84 const was = stateChip(prev, s).text;
85 const now = stateChip(next, s).text;
86 const parts = [
87 prev.title !== next.title ? `“${next.title}”` : '',
88 was !== now ? `${was} → ${now}` : '',
89 ciText(prev.ci, s) !== ciText(next.ci, s) ? ciText(next.ci, s) : '',
90 prev.tickets.length !== next.tickets.length ? `${s.tickets} ${prev.tickets.length} → ${next.tickets.length}` : '',
91 ].filter(Boolean);
92 return parts.length === 0 ? s.upToDate(next.number) : s.changed(next.number, parts.join(' · '));
93}
94
95/**
96 * The card, top to bottom, `inner` cells wide: the PR title (wrapped to at most
97 * 3 rows), the Spec with its integration branch, the CI and the PR state chip,
98 * then one line per ticket. `footer` follows the link.
99 */
100export function cardLines(pr: PrData, nowMs: number, inner: number, s: Strings, titleInner = inner): { lines: CardLine[]; footer: string } {
101 const prefix = `PR #${pr.number}`;
102 const title = wrapCells(`${prefix} ${pr.title}`, titleInner, 3).map((text, i) =>
103 i === 0 && text.startsWith(prefix)
104 ? line([{ text: prefix, color: 'cyan', bold: true }, { text: text.slice(prefix.length) }])
105 : line([{ text }]),
106 );
107
108 const subjects = ticketSubjects(pr.tickets);
109 const branches = `${s.integration} ${pr.base} ← ${pr.head}`;
110 // Row 2: the Spec, else the lone ticket, else (several tickets) just the branches; no tickets keeps the state.
111 const only = pr.tickets.length === 1 ? pr.tickets[0] : undefined;
112 const lead = pr.spec
113 ? { head: `Spec #${pr.spec.number} `, title: pr.spec.title }
114 : only
115 ? { head: `${s.ticket} #${only.number} · `, title: subjects.get(only.number) ?? only.title }
116 : null;
117 let meta: CardLine;
118 if (lead) {
119 const tail = ` · ${branches}`;
120 const room = Math.max(8, inner - width(lead.head) - width(tail));
121 meta = line([{ text: `${lead.head}${shortTitle(lead.title, room)}${tail}`, color: 'magenta' }]);
122 } else if (pr.tickets.length >= 2) {
123 meta = line([{ text: branches, color: 'magenta' }]);
124 } else {
125 meta = line([{ text: branches }]);
126 }
127
128 const { ci } = pr;
129 const ciColor = ci.total === 0 ? undefined : ci.fail > 0 ? 'red' : ci.pending > 0 ? 'yellow' : 'green';
130 const chip = stateChip(pr, s);
131 const summary = line([
132 { text: ciText(ci, s), color: ciColor },
133 { text: ' · ' },
134 { text: chip.text, color: chip.color, bold: true },
135 ...(pr.tickets.length === 0 ? [{ text: ` · ${s.noTickets}` }] : []),
136 ]);
137
138 return {
139 lines: [...title, meta, summary, ...sortTickets(pr.tickets).map(t => ticketLine(t, subjects.get(t.number) ?? t.title, s, inner))],
140 footer: s.fetched(relTime(pr.fetchedAt, nowMs, s)),
141 };
142}
143
144/**
145 * What fits on the hint row after the hint text: `available` is the row's width
146 * less the hint, the ` · ` separator and a safety margin. The title takes all
147 * that is left after `PR #N ` and the state chip, cut with `…`. When under 8 cells
148 * would remain for the title, the chip goes first, then the title shrinks.
149 */
150export function hintLayout(pr: PrData, available: number, s: Strings): { title: string; hasSummary: boolean } {
151 const head = width(`PR #${pr.number} `);
152 const summary = width(` · ${stateChip(pr, s).text}`);
153 if (available - head - summary >= 8) {
154 return { title: truncate(pr.title, available - head - summary), hasSummary: true };
155 }
156 return { title: truncate(pr.title, Math.max(8, available - head)), hasSummary: false };
157}
158
159const MODES: ReadonlyArray<readonly [string, string]> = [
160 ['bypass permissions on', 'red'],
161 ['accept edits on', 'magenta'],
162 ['plan mode on', 'cyan'],
163 ['auto mode on', 'yellow'],
164];
165
166/**
167 * The engine's hint without its `· ← N agent(s)` pill. The engine shows that pill only while the
168 * prompt is empty, so keeping it makes the row grow and shrink as you type.
169 */
170export function withoutAgents(hint: string): string {
171 return hint.replace(/\s*·\s*←\s*\d+\s+agents?\b/g, '').trimEnd();
172}
173
174/**
175 * Splits the engine's hint string into runs: a leading `▸▸ ` / `⏵⏵ ` glyph and
176 * a known mode phrase take the mode colour as the engine draws it; the rest is dim.
177 */
178export function hintSpans(hint: string): CardPart[] {
179 const m = /^((?:▸▸|⏵⏵)\s*)?(.*)$/s.exec(hint);
180 const glyph = m?.[1] ?? '';
181 const body = m?.[2] ?? hint;
182 for (const [phrase, color] of MODES) {
183 if (body.startsWith(phrase)) {
184 const rest = body.slice(phrase.length);
185 return [{ text: glyph + phrase, color }, ...(rest ? [{ text: rest, dim: true }] : [])];
186 }
187 }
188 return [{ text: hint, dim: true }];
189}
190hooks/graphql.ts 93 lines1// The GraphQL side of one fetch: query text and `gh api graphql` arguments, plus the unwrapping of its answers into the shape parse.ts reads. Pure; no engine calls.
2import type { PrData, PrTicket } from '../types';
3import { isSpecIssue, parseJson, parseTicket } from './parse';
4
5type Json = Record<string, unknown>;
6
7// The one request per fetch: the current branch's PRs (open or merged, latest first) with their CI, commit headlines
8// and closing issues.
9// `gh` fills {owner}, {repo} and {branch} from the session directory's repository and checked-out branch.
10export const REPO_ARGS = ['api', 'graphql', '-F', 'owner={owner}', '-F', 'name={repo}'];
11export const PR_ARGS = [...REPO_ARGS, '-F', 'branch={branch}'];
12const ISSUE_FIELDS = 'number title state body labels(first:10){nodes{name}}';
13export const PR_QUERY =
14 'query($owner:String!,$name:String!,$branch:String!){repository(owner:$owner,name:$name){pullRequests(headRefName:$branch,states:[OPEN,MERGED],first:10,orderBy:{field:UPDATED_AT,direction:DESC}){nodes{' +
15 'number title state isDraft isCrossRepository baseRefName headRefName headRefOid url body ' +
16 'commits(first:100){nodes{commit{messageHeadline}}} ' +
17 'statusCheckRollup{contexts(first:100){nodes{... on CheckRun{status conclusion} ... on StatusContext{state}}}} ' +
18 `closingIssuesReferences(first:50){nodes{${ISSUE_FIELDS}}}}}}}`;
19
20/** The second request, only when GitHub links no closing issue but the PR body says `Closes #N`: those issues by number. */
21export function issuesQuery(nums: readonly number[]): string {
22 return `query($owner:String!,$name:String!){repository(owner:$owner,name:$name){${nums.map(n => `i${n}:issue(number:${n}){${ISSUE_FIELDS}}`).join(' ')}}}`;
23}
24
25const nodes = (v: unknown): Json[] => {
26 const n = v !== null && typeof v === 'object' ? (v as Json).nodes : null;
27 return Array.isArray(n) ? (n as Json[]) : [];
28};
29const flatIssue = (i: Json): Json => ({ ...i, labels: nodes(i.labels) });
30
31/** The unwrapped PR node; `closingIssuesReferences` is already a plain array. */
32export type PrJson = Json & { closingIssuesReferences: Json[] };
33/** ok:false = no usable answer (bad JSON, GraphQL `errors`, no repository): the caller keeps what it had. ok:true with pr:null = GitHub says there is no PR of ours. */
34export type PrAnswer = { ok: false } | { ok: true; pr: PrJson | null };
35
36/**
37 * Unwraps the GraphQL answer into the shape the parsers read (what `gh pr view --json` gave):
38 * commits, statusCheckRollup, closingIssuesReferences and labels become plain arrays.
39 * The PR is the first OPEN one whose head lives in this repository (a fork's PR of the same branch name is skipped),
40 * else the first (latest) MERGED one; a CLOSED PR is never shown. Whether a MERGED one is still the branch's is
41 * decided against local git (`isCurrentPr`).
42 */
43export function parseGraphql(text: string): PrAnswer {
44 const root = parseJson(text) as { data?: { repository?: { pullRequests?: unknown } | null }; errors?: unknown } | null;
45 const repo = root?.data?.repository;
46 if (!root || root.errors || !repo) return { ok: false };
47 const ours = nodes(repo.pullRequests).filter(n => n.isCrossRepository !== true);
48 const pr = ours.find(n => n.state === 'OPEN') ?? ours.find(n => n.state === 'MERGED');
49 if (!pr) return { ok: true, pr: null };
50 const rollup = pr.statusCheckRollup as Json | null | undefined;
51 return {
52 ok: true,
53 pr: {
54 ...pr,
55 commits: nodes(pr.commits).map(n => n.commit),
56 statusCheckRollup: nodes(rollup?.contexts),
57 closingIssuesReferences: nodes(pr.closingIssuesReferences).map(flatIssue),
58 },
59 };
60}
61
62/** The commit `refs/heads/<branch>` points at in a `git for-each-ref` snapshot (`<refname> <sha>` lines); null when absent. */
63export function branchHead(refs: string | null, branch: string): string | null {
64 const line = (refs ?? '').split('\n').find(l => l.startsWith(`refs/heads/${branch} `));
65 return line ? line.slice(`refs/heads/${branch} `.length).trim() || null : null;
66}
67
68/**
69 * A merged PR belongs to the branch only while the branch still sits on the commit that was merged
70 * (`headRefOid`). Once the branch moves on (a long-lived `dev` after a `dev → main` release, new work
71 * on a reused branch) that PR is history, not this branch's PR. An open PR always counts.
72 */
73export function isCurrentPr(pr: Json, refs: string | null, branch: string): boolean {
74 if (pr.state !== 'MERGED') return true;
75 const head = branchHead(refs, branch);
76 return head !== null && head === pr.headRefOid;
77}
78
79/** The issues of an `issuesQuery` answer (a missing issue is skipped). */
80export function parseIssues(text: string): Json[] {
81 const root = parseJson(text) as { data?: { repository?: Json } } | null;
82 return Object.values(root?.data?.repository ?? {}).filter((v): v is Json => v !== null && typeof v === 'object').map(flatIssue);
83}
84
85/** Splits issues into tickets and the Spec (the one labelled `spec`). */
86export function splitIssues(issues: readonly Json[]): { raw: PrTicket[]; spec: PrData['spec'] } {
87 const spec = issues.find(isSpecIssue);
88 return {
89 raw: issues.filter(i => !isSpecIssue(i)).map(parseTicket),
90 spec: spec ? { number: Number(spec.number), title: String(spec.title ?? '') } : null,
91 };
92}
93hooks/parse.ts 289 lines1// Pure parsers and text helpers for gh JSON, acceptance tables, git ticket status and cell-width layout; no engine calls.
2import type { PrCi, PrData, PrProgress, PrTicket, TicketStatus, Where } from '../types';
3import type { Strings } from './strings';
4
5const DONE = /✓|✅|done/i;
6
7const cells = (line: string): string[] =>
8 line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim());
9
10const isRow = (line: string): boolean => line.trim().startsWith('|');
11
12/**
13 * Finds the first markdown table whose header has `State` and `Rounds`
14 * columns (spec order: Item | Target/source | State | Rounds | Rewrites) and
15 * counts its data rows: done = State cell has ✓, ✅ or "done". Null when
16 * there is no such table.
17 */
18export function parseAcceptance(body: string): PrProgress | null {
19 const lines = body.split(/\r?\n/);
20 for (let i = 0; i < lines.length; i++) {
21 const line = lines[i] ?? '';
22 if (!isRow(line)) continue;
23 const head = cells(line).map(c => c.toLowerCase());
24 const state = head.indexOf('state');
25 const rounds = head.indexOf('rounds');
26 if (state < 0 || rounds < 0) continue;
27
28 let done = 0;
29 let total = 0;
30 let maxRounds = 0;
31 let j = i + 1;
32 // Skip the |---|---| separator.
33 if (/^\s*\|?[\s:|-]+\|?\s*$/.test(lines[j] ?? '') && (lines[j] ?? '').includes('-')) j++;
34 for (; j < lines.length && isRow(lines[j] ?? ''); j++) {
35 const row = cells(lines[j] ?? '');
36 total++;
37 if (DONE.test(row[state] ?? '')) done++;
38 const n = parseInt(row[rounds] ?? '', 10);
39 if (Number.isFinite(n) && n > maxRounds) maxRounds = n;
40 }
41 return { done, total, maxRounds };
42 }
43 return null;
44}
45
46type Check = { status?: string; conclusion?: string; state?: string };
47
48const OK = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED']);
49const FAIL = new Set(['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE']);
50
51/** Folds statusCheckRollup (CheckRun and StatusContext entries) into counts. */
52export function parseCi(rollup: unknown): PrCi {
53 const ci: PrCi = { ok: 0, fail: 0, pending: 0, total: 0 };
54 if (!Array.isArray(rollup)) return ci;
55 for (const c of rollup as Check[]) {
56 // A CheckRun is final only when COMPLETED; a StatusContext carries `state`.
57 const verdict = (c.state ?? (c.status === 'COMPLETED' ? c.conclusion : '') ?? '').toUpperCase();
58 ci.total++;
59 if (OK.has(verdict)) ci.ok++;
60 else if (FAIL.has(verdict)) ci.fail++;
61 else ci.pending++;
62 }
63 return ci;
64}
65
66type Json = Record<string, unknown>;
67const str = (v: unknown): string => (typeof v === 'string' ? v : '');
68const num = (v: unknown): number => (typeof v === 'number' ? v : 0);
69
70export function parseJson(text: string): Json | null {
71 try {
72 const v: unknown = JSON.parse(text);
73 return v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Json) : null;
74 } catch {
75 return null;
76 }
77}
78
79// GitHub closing keywords followed by a same-repo `#N` (`owner/repo#N` has no space before `#`, so it never matches).
80const CLOSES = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?):?[ \t]+#(\d+)\b/gi;
81
82/** Ticket numbers from `Closes #N` / `Fixes #N` / `Resolves #N` lines of a PR body: de-duplicated, in order. */
83export function bodyClosingNumbers(body: string): number[] {
84 const seen = new Set<number>();
85 for (const m of body.matchAll(CLOSES)) seen.add(Number(m[1]));
86 return [...seen].filter(n => n > 0);
87}
88
89/**
90 * Issue numbers a PR closes: `closingIssuesReferences`, else the `Closes #N`
91 * lines of its body (a PR into a non-default branch has no references).
92 */
93export function closingNumbers(pr: Json): number[] {
94 const refs = Array.isArray(pr.closingIssuesReferences) ? (pr.closingIssuesReferences as Json[]) : [];
95 const nums = refs.map(r => num(r.number)).filter(n => n > 0);
96 return nums.length > 0 ? nums : bodyClosingNumbers(str(pr.body));
97}
98
99/** The PR head's branch name and its commits' headlines, as the status rules read them. */
100export function prHead(pr: Json): { headRef: string; headlines: string[] } {
101 const commits = Array.isArray(pr.commits) ? (pr.commits as Json[]) : [];
102 return { headRef: typeof pr.headRefName === 'string' ? pr.headRefName : '', headlines: commits.map(c => String(c.messageHeadline ?? '')) };
103}
104
105export function parseTicket(issue: Json): PrTicket {
106 return {
107 number: num(issue.number),
108 title: str(issue.title),
109 state: str(issue.state),
110 progress: parseAcceptance(str(issue.body)),
111 // Placeholder; the caller sets it from local git once branches are read.
112 status: 'todo',
113 branch: null,
114 ahead: 0,
115 };
116}
117
118export function parsePr(pr: Json, where: Where, tickets: PrTicket[], spec: PrData['spec'] = null, fetchedAt = 0): PrData {
119 return {
120 where,
121 number: num(pr.number),
122 title: str(pr.title),
123 state: str(pr.state),
124 isDraft: pr.isDraft === true,
125 base: str(pr.baseRefName),
126 head: str(pr.headRefName),
127 url: str(pr.url),
128 fetchedAt,
129 ci: parseCi(pr.statusCheckRollup),
130 tickets,
131 spec,
132 };
133}
134
135/** Display width in terminal cells: wide (CJK, full-width) glyphs count 2. */
136const cellWidth = (ch: string): number => {
137 const c = ch.codePointAt(0) ?? 0;
138 return (c >= 0x1100 && c <= 0x115f) || (c >= 0x2e80 && c <= 0xa4cf) ||
139 (c >= 0xac00 && c <= 0xd7a3) || (c >= 0xf900 && c <= 0xfaff) ||
140 (c >= 0xfe30 && c <= 0xfe6f) || (c >= 0xff00 && c <= 0xff60) ||
141 (c >= 0xffe0 && c <= 0xffe6) || (c >= 0x1f300 && c <= 0x1faff)
142 ? 2
143 : 1;
144};
145
146/** Display width of a string in terminal cells. */
147export function width(s: string): number {
148 let w = 0;
149 for (const ch of s) w += cellWidth(ch);
150 return w;
151}
152
153/** Cuts `s` to at most `max` cells, ending in `…` when cut. */
154export function truncate(s: string, max: number): string {
155 if (width(s) <= max) return s;
156 let out = '';
157 let w = 0;
158 for (const ch of s) {
159 const cw = cellWidth(ch);
160 if (w + cw > max - 1) break;
161 out += ch;
162 w += cw;
163 }
164 return out + '…';
165}
166
167/**
168 * Breaks `s` into at most `maxRows` rows of at most `max` cells each; what
169 * does not fit in the last row is cut with `…`.
170 */
171export function wrapCells(s: string, max: number, maxRows: number): string[] {
172 const rows: string[] = [];
173 let rest = s;
174 while (rest !== '' && rows.length < maxRows) {
175 if (rows.length === maxRows - 1) {
176 rows.push(truncate(rest, max));
177 break;
178 }
179 let row = '';
180 let w = 0;
181 for (const ch of rest) {
182 const cw = cellWidth(ch);
183 if (w + cw > max) break;
184 row += ch;
185 w += cw;
186 }
187 rows.push(row);
188 rest = rest.slice(row.length);
189 }
190 return rows;
191}
192
193/** Two places are the same when repository root and branch agree; the directory inside the repo does not matter. */
194export const sameWhere = (a: Where | null, b: Where | null): boolean => a !== null && b !== null && a.root === b.root && a.branch === b.branch;
195
196/** Whether `cwd` is the repository `root` or a folder below it (a pure prefix test, no git). */
197export const isInside = (cwd: string, root: string): boolean => cwd === root || cwd.startsWith(root.endsWith('/') ? root : `${root}/`);
198
199/** The ticket branch the card shows: the one furthest ahead; among equals a local one before its origin/ twin. Null when none. */
200export function pickShown(found: ReadonlyArray<{ b: string; n: number }>): string | null {
201 const sorted = [...found].sort((x, y) => y.n - x.n || Number(x.b.startsWith('origin/')) - Number(y.b.startsWith('origin/')));
202 return sorted[0]?.b ?? null;
203}
204
205/** A time (ms) relative to `nowMs`, in the UI language; a non-finite time reads as unknown. */
206export function relTime(at: number, nowMs: number, t: Strings): string {
207 if (!Number.isFinite(at)) return t.rel.unknown;
208 const s = Math.max(0, Math.round((nowMs - at) / 1000));
209 if (s < 60) return t.rel.now;
210 if (s < 3600) return t.rel.min(Math.floor(s / 60));
211 if (s < 86400) return t.rel.hour(Math.floor(s / 3600));
212 return t.rel.day(Math.floor(s / 86400));
213}
214
215/** Branches that belong to ticket `n`: `(^|/)<n>-`, never `worktree-*`. */
216export function ticketBranches(n: number, names: readonly string[]): string[] {
217 const re = new RegExp(`(^|/)${n}-`);
218 return names.filter(b => !b.startsWith('worktree-') && re.test(b));
219}
220
221/**
222 * PR commit headlines -> tickets they merged: `<type>(#N): ...`, the CJK form `<type>(<scope>):<text> #N`
223 * (the number ends the headline), or a merge headline naming a `/N-` branch. Body mentions never count.
224 */
225export function mergedByCommits(headlines: readonly string[], numbers: readonly number[]): Set<number> {
226 const wanted = new Set(numbers);
227 const out = new Set<number>();
228 for (const h of headlines) {
229 const m = /^[a-z]+\(#(\d+)\):/.exec(h) ?? /^[a-z]+([^)]+):.*\s#(\d+)$/.exec(h);
230 if (m && wanted.has(Number(m[1]))) out.add(Number(m[1]));
231 if (!h.startsWith('Merge')) continue;
232 for (const b of h.matchAll(/\/(\d+)-/g)) if (wanted.has(Number(b[1]))) out.add(Number(b[1]));
233 }
234 return out;
235}
236
237/** Local and git facts about one ticket. */
238export type TicketFacts = {
239 /** A ticket branch is the PR head itself (single-ticket delivery). */
240 isHead: boolean;
241 /** One `rev-list --count <head>..<branch>` per local ticket branch. */
242 counts: readonly number[];
243 /** A PR commit headline names the ticket (see mergedByCommits). */
244 isMerged: boolean;
245}
246
247/**
248 * Ticket status, by priority: done (issue CLOSED, or an all-✓ acceptance table)
249 * > doing (a ticket branch is the head, or a branch is ahead of it)
250 * > merged (a PR commit headline names it) > todo.
251 * Branch existence alone only ever means "doing".
252 */
253export function ticketStatus(state: string, progress: PrProgress | null, facts: TicketFacts): TicketStatus {
254 if (state === 'CLOSED') return 'done';
255 if (progress && progress.total > 0 && progress.done === progress.total) return 'done';
256 if (facts.isHead || facts.counts.some(c => c > 0)) return 'doing';
257 return facts.isMerged ? 'merged' : 'todo';
258}
259
260export type PrState = 'draft' | 'ready' | 'merged';
261
262/** The PR's own state, the one thing the hint row and the card summary show: Draft → Ready → Merged. */
263export function prState(pr: Pick<PrData, 'state' | 'isDraft'>): PrState {
264 if (pr.state === 'MERGED') return 'merged';
265 return pr.isDraft ? 'draft' : 'ready';
266}
267
268const ORDER: Record<TicketStatus, number> = { doing: 0, todo: 1, merged: 2, done: 3 };
269
270/** Attention first: in progress, not started, merged, done (stable within a status). */
271export function sortTickets(tickets: readonly PrTicket[]): PrTicket[] {
272 return tickets
273 .map((t, i) => [t, i] as const)
274 .sort((a, b) => ORDER[a[0].status] - ORDER[b[0].status] || a[1] - b[1])
275 .map(([t]) => t);
276}
277
278/** First segment of a title (cut at `——`, ` — ` or `(`), at most `max` cells. */
279export function shortTitle(title: string, max: number): string {
280 const cut = title.split(/——| — |(/)[0] ?? title;
281 return truncate(cut.trim(), max);
282}
283
284/** True when the issue JSON carries the label `spec`: that issue is the Spec, not a ticket. */
285export function isSpecIssue(issue: Json): boolean {
286 const labels = Array.isArray(issue.labels) ? (issue.labels as Json[]) : [];
287 return labels.some(l => str(l.name) === 'spec');
288}
289hooks/strings.ts 76 lines1// UI strings of pr-hint, English and Chinese; English unless the session's language says Chinese. Called by card.ts, parse.ts and register.tsx.
2// Pure; tested in strings.test.ts.
3
4export type Lang = 'en' | 'zh';
5
6const plural = (n: number, one: string) => (n === 1 ? `1 ${one}` : `${n} ${one}s`);
7
8// The PR's own state, the same English words in both languages (owner's ruling): Draft → Ready → Merged.
9const PR_STATE: Record<'draft' | 'ready' | 'merged', string> = { draft: 'Draft', ready: 'Ready', merged: 'Merged' };
10
11const EN = {
12 prState: PR_STATE,
13 status: { todo: 'not started', doing: 'in progress', merged: 'merged', done: 'accepted' },
14 ticket: 'Ticket',
15 integration: 'integration branch',
16 behind: (n: number) => `${plural(n, 'commit')} behind`,
17 noCi: 'no CI',
18 noTickets: 'no linked tickets',
19 openPr: 'Open PR',
20 fetched: (rel: string) => ` · fetched ${rel}`,
21 refresh: '↻ refresh',
22 refreshing: 'refreshing…',
23 tickets: 'tickets',
24 upToDate: (n: number) => `PR #${n} is up to date`,
25 changed: (n: number, what: string) => `PR #${n} updated: ${what}`,
26 gone: 'No PR on this branch',
27 failed: 'Fetch failed, try again later',
28 rel: {
29 unknown: 'unknown',
30 now: 'just now',
31 min: (n: number) => `${n} min ago`,
32 hour: (n: number) => `${n} h ago`,
33 day: (n: number) => `${n} d ago`,
34 },
35};
36
37export type Strings = typeof EN;
38
39const ZH: Strings = {
40 prState: PR_STATE,
41 status: { todo: '未开始', doing: '进行中', merged: '已合入', done: '已验收' },
42 ticket: 'Ticket',
43 integration: '集成分支',
44 behind: n => `还差 ${n} 个提交`,
45 noCi: 'CI 无',
46 noTickets: 'Tickets 无关联',
47 openPr: '打开 PR',
48 fetched: rel => ` · 拉取于 ${rel}`,
49 refresh: '↻ 刷新',
50 refreshing: '刷新中…',
51 tickets: 'Tickets',
52 upToDate: n => `PR #${n} 已是最新`,
53 changed: (n, what) => `PR #${n} 已更新:${what}`,
54 gone: '当前分支已没有 PR',
55 failed: '拉取失败,稍后再试',
56 rel: {
57 unknown: '未知',
58 now: '刚刚',
59 min: n => `${n} 分钟前`,
60 hour: n => `${n} 小时前`,
61 day: n => `${n} 天前`,
62 },
63};
64
65export const strings = (lang: Lang): Strings => (lang === 'zh' ? ZH : EN);
66
67/**
68 * The UI language: the config option when set, else Claude Code's `language`
69 * setting, else the locale; Chinese only when one of them says so.
70 */
71export function pickLang(option: unknown, setting: unknown, locale: string | undefined): Lang {
72 if (option === 'en' || option === 'zh') return option;
73 const said = `${typeof setting === 'string' ? setting : ''} ${locale ?? ''}`.toLowerCase();
74 return /chinese|中文|^zh|\szh/.test(said.trim()) || said.includes('zh_') || said.includes('zh-') ? 'zh' : 'en';
75}
76types/index.d.ts 56 lines1// State contract of pr-hint: the PR snapshot the hint line and hover card draw from.
2
3export type PrCi = { ok: number; fail: number; pending: number; total: number };
4
5/** Acceptance-table progress of one ticket body; null when the body has no table. */
6export type PrProgress = { done: number; total: number; maxRounds: number };
7
8/** todo not started · doing in progress · merged into the PR head · done (issue closed or table all ✓) */
9export type TicketStatus = 'todo' | 'doing' | 'merged' | 'done';
10
11export type PrTicket = {
12 number: number;
13 title: string;
14 state: string;
15 progress: PrProgress | null;
16 status: TicketStatus;
17 /** The ticket's branch shown on the card (the one furthest ahead, else the first local one). */
18 branch: string | null;
19 /** Commits that branch has beyond the PR head. */
20 ahead: number;
21};
22
23/** Where the session stands: its directory, the repository root above it, and the checked-out branch (`HEAD` when detached). */
24export type Where = { cwd: string; root: string; branch: string };
25
26export type PrData = {
27 /** Where the PR was read; drawn only while the session is still in that repository root (any subfolder) on that branch. */
28 where: Where;
29 number: number;
30 title: string;
31 state: string;
32 isDraft: boolean;
33 base: string;
34 head: string;
35 url: string;
36 /** When pr-hint last read this PR from gh (ms, the engine clock); bumped by each gh fetch, kept by the git recompute. */
37 fetchedAt: number;
38 ci: PrCi;
39 tickets: PrTicket[];
40 /** The closing issue labelled `spec`; kept out of tickets and every count. */
41 spec: { number: number; title: string } | null;
42};
43
44declare module 'claude-code' {
45 interface PluginState {
46 'pr-hint': {
47 /** Kept under a shape tag (see register.tsx); bump the tag when PrData changes. */
48 pr: Shaped<PrData | null>;
49 /** The card is pinned open by a press on the hint row's pin. */
50 pinned: boolean;
51 /** A manual ↻ refresh is running. */
52 refreshing: boolean;
53 };
54 }
55}
56