SLOPSHOPPER

pr-hint

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…

newbandspinnertoastprocesstimer
v0.0.1MITupdated 2026-10-08nokiy/claude-code-mods/plugins/pr-hint
A shopper browsing a rack in a slop shop
README

pr-hint

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.

What it does

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

Spec and tickets

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.

How a ticket's status is derived

From the local git branches plus the PR's commit headlines (no git fetch, no model calls), first match wins:

StatusRule
accepted (green dot)the issue is closed, or its acceptance table (a table whose header has State and Rounds) is all ✓
in progressa ticket branch (*/<N>-* or <N>-*) is the PR head, or is ahead of it
mergeda 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 startednone 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.

Refresh

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.

EveryReadsRecomputes ticket statuses when
20 slocal git only, no network: the repository root, the checked-out branch and git for-each-refthe 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 minone 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 issuealways (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.

Settings

Open /config and pick pr-hint.

SettingDefaultMeaning
Languageautoauto follows Claude Code's language setting, then the locale; en or zh forces one. English is the default.

Install

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.

License

MIT

Source 6 files
hooks/register.tsx 384 lines
1// 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}
384
hooks/card.ts 190 lines
1// 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}
190
hooks/graphql.ts 93 lines
1// 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}
93
hooks/parse.ts 289 lines
1// 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}
289
hooks/strings.ts 76 lines
1// 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}
76
types/index.d.ts 56 lines
1// 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