A side pane for any repo with a plan: sections, items and what blocks the next one. Providers: AI-DLC v2, and a task ledger (WORKPLAN.md) read against its pull…

A side pane for any repo with a plan: sections, items, and what blocks the next one. Which plan it reads is a provider: AI-DLC v2 (awslabs/aidlc-workflows) and a plain task ledger (WORKPLAN.md) read against the repository's pull or merge requests (GitHub through gh, GitLab through glab, self-hosted included). AI-DLC wins when both exist.
SHOP-MVP-RESTART checked 12s ago [ ↻ Refresh ]
╭──────────────────────────────────────────────────────────╮
│ Now Inception, 5 of 8 │
│ ○ domain-design up next │
│ main: refined-mockups in review, PR #33 │
│ │
│ Next /aidlc [ Put in prompt ] │
│ Execute Domain Design │
╰──────────────────────────────────────────────────────────╯
Needs attention (2)
‖ PR #33 not merged Inception, after refined-mockups
‖ Parked at domain-design Inception, after refined-mockups
◑ Plan 45% done, 10 of 22
▸ Initialization ● 3/3 ✓
▸ Ideation ● 4/4 ✓
▾ Inception ◑ 3/8
– reverse-engineering skipped
✓ practices-discovery [#27]
✓ requirements-analysis [#30]
✓ user-stories [#32]
◐ refined-mockups [#33] in review
╭──────────────────────────────────────────────────────╮
│ ‖ PR #33 not merged │
│ domain-design can't start on main until it lands. │
╰──────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────╮
│ ‖ Parked at domain-design │
│ Waiting on PR #33 to merge; parked 2026-10-11 06:32. │
│ Resume with /aidlc once it lands. │
╰──────────────────────────────────────────────────────╯
› ○ domain-design up next
○ units-generation
○ contract-design
○ delivery-planning
▸ Construction ○ 0/7
▸ Operation skipped
Top to bottom the pane answers, in order: where am I (the header), what am I on and what is next (the Now card, with the Next command and a Put in prompt button), what blocks (Needs attention), and how far along the plan is (Plan, with its sections). The pane sizes to its content, so it follows the width the surface gives it, in three steps:
| Columns | Layout | |
|---|---|---|
| under 48 | narrow | one column. The header is the title and a lone ↻; the badge and the "checked" stamp move to a second row. Next stacks its button under the command. Attention rows are two lines (title, then where). Items show their first chip and a dim +N, and no status word except local only, reopened, stale and a PR's review state (cut, down to 8 columns, before the title is). Done-folding after 6 items, at most 8 open items; no status strip |
| 48 to 79 | medium | one column. ↻ Refresh, the badge and the stamp share the title's row, and the title is what gets cut (…). Every chip and the status words show. Done-folding after 10 items, at most 12 open items; a collapsed section of 10+ items gets a status strip (64 px wide, or 10 cells on the terminal) |
| 80 and up | wide | with sections, two columns: the Now card, actions and attention on the left (40%), the plan on the right. The left column lays out for its own width (40% less the gap), so it uses the narrow layouts (stacked Next, two-line Attention rows) until the pane is 125 columns wide. Done-folding after 16 items, at most 20 open items; the strip is 96 px wide |
Glyphs. Items: ✓ done (for AI-DLC: merged on the default branch), ◐ in progress, partial, in review (an open PR) or pushed and not merged, ◌ local only (this worktree, no PR), ○ planned, ✕ failed, – skipped, ↻ revising, ? awaiting a person, ↺ reopened (was done, being redone), an amber ✓ stale (done, but after a reopened item); › marks the current one. An item whose PR is open says where the review stands in its status word (see Pull request review): in draft, awaiting dev-b, changes requested, approved, approved with comments, no reviewer. Attention and gates: ⊘ blocks, ‖ parked or only a warning (also changes requested, and your PR not sent for review), ? waits on an answer or an approval, or on your review, ✓ your PR is approved (with or without comments) and waits on you to merge, ! the check itself failed, ≠ the plan and the repo disagree. Several stages sharing one PR carry a fold gutter on the right: ┐, │ between, ┘. Rings: ○ ◔ ◑ ◕ ● for 0, 1-37, 38-62, 63-99 and 100 percent.
Done-folding. A section with more items than the cap for its width (6, 10, 16) hides its done items behind one row, ✓ 6 done, show, at the top of the list. It never hides the current item, any item with a fold chip, or one with a gate after it. Pressing the row shows them all (and a Hide done row, in the same place at the top, puts them back). If the fold row's text is empty, nothing is hidden.
The open-items cap. A big ledger (one section may hold about a hundred open items) is not drawn as a wall. After done-folding, an expanded section shows at most 8 items on a narrow pane, 12 on a medium one and 20 on a wide one (the current item stays, in its place), then a row Show 32 more that shows the rest; Show fewer at the foot puts them back. The two are independent, so both rows can appear (done on top, more at the bottom). A gate after an item that is left out is not drawn in the list, but it stays under Needs attention. Fold gutters are computed over the items drawn. An empty label hides nothing.
The strip. A collapsed section of 10 or more items shows how its items stand, next to the ring: done in green, in progress and partial in the accent, failed in red, the rest in the track tone (a 6 px bar of rounded segments on the desktop, a 10-cell █▓▒░ bar on the terminal; any group that exists keeps at least one cell).
Small things. Next's reason and the Attention titles (drift labels included) wrap, since their tails carry the meaning; only the where is cut. The header's subtitle is a dim line under the title row (under row 2 on a narrow pane) that wraps, and is left out when the Now card already names the current item, because the card's place says the same. The plan heading's summary wraps beside its ring too, so a long label breaks onto a second line instead of being cut. On the terminal the big ring is the glyph alone (the summary carries the percent). A section at 100% or built has no strip, and every section's count column has one width (the widest, such as 18/18 ✓) so the rings line up. A chip of 8 characters or fewer never shrinks; a longer one is cut first, down to 6 columns, then the title. Proposed sections (a ledger's suggested products) sit under one dim "Proposed" divider, each as a dim card without a tag of its own. A section with no items and nothing counted has no ring and a dim title. A linked title is a Link around a truncating Text, in a clipping, shrinking wrapper.
Freshness. renderPane takes an optional clock ({ now, checkedAt }) as its last argument. With one the stamp reads checked 12s ago; without it, read 12:03, the time the plan was read. The clock and the error line below come from the refresh engine (src/refresh.ts), which fills freshness on the model; hooks/register.tsx passes the clock. When the latest re-read or remote check failed, a red line under the header says so, the same text is the first Attention row, and the panel keeps the last good model.
On the desktop, vscode and mobile surfaces the rings are real donuts (an Svg): 40 px with the percent drawn inside for the plan, 16 px per section, and each Attention row a bordered card in its tone. The terminal has no Svg, so it draws the glyph rings and plain Attention lines above.
It is a mod: a TypeScript function-hooks module (hooks/register.tsx) the engine draws on the desktop app's and the terminal's pane surfaces.
/plugin install project-panel --marketplace maksymskuibida/mskuibida-ai-tools
The command is /panel. Where a provider finds a plan the desktop app opens the pane on session start (panel: auto); anywhere, run /panel. Where none does, the mod stays silent — no pane, no command.
It is /panel, not /aidlc: every AI-DLC workspace already ships its own /aidlc skill (the workflow's entry point), and the two would collide. There are no aliases.
Function hooks without CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1: unverified. claude plugin test and validate do not prove it for a live session. The only evidence is that the name appears in the claude 2.1.296 binary solely in lists of environment-variable names, never as a read that gates anything.
A provider turns one kind of plan into the same neutral PanelModel (types/index.d.ts); the renderer draws any model the same way. src/detect.ts tries the providers in order and the first hit wins; a provider that throws counts as a miss.
| Provider | Reads | Status |
|---|---|---|
aidlc | an AI-DLC v2 workspace (aidlc/spaces/) | shipped |
ledger | a task ledger (WORKPLAN.md and kin) read against its pull requests | shipped (0.4.0) |
A provider is { id, detect(ctx), load(ctx, detection), act?(ctx, detection, actionId) }. ctx hands it file reads (fsx), the host (run a command, complete with a model, mcp to call one of the session's MCP servers, write a file, a persistent store; anything that would spawn goes through it), the session and the options.
The model is sections (with an optional ring), an overall percent with an optional overall.label (display text that replaces the plan heading's own "45% done, 10 of 22", e.g. "74% of effort · 189/287 tasks"), items (status, chips, links), gates between items (parked, awaiting approval, later PR-not-merged...), drift flags, a Next command, and optional action buttons. Text in it is display text the provider resolved through src/strings/en.ts; an empty string hides that element.
For a repository that keeps its plan in a task ledger: a WORKPLAN.md-style file of tasks with ids. It shows how far the repository has got, task by task, by reading the ledger against the pull requests.
In a git repository, the first of these that parses as a ledger (at least 3 items with ids, or at least 5 checkboxes), looking only at git-tracked files:
task-ledger: in the front matter of docs/DELIVERY.md;WORKPLAN.md, ROADMAP.md, PLAN.md, TODO.md (any case), in that order;docs/;docs/**/plan*.md.Ledgers may be tables whose first cell starts with a bold id (| T04 | Act | T01a, T03 | M |), headings (#### T04 — Act, a Done first line marks it done), or checkboxes (- [x] API-3 Rate limiting). Phases are the ## headings. A fenced "order of play" (done T02 …, open T16 …, then T04 …) sets the status of items the ledger does not otherwise declare.
| Evidence | Status |
|---|---|
| a merged PR for the task | done |
| an open PR | in progress, with a #N chip |
the PR carries the qa:failed label | failed |
only sub-slice PRs (T06a, T06b for a task T06) | partial if one merged, in progress if only open ones |
| nothing, or only closed PRs | what the ledger declares (Done, [x], (implemented), an order-of-play line), else planned |
How a PR is matched to a task: the id must appear in the PR title or branch with word boundaries (T04 never matches T04a; T1 never matches T10), in any case (qa/t04a-preprod). The strongest tier decides: a delivery branch (feat/T04-…), then a follow-up branch (fix/, hotfix/, qa/), then a loose mention, which counts only when the PR title names the id as its subject (feat(T04):, T04 —, [T04], a trailing (T04)); any other mention is just a chip. Fix PRs on a delivered task stay follow-up chips. A PR naming a longer id (fix-T04a-backdate-picker) belongs to that id only, not to T04a. Reverts never count; they show as chips. Progress is weighted by size: XS 0.5, S 1, M 2, L 3, XL 5 (unknown 1); done counts 1, partial 0.5.
The header shows idle when nothing is in flight, stage when the session's branch names a task, and inferred otherwise. Next is the first planned task with no open PR whose ledger dependencies are done, unless the session's task has an open PR that asks something of you (see Pull request review). The Attention block lists the rows tied to you first, then disagreements: the ledger says not done but a PR merged, the ledger says done but a PR is still open, qa:failed. A task in progress shows its PR's review state as its status word, and a Slack thread chip when the PR has one.
All through the host, no shell, from the repository root: git rev-parse --show-toplevel, git ls-files -s -z, git show HEAD:<path>, git fetch --quiet origin <default branch>, git show origin/<default branch>:<path>, git ls-tree -r -z origin/<default branch>, git rev-parse --abbrev-ref HEAD, git symbolic-ref, git remote get-url origin, and the forge's reads (see Forges): on GitHub gh repo view and gh pr list --state all --limit 1000, on GitLab glab api projects/<path> and glab mr list --all, paged. The repository facts, the request list and the branch lookup run together. On GitHub the list also carries each pull request's review (isDraft,reviewDecision,reviewRequests,latestReviews,body,author; one call, the review rides on it). When a pull request is open: gh api user --hostname <host> (once an hour per repository), gh api user/teams?per_page=100 --hostname <host> (only when an open pull request asks a team; once an hour) and git show HEAD:.claude/settings.json (when tracked). The ledger is read from the default branch (after a fetch) so the pane shows what is merged; if the fetch fails but the branch is there the header says the ledger may be behind origin, and if the branch cannot be read it falls back to the committed local copy and says so. When the pull or merge requests cannot be read (no CLI, not logged in, offline, no origin remote) progress comes from the ledger alone and the header says why. It never writes to the repository or the forge: git fetch only updates remote-tracking refs, and every gh/glab call is a read.
The origin remote (git remote get-url origin) says where the requests live. src/forge/ parses it (scp-like git@host:group/sub/repo.git, ssh:// with a port, https:// with or without credentials and a port, git://; nested groups of any depth; credentials are never kept, even a password containing @; an ssh port is not the web port; a port above 65535 is no remote; ssh.github.com is github.com; an ssh-config alias such as Host work is not resolved, so set the forge option for such a remote) and picks the forge in this order, with no list of known hosts:
forge option is none: no forge, no requests, no complaint;forge option is github or gitlab: that forge for the origin's host, no probe;github.com: GitHub;glab auth status --hostname <host> succeeds: GitLab;gh auth status --hostname <host> succeeds: GitHub Enterprise;A found forge's kind is remembered per repository and origin until the mod reloads (the forge itself is rebuilt from the current host on each call); the fallback in 7 is remembered for 60 seconds only, so a host with no login does not pay the two 5-second probes on every load and poll, and logging in is picked up within a minute.
GitHub (gh) | GitLab (glab) | |
|---|---|---|
| a change is | pull request, #33 | merge request, !275 |
| repository | gh repo view --json url,defaultBranchRef | glab api --hostname <host> projects/<url-encoded path> |
| list | gh pr list --state all --limit 1000 --json ... | glab mr list -R <web address> --all -F json -P 100 -p <page>, until a short page, at most 10 pages |
| list, one state (AI-DLC) | --state open, merged or closed, and a --limit | no flag for open (glab's default), --merged or --closed |
| status (open only) | in the same list: review decision, merge state, check rollup | at most 20 open merge requests, five calls at a time: glab mr view <n> -R ... -F json (pipeline) and .../merge_requests/<n>/approvals; a failed lookup leaves its field empty without probing |
| files of a change | gh pr view <n> --json files | .../merge_requests/<n>/diffs?per_page=100 (paged), else .../changes on a GitLab without /diffs |
| remote fingerprint | gh pr list ... --json number,state,mergedAt,headRefOid | glab mr list ... -P 30 -o updated_at -S desc |
| AI-DLC remote fingerprint | the same lists as a load | the lists plus one pipeline lookup per open merge request, no approvals (a load asks for those) |
| line links | <web>/blob/<branch>/<path>#L<n> | <web>/-/blob/<branch>/<path>#L<n> |
Every GitLab merge-request command gets -R <web address> (a full URL, which glab accepts for any host, so GITLAB_HOST is not needed); glab api gets --hostname. Chips, drift lines and links spell a change the way its forge does (!275, not #275).
When a call fails the header (and the Attention line of the live refresh) names the cause:
| Cause | Meant | Shown as |
|---|---|---|
| missing | the CLI does not start (and <cli> --version does not either) | glab missing |
| not logged in | the call failed and <cli> auth status --hostname <host> fails | glab not logged in to gitlab.example.test |
| unreachable | the call timed out, or failed with a network-looking error | could not reach gitlab.example.test |
| failed | anything else | glab failed |
| no origin | the repository has no readable origin | no origin remote |
The causes are found only after a failure, with two cheap probes (--version, auth status, 5 s each).
Phases describe delivery, not the product. A proposal asks for 3 to 8 product sections ("Claude plugin", "API", ...) from claude-sonnet-5-5, given the task ids, titles and phases, the headings of tracked ARCHITECTURE*, README* and DESIGN* docs and the top-level directory names (at most 40).
Consent. A ledger named by task-ledger: in docs/DELIVERY.md is proposed on first load: the repository declared its plan. A ledger the mod merely discovered (a WORKPLAN.md it found) sends nothing to a model until you press Propose product sections, which makes exactly one Sonnet call.
The proposal appears dimmed after the phases, tagged proposed; Pin sections accepts it. Once pinned, claude-haiku-5-5 files every task under a section, in batches of at most 120 tasks (one call per batch; if any batch fails the old result is kept), judges done / partial / planned only for tasks with no PR evidence (at most 40 per batch, 600 characters of cleaned ledger text each; PR evidence always wins), writes short titles and captions, may add planned-only sections, and notes where a task's prose and status disagree. It runs only when its inputs change: the ledger text, the set of merged PRs, the directory names, or the pinned sections (ids, titles and hints). Propose again makes one new Sonnet call; the old pin stays in effect until you pin the new proposal, and a stored result is applied only to the sections it was made for.
A failed or invalid reply is not retried for the same input for 6 hours (a changed input, or pressing Propose, Propose again or Pin, retries at once). With a previous result the header says grouping is unavailable and the last result stays; without one it says product grouping needs a model reply and shows the phases. If the PR list cannot be read (or forge is none), the ledger has no tasks, or the plugin's store cannot be read, the model layer is skipped entirely (no calls, nothing written) and the phases stand alone. The result is stored per repository (ledger:<repo url>) in the plugin's store.
Repository content is read only through git, never from the working directory: a tracked file is read with git show HEAD:<path> or git show origin/<branch>:<path>, so a symlink cannot redirect a read and uncommitted edits are not sent. Only tracked regular files count: symlinks and submodules are dropped from the lists (git ls-files -s, git ls-tree -r). Nothing is read or sent from a path with a segment equal to .git, ending in .local or containing .local., or starting or ending with .env (case-insensitive), even if someone committed it. Untracked and ignored files are never listed. Models see ledger titles, short cleaned excerpts of the ledger text for tasks without PR evidence, doc headings and directory names, nothing else.
From the session's own working tree (its branch, uncommitted changes included):
| Source | Used for |
|---|---|
aidlc/spaces/ | detection — walked up from the session root; aidlc-docs/ (v1) is ignored |
aidlc/.aidlc-sessions/<session>.binding.json | the intent this session is bound to |
aidlc/active-space, intents/active-intent | the shared cursor when the session is unbound |
intents/intents.json | the intent's display slug |
<intent>/aidlc-state.md | phases, stages, markers, current stage, parked state |
<intent>/.aidlc-engine/active-directive.json | the step on screen, when fresher than the state |
<intent>/inception/units-generation/unit-of-work-dependency.md, unit-of-work.md | the Units, their order and dependencies (the ``` `yaml units: `` block), their ID and Jira key (the index's Unit ID and Jira` columns) |
<intent>/unit-points.csv, .mcp.json at the workspace root | each Unit's story points, and the Atlassian MCP server to ask for the missing ones (see Construction by Unit) |
<intent>/audit/*.md, <intent>/construction/<unit>/ | how far each Unit has got |
.git/HEAD | the branch, for stage-vs-side-job mode |
.claude/settings.json at the repository's top | whether a pr-review plugin is on (only when a PR is open) |
And, since 0.7.0, the second truth (see Two truths below), through git and the forge layer (see Forges: gh on GitHub, glab on GitLab, self-hosted included). Nothing is written, to the repository or the forge (the one write the provider makes is unit-points.csv, below):
| Command | Used for |
|---|---|
git show origin/<default>:<intent>/aidlc-state.md | where each stage stands on the default branch |
git remote get-url origin (then, off github.com, glab auth status / gh auth status), cached per repository | which forge the origin lives on, and the forge option's override |
| open changes with their status: gh pr list --state open --limit 100 --json ...,reviewDecision,mergeStateStatus,statusCheckRollup,reviewRequests,latestReviews,body,author; glab mr list -R <web address> -F json -P 100, then per open merge request (at most 20) glab mr view <n> -F json (pipeline) and .../approvals | stage changes: state, draft, review (on GitHub the full review state, s
hooks/register.tsx 388 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Fsx, FsxEntry, ProviderContext, ProviderHost } from '../types'
5import { buildModel, fingerprintOf, runAction } from '../src/model'
6import { createRefreshEngine } from '../src/refresh'
7import type { RefreshEngine, RefreshPorts, Snapshot } from '../src/refresh'
8import { renderPane } from '../src/render'
9import { t } from '../src/strings/en'
10import type { PanelModel, PanelOptions } from '../types'
11
12const PANE = 'panel'
13
14const modelAtom = atom({ plugin: 'project-panel', key: 'model' } as const, null)
15const expandedAtom = atom({ plugin: 'project-panel', key: 'expanded' } as const, {})
16const closedAtom = atom({ plugin: 'project-panel', key: 'isClosed' } as const, false)
17
18// What a missing path looks like when `$.fs` rejects (it says ENOENT); anything else is a real failure.
19const MISSING = /ENOENT|ENOTDIR|EISDIR|no such file|not found|not a directory|is a directory/i
20
21/** Counts the `$.fs` rejections that were not a plain missing path, so a read can tell "nothing there" from "could not look". */
22type FsTally = { failures: number }
23
24function tallied<T>(tally: FsTally, fallback: T): (error: unknown) => T {
25 return error => {
26 if (!MISSING.test(error instanceof Error ? error.message : String(error))) tally.failures++
27 return fallback
28 }
29}
30
31// `$.fs` as the Fsx port, adapted here: the engine follows `$`
32// only into functions of this file, never across an import. The port answers rather than rejects;
33// `tally` remembers the rejections that were not "missing".
34function fsxOf($: EngineInterface, tally: FsTally = { failures: 0 }): Fsx {
35 return {
36 read: path => $.fs.read(path).catch(tallied<string | null>(tally, null)),
37 exists: path => $.fs.exists(path).catch(tallied(tally, false)),
38 list: path =>
39 $.fs
40 .list(path)
41 .then(entries => entries.map(({ name, kind }) => ({ name, kind })))
42 .catch(tallied<FsxEntry[]>(tally, [])),
43 mtime: path =>
44 $.fs
45 .stat(path)
46 .then(stat => stat.mtimeMs)
47 .catch(tallied<number | null>(tally, null)),
48 }
49}
50
51// `$.process`, `$.model`, `$.mcp`, `$.fs.write` and `$.store` as the ProviderHost port.
52function hostOf($: EngineInterface): ProviderHost {
53 return {
54 run: (argv, init) =>
55 $.process.run(argv, init).then(({ exitCode, stdout, stderr }) => ({ exitCode, stdout, stderr })),
56 complete: req =>
57 $.model
58 .complete(req)
59 .then(reply => (reply.isAnswered ? { isAnswered: true, text: reply.text } : { isAnswered: false, text: '' }))
60 .catch(() => ({ isAnswered: false, text: '' })),
61 mcp: (server, tool, args) =>
62 $.mcp.call(server, tool, args).then(result => ({
63 isError: result.isError,
64 text: result.content.map(block => (typeof block.text === 'string' ? block.text : '')).join('\n'),
65 })),
66 write: (path, text) => $.fs.write(path, text),
67 store: {
68 get: key => $.store.get(key),
69 set: (key, value) => $.store.set(key, value),
70 },
71 }
72}
73
74type Settings = PanelOptions & {
75 /** auto: open by itself on a desktop; command: only /panel; off: never. */
76 panel: 'auto' | 'command' | 'off'
77 /** Seconds between mtime polls; 0 = off. */
78 fsPollSeconds: number
79 /** Seconds between remote-fingerprint polls; 0 = off. */
80 prPollSeconds: number
81 /** Which forge pull or merge requests are read from. */
82 forge: 'auto' | 'github' | 'gitlab' | 'none'
83}
84
85const DEFAULT_FS_POLL = 10
86const DEFAULT_PR_POLL = 180
87const MIN_FS_POLL = 2
88const MIN_PR_POLL = 30
89
90/** A poll interval option: unset or garbled = the default, 0 = off, anything else at least `min`. */
91function pollSeconds(value: unknown, fallback: number, min: number): number {
92 if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return fallback
93 return value === 0 ? 0 : Math.max(min, value)
94}
95
96async function contextOf($: EngineInterface, settings: Settings, tally?: FsTally): Promise<ProviderContext> {
97 const [startDir, sessionId, now] = await Promise.all([$.session.root(), $.session.id(), $.clock.now()])
98 return {
99 fsx: fsxOf($, tally),
100 host: hostOf($),
101 startDir,
102 sessionId,
103 now,
104 options: {
105 stageBranchPattern: settings.stageBranchPattern,
106 foldSeparator: settings.foldSeparator,
107 forge: settings.forge,
108 reviewCommands: settings.reviewCommands,
109 slackDomain: settings.slackDomain,
110 },
111 }
112}
113
114/**
115 * detect + load. A disk that fails (not a path that is merely missing) throws: the providers turn a failed
116 * read into "nothing there", which must not be taken for "no plan".
117 */
118async function build($: EngineInterface, settings: Settings): Promise<PanelModel | null> {
119 const tally: FsTally = { failures: 0 }
120 const model = await buildModel(await contextOf($, settings, tally))
121 if (tally.failures > 0) throw new Error('the plan could not be read')
122 return model
123}
124
125// The refresh engine's ports (src/refresh.ts), adapted from `$` here: the engine follows `$`
126// only into functions of this file, and src/ stays `$`-free.
127function refreshPortsOf($: EngineInterface, settings: Settings): RefreshPorts {
128 return {
129 now: () => $.clock.now(),
130 after: (ms, fn) => $.clock.after(ms, fn),
131 every: (ms, fn) => $.clock.every(ms, fn),
132 load: () => build($, settings),
133 fingerprint: async () => fingerprintOf(await contextOf($, settings)),
134 mtime: path =>
135 $.fs
136 .stat(path)
137 .then(stat => stat.mtimeMs)
138 .catch(() => null),
139 run: (argv, init) => $.process.run(argv, init).then(({ exitCode, stdout }) => ({ exitCode, stdout })),
140 publish: async model => {
141 await update($, modelAtom, () => model)
142 },
143 invalidate: async () => {
144 $.ui.invalidate('ui.render')
145 },
146 isVisible: async () => {
147 // The pane list only holds open panes (a person's close removes it), so a closed flag is no veto here.
148 if (settings.panel === 'off') return false
149 const panes = await $.ui.panes()
150 return panes.some(pane => pane.id === PANE && pane.isPlaced)
151 },
152 }
153}
154
155/** Where the register closure keeps its refresh engine; the helpers below take it as a plain argument. */
156type EngineSlot = { engine: RefreshEngine | null }
157
158function bindEngine($: EngineInterface, settings: Settings, slot: EngineSlot, startDir: string): RefreshEngine {
159 slot.engine?.stop()
160 slot.engine = createRefreshEngine(refreshPortsOf($, settings), {
161 startDir,
162 isEnabled: settings.panel !== 'off',
163 fsPollSeconds: settings.fsPollSeconds,
164 prPollSeconds: settings.prPollSeconds,
165 })
166 return slot.engine
167}
168
169async function engineOf($: EngineInterface, settings: Settings, slot: EngineSlot): Promise<RefreshEngine> {
170 return slot.engine ?? bindEngine($, settings, slot, await $.session.root().catch(() => ''))
171}
172
173/** Opens the pane once for a desktop that attached after session start (`panel: auto`, a plan, not closed, not open). */
174async function openForDesktop($: EngineInterface, settings: Settings): Promise<void> {
175 if (settings.panel !== 'auto') return
176 if ((await read($, modelAtom)) === null) return
177 if (await read($, closedAtom)) return
178 if ((await $.ui.panes()).some(pane => pane.id === PANE)) return
179 await $.ui.open({ id: PANE, title: t('pane.title') })
180}
181
182/** Opens the pane on the person's ask (`/panel` or the reopen bar): clears the closed flag, opens, wakes the engine. */
183async function openAsked($: EngineInterface, running: RefreshEngine): Promise<boolean> {
184 await update($, closedAtom, () => false)
185 const opened = await $.ui.open({ id: PANE, title: t('pane.title') })
186 await running.wake().catch(() => undefined)
187 return opened.isPlaced
188}
189
190export const register: Register = (on, options) => {
191 const panel = options.panel
192 const reviewCommands = options.reviewCommands
193 const settings: Settings = {
194 stageBranchPattern: String(options.stageBranchPattern ?? ''),
195 foldSeparator: String(options.foldSeparator ?? ''),
196 reviewCommands: reviewCommands === 'on' || reviewCommands === 'off' ? reviewCommands : 'auto',
197 slackDomain: String(options.slackDomain ?? '').trim(),
198 panel: panel === 'command' || panel === 'off' ? panel : 'auto',
199 fsPollSeconds: pollSeconds(options.fsPollSeconds, DEFAULT_FS_POLL, MIN_FS_POLL),
200 prPollSeconds: pollSeconds(options.prPollSeconds, DEFAULT_PR_POLL, MIN_PR_POLL),
201 forge: options.forge === 'github' || options.forge === 'gitlab' || options.forge === 'none' ? options.forge : 'auto',
202 }
203
204 // The refresh engine, bound at session.start. A module reload re-runs session.start, which stops this one.
205 const slot: EngineSlot = { engine: null }
206
207 on('session.start', async ($, e, next) => {
208 const started = await next(e)
209
210 // Bind the engine afresh (a reload re-runs this hook); the old one's timers stop.
211 let bound: RefreshEngine | null = null
212 let before: Snapshot | undefined
213 try {
214 bound = bindEngine($, settings, slot, await $.session.root().catch(() => e.cwd))
215 } catch {
216 bound = null
217 }
218
219 // A plan that can't be read must never break session start.
220 let model: PanelModel | null = null
221 try {
222 // Baselines first, so a write during the read is seen by the next poll.
223 before = bound === null ? undefined : await bound.snapshot().catch(() => undefined)
224 model = await build($, settings)
225 } catch {
226 model = null
227 }
228 try {
229 // The engine adopts this read (hash, mtimes, HEAD), so its first poll does not re-publish it.
230 const seeded = bound === null ? model : await bound.seed(model, before).catch(() => model)
231 await update($, modelAtom, () => seeded)
232 } catch {
233 return started
234 }
235 // Silent without a plan: no command, no pane.
236 if (model === null) return started
237
238 try {
239 await $.command.register({ name: 'panel', description: t('command.description') })
240 } catch {
241 // The pane can still open by itself.
242 }
243 try {
244 if (settings.panel === 'auto' && !(await read($, closedAtom))) {
245 // Unasked panes are only placed from 144 columns, so only a desktop is opened by itself.
246 const isDesktop = e.surface === 'desktop' || (await $.session.surfaces()).includes('desktop')
247 if (isDesktop) await $.ui.open({ id: PANE, title: t('pane.title') })
248 }
249 } catch {
250 // The command still opens it.
251 }
252 // Polls run only while the pane is on screen; the engine checks that itself.
253 try {
254 await bound?.start()
255 } catch {
256 // The pane works without live refresh.
257 }
258 return started
259 })
260
261 on('command.run', { command: 'panel' }, async $ => {
262 if (settings.panel === 'off') return { text: t('command.disabled') }
263
264 let model: PanelModel | null
265 let running: RefreshEngine
266 try {
267 running = await engineOf($, settings, slot)
268 const outcome = await running.readNow()
269 if (!outcome.ok) return { text: t('command.readFailed') }
270 model = outcome.model
271 } catch {
272 return { text: t('command.readFailed') }
273 }
274 if (model === null) {
275 const root = await $.session.root().catch(() => '')
276 return { text: t('command.noPlan', { root }) || t('command.readFailed') }
277 }
278
279 try {
280 return { text: (await openAsked($, running)) ? t('command.opened') : t('command.notPlaced') }
281 } catch {
282 return { text: t('command.openFailed') }
283 }
284 })
285
286 // A person closing the pane: remembered, so `auto` does not reopen it this session.
287 on('ui.close', { id: PANE }, async ($, e, next) => {
288 const closed = await next(e)
289 if (e.origin.kind === 'person') await update($, closedAtom, () => true).catch(() => undefined)
290 // The pane is gone, whoever closed it: nothing to keep fresh.
291 slot.engine?.stop()
292 return closed
293 }).catch(() => undefined)
294
295 // A tool call that wrote the plan, or ran aidlc/git/gh, re-reads. The result is never altered or delayed.
296 on('tool.call', async ($, e, next) => {
297 const result = await next(e)
298 try {
299 slot.engine?.toolCall(String(e.tool), e as unknown as Record<string, unknown>)
300 } catch {
301 // A classification failure never reaches the model.
302 }
303 return result
304 }).catch(($, e, next) => next(e))
305
306 // The turn ending: the model probably touched the plan. Does not hold the turn.
307 on('turn.complete', async ($, e, next) => {
308 try {
309 slot.engine?.request('turn')
310 } catch {
311 // Never breaks the turn.
312 }
313 return next(e)
314 })
315
316 // A surface joined. The desktop app attaches after session start, so `panel: auto` opens the pane here;
317 // then the engine wakes, checks the remote and asks for a read.
318 on('session.attach', async ($, e, next) => {
319 const attached = await next(e)
320 if (e.surface === 'desktop') {
321 try {
322 await openForDesktop($, settings)
323 } catch {
324 // /panel still opens it.
325 }
326 }
327 try {
328 await slot.engine?.attach()
329 } catch {
330 // Polls resume on the next render.
331 }
332 return attached
333 })
334
335 // A pane the person closed leaves a slim bar above the prompt with a button to bring it back, so reopening
336 // needs no /panel. Gone again once the pane is open (openAsked clears the flag, which redraws the bar).
337 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
338 if (settings.panel === 'off' || e.props.hasSurvey) return next(e)
339 const [model, isClosed] = await Promise.all([read($, modelAtom), read($, closedAtom)])
340 if (model === null || !isClosed) return next(e)
341
342 const { Box, Button, Text } = $.ui.resolve(e)
343 return (
344 <Box>
345 <Text dimColor>{t('reopen.hint')} </Text>
346 <Button
347 key="reopen"
348 label={t('reopen.button')}
349 onPress={async () => {
350 await openAsked($, await engineOf($, settings, slot)).catch(() => undefined)
351 }}
352 />
353 </Box>
354 )
355 })
356
357 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
358 const [model, expanded, now] = await Promise.all([read($, modelAtom), read($, expandedAtom), $.clock.now()])
359 // A pane being drawn is a pane on screen: let a stopped engine notice (no state is written here).
360 slot.engine?.noteRendered(now)
361 void slot.engine?.wake().catch(() => undefined)
362
363 return renderPane($.ui.resolve(e), e.surface, e.props.bodyColumns, model, expanded, {
364 onRefresh: async () => {
365 await (await engineOf($, settings, slot)).readNow({ manual: true })
366 },
367 onToggle: async (sectionId, isOpen) => {
368 await update($, expandedAtom, folds => ({ ...folds, [sectionId]: isOpen }))
369 },
370 onAction: async actionId => {
371 // The provider that owns the plan answers; its model replaces the drawn one.
372 const running = await engineOf($, settings, slot)
373 const before = await running.snapshot().catch(() => undefined)
374 const acted = await runAction(await contextOf($, settings), actionId)
375 if (acted === null) return
376 // The engine adopts the provider's answer (a read still in flight drops its result), so the next poll does not re-publish it.
377 const adopted = await running.seed(acted, before)
378 await update($, modelAtom, () => adopted)
379 },
380 onFillNext: async command => {
381 await $.prompt.fill({ text: command, mode: 'replace' })
382 },
383 },
384 // M2 hook point data: the clock the header's "checked {ago}" reads.
385 { now, checkedAt: slot.engine?.checkedAt() ?? 0 })
386 })
387}
388src/model.ts 31 lines1// The model for this session: detect a plan, let its provider load it.
2import { detectPlan } from './detect'
3import type { PanelModel, Provider, ProviderContext } from '../types'
4
5/** null when no provider finds a plan. A provider's `load` may throw; the caller decides what that means. */
6export async function buildModel(ctx: ProviderContext, providers?: readonly Provider[]): Promise<PanelModel | null> {
7 const found = await detectPlan(ctx, providers)
8 return found ? found.provider.load(ctx, found.detection) : null
9}
10
11/** Runs a provider action the person pressed, against a fresh detection. */
12export async function runAction(
13 ctx: ProviderContext,
14 actionId: string,
15 providers?: readonly Provider[],
16): Promise<PanelModel | null> {
17 const found = await detectPlan(ctx, providers)
18 if (!found?.provider.act) return null
19 return found.provider.act(ctx, found.detection, actionId)
20}
21
22/**
23 * The detected provider's remote fingerprint (src/refresh.ts compares it between polls), or null when no
24 * plan is detected or the provider has none. A provider's `fingerprint` may throw; the caller decides what that means.
25 */
26export async function fingerprintOf(ctx: ProviderContext, providers?: readonly Provider[]): Promise<string | null> {
27 const found = await detectPlan(ctx, providers)
28 if (!found?.provider.fingerprint) return null
29 return found.provider.fingerprint(ctx, found.detection)
30}
31src/refresh.ts 798 lines1// The refresh engine: keeps the published PanelModel true to the plan and the repo.
2//
3// Provider-neutral and `$`-free: everything outside (clock, timers, the provider's
4// load and fingerprint, stats, commands, the state write, "is the pane on screen")
5// is a port that hooks/register.tsx adapts from `$`. So it is unit-testable with
6// fake ports and a fake clock.
7//
8// What it does
9// - Triggers: session start, tool calls that touched the plan, the turn ending, a surface
10// attaching, an mtime poll of the model's `watch` paths plus the git HEAD, a poll of the
11// provider's remote `fingerprint`, and the Refresh button.
12// - Coalesces: a read already scheduled absorbs later requests (it is never pushed out, so a burst
13// cannot starve it); a read already running queues exactly one follow-up.
14// - Publishes only a change: the model is hashed without `readAt` and `freshness`; an identical
15// re-read writes nothing and redraws nothing. `checkedAt()` still moves, for the header stamp.
16// - Never breaks the pane: a failed read or remote check keeps the last model and publishes it
17// with `freshness.error`; the next success clears it.
18// - Runs only while the pane is on screen: stop() cancels every timer, start() resumes with a
19// catch-up read.
20import { agoText } from './freshness'
21import { t } from './strings/en'
22import type { PanelModel } from '../types'
23
24export type Timer = { cancel(): void }
25
26export type RefreshPorts = {
27 now(): Promise<number>
28 after(ms: number, fn: () => void): Timer
29 every(ms: number, fn: () => void): Timer
30 /** detect + provider.load; null = no plan; throws on failure. */
31 load(): Promise<PanelModel | null>
32 /** The detected provider's remote fingerprint; null = it has none; throws on failure. */
33 fingerprint(): Promise<string | null>
34 /** Last modification of a file or directory, null when missing. Never rejects. */
35 mtime(path: string): Promise<number | null>
36 run(
37 argv: readonly string[],
38 init?: { cwd?: string; timeoutMs?: number },
39 ): Promise<{ exitCode: number; stdout: string }>
40 /** Writes the model to state (null = no plan): readers redraw. */
41 publish(model: PanelModel | null): Promise<void>
42 /** A redraw without a model change (the manual refresh's "checked …"). */
43 invalidate(): Promise<void>
44 /** `panel` is not off and the surface lists the pane as open and placed. A person's close removes it from that list. */
45 isVisible(): Promise<boolean>
46}
47
48export type RefreshConfig = {
49 /** The session root: where `git` runs, and what relative tool paths resolve against. */
50 startDir: string
51 /** `panel: off` is false here: nothing ever runs. */
52 isEnabled: boolean
53 /** 0 disables the poll. */
54 fsPollSeconds: number
55 /** 0 disables the poll. */
56 prPollSeconds: number
57 debounceMs?: number
58}
59
60export type ReadOutcome = {
61 /** false when the read failed (the last model stays, with an error line). */
62 ok: boolean
63 /** The model after the read: the new one, or the last good one when it failed. */
64 model: PanelModel | null
65}
66
67export type RefreshEngine = {
68 /** The watched mtimes and git HEAD now: take it BEFORE a read and hand it to `seed`, so a write during the read is not swallowed. */
69 snapshot(): Promise<Snapshot>
70 /**
71 * Adopts a model read elsewhere (session start, a provider action): hash and baselines, and any read in
72 * flight drops its result. Returns it with `freshness`, to publish.
73 */
74 seed(model: PanelModel | null, before?: Snapshot): Promise<PanelModel | null>
75 /** The pane drew at `now`: the engine remembers the "checked ..." text on screen. */
76 noteRendered(now: number): void
77 /** A trigger: debounced. Ignored (beyond re-evaluating visibility) while stopped. */
78 request(reason: string): void
79 /** An immediate, single-flight read: /panel, the Refresh button. `manual` also redraws. */
80 readNow(options?: { manual?: boolean }): Promise<ReadOutcome>
81 /** A tool call finished: re-reads when it touched the plan or ran aidlc/git/gh/glab. */
82 toolCall(tool: string, input: Record<string, unknown>): void
83 /** A surface attached: wake, check the remote, request a read. */
84 attach(): Promise<void>
85 /** Re-evaluates visibility; resumes when the pane is on screen. Cheap when running. */
86 wake(): Promise<void>
87 start(): Promise<void>
88 /** Cancels every timer and pending debounce. */
89 stop(): void
90 isRunning(): boolean
91 /** Epoch ms of the latest successful check, changed or not; 0 before any. */
92 checkedAt(): number
93}
94
95export const DEBOUNCE_MS = 500
96/**
97 * A remote check that takes longer than this (a hung gh) counts as failed. Nothing waits on it (attach does
98 * `void` it), so it is generous: the ledger's fingerprint is two 12 s commands in a row at worst.
99 */
100export const REMOTE_TIMEOUT_MS = 30_000
101const GIT_TIMEOUT_MS = 5000
102
103// ── hashing ──────────────────────────────────────────────────────────────────
104
105/** JSON with sorted keys and no `undefined`: equal values, equal text. */
106export function stableStringify(value: unknown): string {
107 if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null'
108 if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
109 const record = value as Record<string, unknown>
110 const parts: string[] = []
111 for (const key of Object.keys(record).sort()) {
112 if (record[key] !== undefined) parts.push(`${JSON.stringify(key)}:${stableStringify(record[key])}`)
113 }
114 return `{${parts.join(',')}}`
115}
116
117/** cyrb53: a 53-bit string hash, as base-36 text. */
118export function hashText(text: string, seed = 0): string {
119 let h1 = 0xdeadbeef ^ seed
120 let h2 = 0x41c6ce57 ^ seed
121 for (let i = 0; i < text.length; i++) {
122 const code = text.charCodeAt(i)
123 h1 = Math.imul(h1 ^ code, 2654435761)
124 h2 = Math.imul(h2 ^ code, 1597334677)
125 }
126 h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909)
127 h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909)
128 return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36)
129}
130
131/** The model's identity for "did anything change": everything but `readAt` and `freshness`. */
132export function modelHash(model: PanelModel): string {
133 const { readAt: _readAt, freshness: _freshness, ...rest } = model
134 return hashText(stableStringify(rest))
135}
136
137// ── tool-call classification ─────────────────────────────────────────────────
138
139/** An absolute, normalised POSIX path: relative paths resolve against `base`, `.` and `..` fold. */
140export function normalizePath(path: string, base: string): string {
141 const full = path.startsWith('/') ? path : `${base}/${path}`
142 const out: string[] = []
143 for (const part of full.split('/')) {
144 if (part === '' || part === '.') continue
145 if (part === '..') out.pop()
146 else out.push(part)
147 }
148 return `/${out.join('/')}`
149}
150
151const FILE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit'])
152const COMMANDS = ['aidlc', 'git', 'gh', 'glab']
153const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash'])
154const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
155const SHELL_C = /^-[a-zA-Z]*c[a-zA-Z]*$/
156
157/** A command that runs another: the flags that take a value, and how many plain words it takes first. */
158type Wrapper = { withValue: readonly string[]; positional?: number }
159const NO_FLAGS: Wrapper = { withValue: [] }
160const WRAPPERS: Record<string, Wrapper> = {
161 env: { withValue: ['-u', '-C', '-S', '--unset', '--chdir'] },
162 sudo: { withValue: ['-u', '-g', '-h', '-p', '-C', '-D', '-R', '-T', '-U', '--user', '--group'] },
163 nice: { withValue: ['-n', '--adjustment'] },
164 timeout: { withValue: ['-k', '-s', '--kill-after', '--signal'], positional: 1 },
165 xargs: { withValue: ['-n', '-I', '-L', '-P', '-s', '-d', '-E', '-a', '-J', '--max-args', '--replace', '--delimiter'] },
166 stdbuf: NO_FLAGS,
167 nohup: NO_FLAGS,
168 command: NO_FLAGS,
169 builtin: NO_FLAGS,
170 exec: NO_FLAGS,
171 time: NO_FLAGS,
172 npx: { withValue: ['-p', '--package'] },
173 bunx: { withValue: ['-p', '--package'] },
174 // Shell keywords that stand before a command.
175 if: NO_FLAGS,
176 then: NO_FLAGS,
177 else: NO_FLAGS,
178 elif: NO_FLAGS,
179 do: NO_FLAGS,
180 while: NO_FLAGS,
181 until: NO_FLAGS,
182 '!': NO_FLAGS,
183}
184// Package managers: `npm exec x`, `pnpm dlx x`, `npm run x`, `yarn x`, `bun x`: the word after is what runs.
185const RUNNERS = new Set(['npm', 'pnpm', 'yarn', 'bun'])
186const RUNNER_VERBS = new Set(['exec', 'dlx', 'run', 'run-script', 'x'])
187
188/** Past flags (and their values) and a bare `--`. */
189function skipFlags(words: string[], from: number, withValue: readonly string[]): number {
190 let i = from
191 while (i < words.length) {
192 const word = words[i] ?? ''
193 if (word === '--') return i + 1
194 if (!word.startsWith('-') || word === '-') return i
195 i += withValue.includes(word) ? 2 : 1
196 }
197 return i
198}
199
200/** The command word of a simple command: past env assignments, wrappers and package runners. */
201function commandWordOf(words: string[]): { word: string; rest: string[] } | null {
202 let i = 0
203 while (i < words.length) {
204 const word = words[i] ?? ''
205 if (ASSIGNMENT.test(word)) {
206 i++
207 continue
208 }
209 const base = word.split('/').pop() ?? word
210 const wrapper = Object.hasOwn(WRAPPERS, base) ? WRAPPERS[base] : undefined
211 if (wrapper !== undefined) {
212 i = skipFlags(words, i + 1, wrapper.withValue) + (wrapper.positional ?? 0)
213 continue
214 }
215 if (RUNNERS.has(base)) {
216 i = skipFlags(words, i + 1, [])
217 if (RUNNER_VERBS.has(words[i] ?? '')) i = skipFlags(words, i + 1, [])
218 continue
219 }
220 return { word: base, rest: words.slice(i + 1) }
221 }
222 return null
223}
224
225/** The index of the `)` closing the `(` at `open`, or the end of the text. */
226function closingParen(text: string, open: number): number {
227 let depth = 0
228 for (let i = open; i < text.length; i++) {
229 if (text[i] === '(') depth++
230 else if (text[i] === ')' && --depth === 0) return i
231 }
232 return text.length
233}
234
235/**
236 * Every command word a shell line runs: the first word of each simple command, after
237 * `&&`, `||`, `;`, `|`, `&`, `(`, `$(`, backticks and newlines, past env assignments and wrappers
238 * (`FOO=1 env git`, `sudo -u bob git`, `timeout 30 git`, `xargs -n1 git`, `npx aidlc`, `pnpm exec gh`,
239 * `npm run git`), inside `bash -c '...'` / `bash -lc ...`, and inside `$(...)` even in double quotes.
240 * Quotes and comments are respected, so `echo "a && git b"` and `curl github.com` run neither.
241 */
242export function commandWordsOf(command: string, depth = 0): string[] {
243 const found: string[] = []
244 let words: string[] = []
245 let word = ''
246 let hasWord = false
247 let quote: '"' | "'" | null = null
248
249 const endWord = () => {
250 if (hasWord) words.push(word)
251 word = ''
252 hasWord = false
253 }
254 const endCommand = () => {
255 endWord()
256 const head = commandWordOf(words)
257 words = []
258 if (head === null) return
259 found.push(head.word)
260 if (SHELLS.has(head.word) && depth < 4) {
261 const at = head.rest.findIndex(flag => SHELL_C.test(flag))
262 const script = at >= 0 ? head.rest[at + 1] : undefined
263 if (script !== undefined) found.push(...commandWordsOf(script, depth + 1))
264 }
265 }
266
267 for (let i = 0; i < command.length; i++) {
268 const c = command[i] ?? ''
269 if (quote === "'") {
270 if (c === "'") quote = null
271 else word += c
272 } else if (quote === '"') {
273 if (c === '\\' && i + 1 < command.length) word += command[++i] ?? ''
274 else if (c === '"') quote = null
275 else if (c === '$' && command[i + 1] === '(' && depth < 4) {
276 const close = closingParen(command, i + 1)
277 found.push(...commandWordsOf(command.slice(i + 2, close), depth + 1))
278 i = close
279 } else if (c === '`' && depth < 4) {
280 const close = command.indexOf('`', i + 1)
281 const end = close < 0 ? command.length : close
282 found.push(...commandWordsOf(command.slice(i + 1, end), depth + 1))
283 i = end
284 } else word += c
285 } else if (c === '\\') {
286 if (i + 1 < command.length) {
287 word += command[++i] ?? ''
288 hasWord = true
289 }
290 } else if (c === "'" || c === '"') {
291 quote = c
292 hasWord = true
293 } else if (c === '#' && !hasWord) {
294 while (i + 1 < command.length && command[i + 1] !== '\n') i++
295 } else if (c === '$' && command[i + 1] === '(') {
296 endCommand()
297 i++
298 } else if (c === ' ' || c === '\t') {
299 endWord()
300 } else if (c === ';' || c === '|' || c === '&' || c === '(' || c === ')' || c === '`' || c === '\n') {
301 endCommand()
302 } else {
303 word += c
304 hasWord = true
305 }
306 }
307 endCommand()
308 return found
309}
310
311/** True when a shell line runs `aidlc`, `git`, `gh` or `glab` as a command. */
312export function runsPlanCommand(command: string): boolean {
313 return commandWordsOf(command).some(word => COMMANDS.includes(word))
314}
315
316/**
317 * Whether a finished tool call is worth a re-read: a file tool that wrote a watched path
318 * (or anything under a watched directory), or a shell line that ran aidlc, git, gh or glab.
319 * `input` is the tool's arguments (`file_path`, `notebook_path`, `command`).
320 */
321export function isRelevantToolCall(
322 tool: string,
323 input: Record<string, unknown>,
324 watch: readonly string[],
325 startDir: string,
326): boolean {
327 if (tool === 'Bash') return typeof input['command'] === 'string' && runsPlanCommand(input['command'])
328 if (!FILE_TOOLS.has(tool)) return false
329 const raw = tool === 'NotebookEdit' ? input['notebook_path'] : input['file_path']
330 if (typeof raw !== 'string' || raw === '') return false
331 const path = normalizePath(raw, startDir)
332 return watch.some(entry => {
333 if (entry === '') return false
334 const target = normalizePath(entry, startDir)
335 return path === target || path.startsWith(target === '/' ? '/' : `${target}/`)
336 })
337}
338
339// ── the engine ───────────────────────────────────────────────────────────────
340
341/** mtimes of the watched paths and the git HEAD signature, as of one moment. */
342export type Snapshot = { mtimes: Map<string, number | null>; head: string }
343
344export function createRefreshEngine(ports: RefreshPorts, config: RefreshConfig): RefreshEngine {
345 const debounceMs = config.debounceMs ?? DEBOUNCE_MS
346 const fsMs = config.fsPollSeconds > 0 ? config.fsPollSeconds * 1000 : 0
347 const prMs = config.prPollSeconds > 0 ? config.prPollSeconds * 1000 : 0
348 const noop = () => undefined
349
350 // What is published, and what it was read from.
351 let last: PanelModel | null = null
352 let lastHash: string | null = null
353 let publishedError: string | undefined
354 let lastCheckedAt = 0
355 let isPlanFailing = false
356 let isRemoteFailing = false
357 /** The failed remote check's own line (a forge error carries one); '' = the generic one. */
358 let remoteErrorText = ''
359 /** The last non-empty watch list: a plan that went away is polled for its return. */
360 let knownWatch: string[] = []
361 /** The "checked ..." text on screen, so a tick redraws only when it would change. */
362 let drawnText = ''
363
364 // Baselines taken around every successful read.
365 let watchBase = new Map<string, number | null>()
366 let headBase = ''
367 let fingerprintBase: string | undefined
368 /** A fingerprint seen to differ: adopted as the baseline only once a read that started after it succeeds. */
369 let pendingFingerprint: { value: string; afterSeq: number } | null = null
370 /** false once the provider answered "no fingerprint": no remote timer until a read re-detects one. */
371 let hasFingerprint = true
372
373 // Lifecycle. `generation` moves on seed, stop and adopt: work that started before drops its results.
374 let generation = 0
375 let startedReads = 0
376 let isRunning = false
377 let isStarting = false
378 let isStale = true
379 let isTicking = false
380 let isCheckingRemote = false
381 let remoteToken = 0
382 let fsTimer: Timer | null = null
383 let remoteTimer: Timer | null = null
384 let remoteKick: Timer | null = null
385 let debounceTimer: Timer | null = null
386
387 // Single flight.
388 let current: Promise<void> | null = null
389 let isQueued = false
390 let queuedGeneration = 0
391 let outcome: ReadOutcome = { ok: true, model: null }
392
393 const safe = async <T>(run: () => Promise<T>, fallback: T): Promise<T> => {
394 try {
395 return await run()
396 } catch {
397 return fallback
398 }
399 }
400
401 const watchList = (): string[] => (last !== null && last.watch.length > 0 ? last.watch : knownWatch)
402
403 function currentError(): string | undefined {
404 const text = isPlanFailing ? t('refresh.failed') : isRemoteFailing ? remoteErrorText || t('refresh.remoteFailed') : ''
405 return text === '' ? undefined : text
406 }
407
408 const withFreshness = (model: PanelModel, checkedAt: number, error: string | undefined): PanelModel => ({
409 ...model,
410 freshness: error === undefined ? { checkedAt } : { checkedAt, error },
411 })
412
413 // ── baselines ──
414
415 async function statAll(paths: readonly string[]): Promise<Map<string, number | null>> {
416 const entries = await Promise.all(paths.map(async path => [path, await safe(() => ports.mtime(path), null)] as const))
417 return new Map(entries)
418 }
419
420 /** The git HEAD signature: sha and branch; '' when there is no git (a normal case, never an error). */
421 async function headSignature(): Promise<string> {
422 const ask = (argv: readonly string[]) =>
423 safe(async () => {
424 const ran = await ports.run(argv, { cwd: config.startDir, timeoutMs: GIT_TIMEOUT_MS })
425 return ran.exitCode === 0 ? ran.stdout.trim() : ''
426 }, '')
427 const [sha, branch] = await Promise.all([ask(['git', 'rev-parse', 'HEAD']), ask(['git', 'branch', '--show-current'])])
428 return sha === '' && branch === '' ? '' : `${sha}|${branch}`
429 }
430
431 async function snapshot(): Promise<Snapshot> {
432 if (!config.isEnabled) return { mtimes: new Map(), head: '' }
433 const [mtimes, head] = await Promise.all([statAll(watchList()), headSignature()])
434 return { mtimes, head }
435 }
436
437 /** Baselines for `paths`: the snapshot's mtimes where it has them, a stat now for the rest. */
438 async function baseline(paths: readonly string[], before: Snapshot): Promise<void> {
439 const extra = await statAll(paths.filter(path => !before.mtimes.has(path)))
440 watchBase = new Map(paths.map(path => [path, before.mtimes.has(path) ? (before.mtimes.get(path) ?? null) : (extra.get(path) ?? null)]))
441 headBase = before.head
442 }
443
444 async function hasFsChanged(): Promise<boolean> {
445 const now = await snapshot()
446 if (now.head !== headBase) return true
447 if (now.mtimes.size !== watchBase.size) return true
448 for (const [path, mtime] of now.mtimes) if (watchBase.get(path) !== mtime) return true
449 return false
450 }
451
452 // ── publishing ──
453
454 /** The error line changed without a new model (a failed read, a recovered remote): republish the last one. */
455 async function syncError(): Promise<void> {
456 const error = currentError()
457 if (last === null || error === publishedError) return
458 const next = withFreshness(last, last.freshness?.checkedAt ?? lastCheckedAt, error)
459 try {
460 await ports.publish(next)
461 last = next
462 publishedError = error
463 } catch {
464 // Not written: the next change tries again.
465 }
466 }
467
468 /** A check that found nothing to do still counts: the stamp moves, and the pane redraws only if its text would. */
469 async function markChecked(): Promise<void> {
470 if (isPlanFailing) return
471 const now = await safe(() => ports.now(), lastCheckedAt)
472 lastCheckedAt = now
473 const text = agoText(now, now)
474 if (text === drawnText) return
475 drawnText = text
476 await safe(() => ports.invalidate(), undefined)
477 }
478
479 async function doRead(): Promise<void> {
480 const gen = generation
481 const seq = ++startedReads
482 // Baselines first: a write that lands during the load is then seen by the next poll, not swallowed.
483 const before = await snapshot()
484
485 let model: PanelModel | null
486 try {
487 model = await ports.load()
488 } catch {
489 if (gen !== generation) return
490 isPlanFailing = true
491 await syncError()
492 outcome = { ok: false, model: last }
493 return
494 }
495 if (gen !== generation) return
496
497 const paths = model !== null && model.watch.length > 0 ? model.watch : knownWatch
498 await baseline(paths, before)
499 if (gen !== generation) return
500
501 isPlanFailing = false
502 // A read just took fresh baselines: a resume right after it has nothing to catch up on.
503 isStale = false
504 if (model !== null && model.watch.length > 0) knownWatch = model.watch
505 const now = await safe(() => ports.now(), model?.readAt ?? lastCheckedAt)
506 lastCheckedAt = now
507 if (pendingFingerprint !== null && seq > pendingFingerprint.afterSeq) {
508 fingerprintBase = pendingFingerprint.value
509 pendingFingerprint = null
510 }
511 if (!hasFingerprint && isRunning) {
512 // A fresh read may have found a provider with a remote.
513 hasFingerprint = true
514 kickRemote()
515 }
516
517 const hash = model === null ? '' : modelHash(model)
518 const error = model === null ? undefined : currentError()
519 if (hash !== lastHash || error !== publishedError) {
520 const next = model === null ? null : withFreshness(model, now, error)
521 try {
522 await ports.publish(next)
523 last = next
524 lastHash = hash
525 publishedError = error
526 } catch {
527 // Not written: the next read tries again.
528 }
529 }
530 outcome = { ok: true, model: last }
531 }
532
533 // ── single flight ──
534
535 async function pump(): Promise<void> {
536 try {
537 do {
538 isQueued = false
539 await doRead()
540 // A follow-up queued before a stop or a seed belongs to a world that is gone.
541 } while (isQueued && queuedGeneration === generation)
542 } finally {
543 current = null
544 }
545 }
546
547 /** Runs a read, or queues exactly one follow-up behind the one running. Resolves with the last read's outcome. */
548 async function schedule(): Promise<ReadOutcome> {
549 if (current !== null) {
550 isQueued = true
551 queuedGeneration = generation
552 } else current = pump()
553 await current
554 return outcome
555 }
556
557 function cancelDebounce(): void {
558 debounceTimer?.cancel()
559 debounceTimer = null
560 }
561
562 // ── polls ──
563
564 async function visible(): Promise<boolean> {
565 // A failing visibility check does not stop a running engine.
566 return safe(() => ports.isVisible(), true)
567 }
568
569 async function fsTick(): Promise<void> {
570 if (!isRunning || isTicking) return
571 isTicking = true
572 const gen = generation
573 try {
574 if (!(await visible())) {
575 engine.stop()
576 return
577 }
578 if (!isRunning || gen !== generation) return
579 if (await hasFsChanged()) engine.request('fs')
580 else await markChecked()
581 } finally {
582 isTicking = false
583 }
584 }
585
586 /** A fingerprint that outlasts REMOTE_TIMEOUT_MS (a hung gh) counts as a failure. */
587 function guarded<T>(work: Promise<T>): Promise<T> {
588 return new Promise<T>((resolve, reject) => {
589 const timer = ports.after(REMOTE_TIMEOUT_MS, () => reject(new Error('remote check timed out')))
590 work.then(
591 value => {
592 timer.cancel()
593 resolve(value)
594 },
595 error => {
596 timer.cancel()
597 reject(error)
598 },
599 )
600 })
601 }
602
603 function ensureRemoteTimer(): void {
604 if (!isRunning || prMs === 0 || remoteTimer !== null) return
605 try {
606 remoteTimer = ports.every(prMs, () => void remoteTick().catch(noop))
607 } catch {
608 remoteTimer = null
609 }
610 }
611
612 /** Runs the remote check on the next turn of the clock, never inside whatever dispatch woke the engine. */
613 function kickRemote(): void {
614 remoteKick?.cancel()
615 try {
616 remoteKick = ports.after(0, () => {
617 remoteKick = null
618 void checkRemote().catch(noop)
619 })
620 } catch {
621 remoteKick = null
622 }
623 }
624
625 /** Resolves true when the check itself worked (whatever it found). */
626 async function checkRemote(): Promise<boolean> {
627 if (isCheckingRemote) return false
628 isCheckingRemote = true
629 const token = ++remoteToken
630 const gen = generation
631 try {
632 let fingerprint: string | null
633 try {
634 fingerprint = await guarded(ports.fingerprint())
635 } catch (error) {
636 if (gen !== generation) return false
637 isRemoteFailing = true
638 // A provider's error may carry the finished line (the forge layer names the cause); anything else gets the generic one.
639 const display = (error as { display?: unknown } | null)?.display
640 remoteErrorText = typeof display === 'string' ? display : ''
641 hasFingerprint = true
642 ensureRemoteTimer()
643 await syncError()
644 return false
645 }
646 if (gen !== generation) return false
647 isRemoteFailing = false
648 remoteErrorText = ''
649 if (fingerprint === null) {
650 // No remote state: nothing to poll until a read finds a provider that has some.
651 hasFingerprint = false
652 remoteTimer?.cancel()
653 remoteTimer = null
654 await syncError()
655 return true
656 }
657 hasFingerprint = true
658 ensureRemoteTimer()
659 const before = fingerprintBase
660 if (before === undefined && last !== null) fingerprintBase = fingerprint
661 else if (before !== fingerprint) {
662 // Adopted as the baseline only when a read that starts now has succeeded.
663 pendingFingerprint = { value: fingerprint, afterSeq: startedReads }
664 engine.request('remote')
665 return true
666 }
667 await syncError()
668 return true
669 } finally {
670 if (token === remoteToken) isCheckingRemote = false
671 }
672 }
673
674 async function remoteTick(): Promise<void> {
675 if (!isRunning) return
676 if (!(await visible())) {
677 engine.stop()
678 return
679 }
680 if (isRunning && (await checkRemote())) await markChecked()
681 }
682
683 // ── the engine ──
684
685 const engine: RefreshEngine = {
686 snapshot,
687
688 async seed(model, before) {
689 if (!config.isEnabled) return model
690 generation++
691 const now = await safe(() => ports.now(), model?.readAt ?? 0)
692 lastCheckedAt = now
693 last = model === null ? null : withFreshness(model, now, undefined)
694 lastHash = model === null ? '' : modelHash(model)
695 publishedError = undefined
696 if (model !== null && model.watch.length > 0) knownWatch = model.watch
697 await baseline(model !== null && model.watch.length > 0 ? model.watch : knownWatch, before ?? (await snapshot()))
698 isStale = false
699 return last
700 },
701
702 request(_reason) {
703 if (!config.isEnabled) return
704 if (!isRunning) {
705 void engine.wake().catch(noop)
706 return
707 }
708 // A read is already due and will see everything written so far; pushing it out would starve it under a burst.
709 if (debounceTimer !== null) return
710 debounceTimer = ports.after(debounceMs, () => {
711 debounceTimer = null
712 if (isRunning) void schedule().catch(noop)
713 })
714 },
715
716 async readNow(options) {
717 if (!config.isEnabled) return { ok: true, model: last }
718 cancelDebounce()
719 const result = await schedule()
720 if (options?.manual === true) await safe(() => ports.invalidate(), undefined)
721 return result
722 },
723
724 toolCall(tool, input) {
725 if (!config.isEnabled) return
726 if (isRelevantToolCall(tool, input, watchList(), config.startDir)) engine.request('tool')
727 },
728
729 noteRendered(now) {
730 drawnText = agoText(now, lastCheckedAt)
731 },
732
733 async attach() {
734 if (!config.isEnabled) return
735 await engine.wake()
736 if (!isRunning) return
737 // A hung gh must not hold the attach.
738 void checkRemote().catch(noop)
739 engine.request('attach')
740 },
741
742 async wake() {
743 if (!config.isEnabled || isRunning) return
744 await engine.start()
745 },
746
747 async start() {
748 if (!config.isEnabled || isRunning || isStarting) return
749 isStarting = true
750 const gen = generation
751 let isShown = false
752 try {
753 isShown = await safe(() => ports.isVisible(), false)
754 } finally {
755 isStarting = false
756 }
757 // A stop (or a seed) that came while the pane was being looked at wins.
758 if (gen !== generation || isRunning) return
759 if (!isShown) {
760 isStale = true
761 return
762 }
763 try {
764 if (fsMs > 0) fsTimer = ports.every(fsMs, () => void fsTick().catch(noop))
765 } catch {
766 fsTimer = null
767 return
768 }
769 isRunning = true
770 if (isStale) {
771 isStale = false
772 engine.request('resume')
773 }
774 if (prMs > 0 && hasFingerprint) kickRemote()
775 },
776
777 stop() {
778 isRunning = false
779 isStale = true
780 isQueued = false
781 generation++
782 remoteToken++
783 isCheckingRemote = false
784 fsTimer?.cancel()
785 remoteTimer?.cancel()
786 remoteKick?.cancel()
787 fsTimer = null
788 remoteTimer = null
789 remoteKick = null
790 cancelDebounce()
791 },
792
793 isRunning: () => isRunning,
794 checkedAt: () => lastCheckedAt,
795 }
796 return engine
797}
798src/render.tsx 93 lines1// The panel's tree: any PanelModel, drawn the same way. Deterministic and
2// I/O-free: model + folds + clock in, elements out. Box, Text, Button and Link
3// are in every surface's table; Svg (rings, the status strip) is in all but the
4// terminal's, which draws glyphs and block cells instead.
5//
6// Top to bottom: header (where am I), the Now card with Next (what am I on,
7// what is next), the actions, Needs attention (what blocks), Plan (overall
8// progress and sections). From 80 columns, with sections, the Now card, actions
9// and attention sit in a left column and the plan in a right one.
10//
11// Copy comes from `t()` or from the model (display text a provider resolved).
12// A string that is '' hides its element; nothing is substituted. Glyphs are
13// not copy. The pieces live in ./render/.
14import type { RenderSurface } from 'claude-code'
15import type { Clock } from './freshness'
16import { t } from './strings/en'
17import type { PanelModel } from '../types'
18import { attention, rowsOf } from './render/attention'
19import { header } from './render/header'
20import { breakpoint } from './render/kit'
21import type { Ctx, El, Handlers, Kit } from './render/kit'
22import { actionsRow, nowCard } from './render/now'
23import { orphanGates, plan } from './render/plan'
24
25export type { Handlers, Kit } from './render/kit'
26
27const LEFT_SHARE = 0.4
28const COLUMN_GAP = 2
29
30/**
31 * `surface` decides the ring, not the table's keys: the terminal's table answers
32 * an `Svg` constructor at run time though its type has none, and draws nothing from it.
33 * `clock` (the engine's `now`, its last check) turns the stamp into "checked 12s ago";
34 * without it the stamp is the model's "read 12:03".
35 */
36export function renderPane(
37 table: Kit,
38 surface: RenderSurface,
39 columns: number,
40 model: PanelModel | null,
41 expanded: Record<string, boolean>,
42 handlers: Handlers,
43 clock?: Clock,
44): El {
45 const kit: Kit = surface === 'terminal' ? { ...table, Svg: undefined } : table
46 const width = Number.isFinite(columns) ? columns : 60
47 const ctx: Ctx = { kit, surface, bp: breakpoint(width), columns: width, handlers }
48 const { Box, Text } = kit
49
50 if (model === null) {
51 const empty = t('pane.empty')
52 return (
53 <Box flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={1}>
54 {header(ctx, null, clock)}
55 {empty !== '' && (
56 <Text dimColor wrap="wrap">
57 {empty}
58 </Text>
59 )}
60 </Box>
61 )
62 }
63
64 const hasPlan = model.sections.length > 0 || orphanGates(model).length > 0
65 const isTwoColumn = ctx.bp === 'L' && hasPlan
66 // The left column is 40% of the pane, less the gap: it lays out for its own width, not the pane's.
67 const leftWidth = Math.floor(width * LEFT_SHARE) - COLUMN_GAP
68 const leftCtx: Ctx = isTwoColumn ? { ...ctx, bp: breakpoint(leftWidth), columns: leftWidth } : ctx
69 const left = [nowCard(leftCtx, model), actionsRow(leftCtx, model), rowsOf(model).length > 0 && attention(leftCtx, model)]
70 const right = hasPlan && plan(ctx, model, expanded)
71
72 return (
73 <Box flexDirection="column" gap={1}>
74 {header(ctx, model, clock)}
75 {isTwoColumn ? (
76 <Box gap={COLUMN_GAP}>
77 <Box flexDirection="column" gap={1} width="40%">
78 {left}
79 </Box>
80 <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0}>
81 {right}
82 </Box>
83 </Box>
84 ) : (
85 <Box flexDirection="column" gap={1}>
86 {left}
87 {right}
88 </Box>
89 )}
90 </Box>
91 )
92}
93src/strings/en.ts 287 lines1// The single source of every user-facing string the panel draws.
2//
3// A mod may import only code files (.ts/.tsx/...), so the table is a module, not
4// en.json (`import x from './en.json'` is refused by the engine). Keys are
5// stable; a missing key resolves to '' and the renderer hides the element
6// rather than substituting text (see `t` below). Placeholders are `{name}`.
7//
8// Generic chrome (pane, command, header, ring, section, item) is drawn by the
9// renderer and the entry point. Keys under `forge.` are the forge layer's causes
10// (src/forge/problem.ts). Keys under `aidlc.` and `ledger.` belong to the
11// AI-DLC and ledger providers, which resolve them through `t()` while building their
12// PanelModel; the model then carries display text, and '' means "hide".
13
14export const en = {
15 // ── chrome ────────────────────────────────────────────────────────────────
16 'pane.title': 'Project',
17 'pane.empty': 'No plan found for this session yet.',
18
19 'command.description': 'Open the project panel: sections, items and what blocks the next one',
20 'command.opened': 'Project panel opened.',
21 'command.notPlaced': 'Project panel is open but not shown yet: widen the window or press ctrl+x tab.',
22 'command.noPlan': 'No plan found above {root}.',
23 'command.readFailed': 'Could not read the plan; the panel keeps what it had.',
24 'command.disabled': 'The project panel is turned off (the panel option is set to off).',
25 'command.openFailed': 'Could not open the project panel.',
26
27 'reopen.hint': 'Project panel is closed.',
28 'reopen.button': 'Open panel',
29
30 'header.refresh': 'Refresh',
31 'header.checked': 'checked {ago}',
32 'ago.now': 'just now',
33 'ago.seconds': '{n}s ago',
34 'ago.minutes': '{n}m ago',
35 'ago.hours': '{n}h ago',
36 'header.readAt': 'read {time}',
37 'header.badge.sideJob': 'Side job',
38 'header.badge.idle': 'Idle',
39
40 'now.label': 'Now',
41 'now.where': '{section}, {index} of {total}',
42 'now.nextUp': 'Next up',
43 'now.sideJob.label': 'This session',
44 'now.sideJob.headline': 'Side job, not a stage of this plan',
45 'now.idle.label': 'Workspace',
46 'now.idle.headline': 'Nothing in flight',
47 'now.landing.local': 'local only, this worktree',
48
49 'next.label': 'Next',
50 'next.fill': 'Put in prompt',
51
52 'attention.title': 'Needs attention ({count})',
53 'attention.whereAfter': '{section}, after {item}',
54 'attention.whereOn': '{section}, {item}',
55
56 'plan.title': 'Plan',
57 'plan.summary': '{percent}% done, {done} of {total}',
58 'plan.foldedHint': 'folded, show',
59
60 'ring.alt': '{percent}% done',
61 'strip.alt': '{done} of {total} done',
62
63 'section.count': '{done}/{total}',
64 'section.skipped': 'skipped',
65 'section.proposedHeading': 'Proposed',
66 'section.doneFold': '{count} done, show',
67 'section.doneHide': 'Hide done',
68 'section.moreItems': 'Show {count} more',
69 'section.fewerItems': 'Show fewer',
70
71 'item.status.open': 'in progress',
72 'item.status.awaiting': 'awaiting',
73 'item.status.revising': 'revising',
74 'item.status.reopened': 'reopened',
75 'item.status.stale': 'stale',
76 'item.status.skipped': 'skipped',
77 'item.status.failed': 'failed',
78 'item.status.partial': 'partial',
79
80 // ── AI-DLC provider ───────────────────────────────────────────────────────
81 'aidlc.name': 'AI-DLC',
82
83 'aidlc.phase.INITIALIZATION': 'Initialization',
84 'aidlc.phase.IDEATION': 'Ideation',
85 'aidlc.phase.INCEPTION': 'Inception',
86 'aidlc.phase.CONSTRUCTION': 'Construction',
87 'aidlc.phase.OPERATION': 'Operation',
88 'aidlc.phase.perUnit': 'per unit: {units}',
89
90 'aidlc.header.unit': 'unit {unit}',
91
92 // Construction drawn as Units of work (providers/aidlc/units.ts, points.ts): the section's caption, a Unit's points
93 // chip, its status word ({stage} a per-unit stage slug, {units} the Unit IDs it waits on), the plan heading when
94 // Construction counts by points, and the rows about the points file ({file}; {units} the Unit IDs it repeats).
95 'aidlc.units.caption': '{done} of {total} story points done, {count} units',
96 'aidlc.chip.points': '{points} pt',
97 'aidlc.chip.pointsDefault': '{points} pt, default',
98 'aidlc.unit.atStage': '{stage}',
99 'aidlc.unit.paused': 'paused',
100 'aidlc.unit.pausedAt': 'paused at {stage}',
101 'aidlc.unit.stageDone': '{stage} done',
102 'aidlc.unit.waitsOn': 'waits on {units}',
103 'aidlc.overall.points': '{percent}% done, Construction by story points, {done} of {total}',
104 'aidlc.gate.pointsUncommitted.title': '{file} not committed',
105 'aidlc.gate.pointsRepeated.title': '{file} lists a unit more than once',
106 'aidlc.gate.pointsRepeated.caption': '{units}: the last row counts. Remove the rows that are out of date.',
107 'aidlc.gate.pointsUncommitted.caption': 'It has story points not committed yet, possibly added from Jira. Commit it with this branch.',
108
109 'aidlc.sideJob.caption':
110 'This session is not bound to a stage of {intent}. Progress below is the intent the workspace points at.',
111
112 // A side job folds the intent's plan under its own context, so "below" is not where the progress is.
113 'aidlc.sideJob.captionFolded': 'This session is not bound to a stage of {intent}. The intent\'s progress is folded under Plan.',
114
115 // What the panel knows about the side-job session itself ({ref} as the forge spells it; `.gitlab` says merge request).
116 'aidlc.session.branch': 'on {branch}',
117 'aidlc.session.aheadBehind': '{ahead} ahead, {behind} behind {base}',
118 'aidlc.session.ahead': '{ahead} ahead of {base}',
119 'aidlc.session.behind': '{behind} behind {base}',
120 'aidlc.session.pr': 'PR {ref}, {state}',
121 'aidlc.session.pr.gitlab': 'MR {ref}, {state}',
122 'aidlc.session.pr.open': 'open',
123
124 'aidlc.idle.noneInFlight': 'This workspace has no intent in flight. Start one with /aidlc.',
125 'aidlc.idle.noCursor': 'This workspace has more than one live intent and none is selected. Pick one with /aidlc.',
126 'aidlc.idle.archived': 'This session was bound to an intent that has since been archived, so it shows no workflow.',
127
128 'aidlc.gate.parked.title': 'Parked at {stage}',
129 'aidlc.gate.parked.titleNoStage': 'Parked',
130 'aidlc.gate.parked.caption': 'The workflow was parked {since}. Resume it with /aidlc.',
131 // In a side job the workflow is not this session's to resume: the same facts, no command.
132 'aidlc.gate.parked.captionSideJob': 'The workflow was parked {since}.',
133 'aidlc.gate.awaiting-approval.title': '{stage} awaits your approval',
134 'aidlc.gate.awaiting-approval.caption': 'The gate is open: approve or request changes to move on.',
135
136 // Two truths (M3): the working tree against the default branch on origin, and the PRs between.
137 // {ref} is the forge's own reference text ("#33", "!275"), never spelled here; {base} the default branch. Keys that
138 // name a pull request have a `.gitlab` twin that says merge request (see `wording` in providers/aidlc/truths.ts).
139 'aidlc.word.inPr': 'in review',
140 'aidlc.word.inDraftPr': 'in draft',
141 'aidlc.word.local': 'local only',
142 'aidlc.word.unmerged': 'not merged',
143 'aidlc.word.noPr': 'pushed, no PR',
144 'aidlc.word.noPr.gitlab': 'pushed, no MR',
145 'aidlc.word.upNext': 'up next',
146
147 'aidlc.chip.pr': '{ref}',
148 'aidlc.chip.prDraft': '{ref} draft',
149 'aidlc.chip.ciFailing': 'CI failing',
150 'aidlc.chip.reopened': '↺ reopened in {ref}',
151
152 'aidlc.base.inPr': '{base}: {stage} in review, PR {ref}',
153 'aidlc.base.inPr.gitlab': '{base}: {stage} in review, MR {ref}',
154 'aidlc.base.inPrReview': '{base}: {stage} {review}, PR {ref}',
155 'aidlc.base.inPrReview.gitlab': '{base}: {stage} {review}, MR {ref}',
156 'aidlc.base.unmerged': '{base}: {stage} not merged yet',
157 'aidlc.base.merged': '{base}: {stage} merged',
158 'aidlc.base.running': '{base}: {stage} in progress',
159 'aidlc.base.parked': '{base}: parked at {stage}',
160 'aidlc.base.notStarted': '{base}: {stage} not started',
161 'aidlc.base.absent': 'This intent is not on {base} yet',
162
163 'aidlc.gate.pr-not-merged.title': 'PR {ref} not merged',
164 'aidlc.gate.pr-not-merged.title.gitlab': 'MR {ref} not merged',
165 'aidlc.gate.pr-not-merged.caption': "{next} can't start on {base} until it lands.",
166 'aidlc.gate.ci-failing.title': 'CI failing on PR {ref}',
167 'aidlc.gate.ci-failing.title.gitlab': 'CI failing on MR {ref}',
168 'aidlc.gate.ci-failing.caption': 'Failing: {checks}',
169 'aidlc.gate.changes-requested.title': 'Changes requested on PR {ref}',
170 'aidlc.gate.changes-requested.title.gitlab': 'Changes requested on MR {ref}',
171 'aidlc.gate.changes-requested.caption': 'Address the review before it can merge.',
172 'aidlc.gate.behind-main.title': '{branch} is behind {base}',
173 'aidlc.gate.behind-main.caption': '{count} new commits on {base}; merge or rebase before it can land.',
174 'aidlc.gate.behind-main.captionPr': 'PR {ref} is out of date with {base}; update the branch.',
175 'aidlc.gate.behind-main.captionPr.gitlab': 'MR {ref} is out of date with {base}; update the branch.',
176 'aidlc.gate.conflicts.title': 'PR {ref} has conflicts with {base}',
177 'aidlc.gate.conflicts.title.gitlab': 'MR {ref} has conflicts with {base}',
178 'aidlc.gate.conflicts.caption': 'Resolve them before it can merge.',
179 'aidlc.gate.parked.captionPr': 'Waiting on PR {ref} to merge; parked {since}. Resume with /aidlc once it lands.',
180 'aidlc.gate.parked.captionPr.gitlab': 'Waiting on MR {ref} to merge; parked {since}. Resume with /aidlc once it lands.',
181 'aidlc.gate.parked.captionPrSideJob': 'Waiting on PR {ref} to merge; parked {since}.',
182 'aidlc.gate.parked.captionPrSideJob.gitlab': 'Waiting on MR {ref} to merge; parked {since}.',
183
184 // ── ledger provider ───────────────────────────────────────────────────────
185 'ledger.subtitle': '{path} · {done}/{total} done',
186
187 'ledger.caption.localOnly': 'Showing the local copy of the ledger: the default branch could not be read.',
188 'ledger.caption.noChanges.github': 'Pull requests could not be read ({cause}), so progress comes from the ledger alone.',
189 'ledger.caption.noChanges.gitlab': 'Merge requests could not be read ({cause}), so progress comes from the ledger alone.',
190 'ledger.caption.noRemote': 'No origin remote, so progress comes from the ledger alone.',
191 'ledger.caption.proposal': 'Proposed product sections below — pin them to group tasks by product.',
192 'ledger.caption.aiFailed': 'Grouping by product is unavailable right now; showing the last result.',
193 'ledger.caption.aiUnavailable': 'Product grouping needs a model reply; showing phases.',
194 'ledger.caption.stale': 'Could not fetch; the ledger may be behind origin.',
195
196 'ledger.section.unassigned': 'Unassigned',
197 'ledger.section.tasks': 'Tasks',
198
199 'ledger.chip.more': '+{count} more',
200 'ledger.chip.revert': 'revert {ref}',
201
202 'ledger.next.command': 'start {id} — {title}',
203 'ledger.next.commandNoTitle': 'start {id}',
204 'ledger.next.reasonDeps': 'depends on {deps}, all done',
205 'ledger.next.reasonFree': 'no unfinished dependencies',
206
207 'ledger.drift.declared-open-but-merged': '{id}: the ledger says not done, but {ref} merged',
208 'ledger.drift.declared-done-but-open': '{id}: the ledger says done, but {ref} is still open',
209 'ledger.drift.declared-done-no-pr-open-pr': '{id}: the ledger says done, but {ref} (a slice of it) is still open',
210 'ledger.drift.qa-failed': '{id}: QA failed on {ref}',
211 'ledger.drift.note': '{id}: {note}',
212
213 'ledger.action.propose': 'Propose product sections',
214 'ledger.action.pin': 'Pin sections',
215 'ledger.action.rebuild': 'Propose again',
216
217 // ── pull request review (both providers) ──────────────────────────────────
218 // {ref} is the forge's own reference ("#33"), {number} the bare number a command takes, {who} the logins
219 // asked to review (the viewer reads as review.who.you).
220 'review.word.draft': 'in draft',
221 'review.word.awaiting': 'awaiting {who}',
222 'review.word.changes': 'changes requested',
223 'review.word.commented': 'commented',
224 'review.word.stale': 'approved, new commits',
225 'review.word.approved': 'approved',
226 'review.word.approvedComments': 'approved with comments',
227 'review.word.unreviewed': 'no reviewer',
228 'review.who.you': 'you',
229 'review.who.separator': ', ',
230
231 'review.chip.slack': 'Slack thread',
232
233 'review.row.review-requested.title': 'Your review is requested on PR {ref}',
234 'review.row.review-requested.caption': '{title} — by {author}',
235 'review.row.changes-requested.title': 'Changes requested on your PR {ref}',
236 'review.row.changes-requested.caption': 'Answer the review before it can merge.',
237 'review.row.review-commented.title': 'Comments on your PR {ref}',
238 'review.row.review-commented.caption': 'A review left comments without a verdict.',
239 'review.row.approval-stale.title': 'PR {ref} changed since its approval',
240 'review.row.approval-stale.caption': 'Merging needs an approval of the latest commit.',
241 'review.row.approved-comments.title': 'PR {ref} approved with comments',
242 'review.row.approved-comments.caption': 'Take what you want from the comments, then merge.',
243 'review.row.approved.title': 'PR {ref} approved',
244 'review.row.approved.caption': 'Ready to merge.',
245 'review.row.draft-not-requested.title': 'Your draft PR {ref} is not sent for review',
246 'review.row.draft-not-requested.caption': 'Sending it for review marks it ready.',
247 'review.row.review-not-requested.title': 'PR {ref} is not sent for review',
248 'review.row.review-not-requested.caption': 'Nobody is asked to review it yet.',
249
250 // The pr-review plugin's commands, put in the prompt from a row or from Next.
251 'review.command.review': '/pr-review:review-pr {number}',
252 'review.command.address': '/pr-review:address-review {number}',
253 'review.command.merge': '/pr-review:address-review {number}',
254 'review.command.request': '/pr-review:request-review {number}',
255
256 // ── refresh engine (M2) ───────────────────────────────────────────────────
257 'refresh.failed': 'Could not re-read the plan; showing the last good read.',
258 'refresh.remoteFailed': 'Could not check the remote; changes may be stale.',
259 'refresh.remoteFailed.github': 'Could not check pull requests ({cause}); they may be stale.',
260 'refresh.remoteFailed.gitlab': 'Could not check merge requests ({cause}); they may be stale.',
261
262 // ── forge layer (src/forge/): why a gh / glab call did not work ───────────
263 'forge.cause.missing': '{cli} missing',
264 'forge.cause.notLoggedIn': '{cli} not logged in to {host}',
265 'forge.cause.unreachable': 'could not reach {host}',
266 'forge.cause.failed': '{cli} failed',
267 'forge.cause.noRemote': 'no origin remote',
268} as const
269
270export type StringKey = keyof typeof en
271
272/**
273 * Resolves `key` with `{param}` placeholders filled. Unknown key → ''. A
274 * placeholder whose param is missing or empty makes the whole string '' so a
275 * half-filled sentence is never drawn.
276 */
277export function t(key: string, params: Record<string, string> = {}): string {
278 const template = (en as Record<string, string>)[key] ?? ''
279 let isComplete = true
280 const out = template.replace(/\{(\w+)\}/g, (_, name: string) => {
281 const value = params[name]
282 if (value === undefined || value === '') isComplete = false
283 return value ?? ''
284 })
285 return isComplete ? out : ''
286}
287src/detect.ts 24 lines1// Which provider's plan this repo has: the providers are tried in order and the
2// first hit wins (AI-DLC keeps priority over the ledger). A provider that throws
3// counts as a miss, so one broken provider never hides another's plan.
4import type { Detection, Provider, ProviderContext } from '../types'
5import { aidlc } from './providers/aidlc'
6import { ledger } from './providers/ledger'
7
8export const PROVIDERS: readonly Provider[] = [aidlc, ledger]
9
10export async function detectPlan(
11 ctx: ProviderContext,
12 providers: readonly Provider[] = PROVIDERS,
13): Promise<{ provider: Provider; detection: Detection } | null> {
14 for (const provider of providers) {
15 try {
16 const detection = await provider.detect(ctx)
17 if (detection) return { provider, detection }
18 } catch {
19 // A miss.
20 }
21 }
22 return null
23}
24src/freshness.ts 29 lines1// How long ago the panel last looked, as display text. Pure: the caller hands in
2// `now`, so the renderer stays deterministic and the redesign can call it freely.
3import { t } from './strings/en'
4import type { PanelModel } from '../types'
5
6const SECOND = 1000
7const MINUTE = 60 * SECOND
8const HOUR = 60 * MINUTE
9
10/** "just now" under 5 s, then "12s ago", "3m ago", "2h ago"; '' for a bad or absent time. */
11export function agoText(now: number, then: number): string {
12 if (!Number.isFinite(now) || !Number.isFinite(then)) return ''
13 const elapsed = Math.max(0, now - then)
14 if (elapsed < 5 * SECOND) return t('ago.now')
15 if (elapsed < MINUTE) return t('ago.seconds', { n: String(Math.floor(elapsed / SECOND)) })
16 if (elapsed < HOUR) return t('ago.minutes', { n: String(Math.floor(elapsed / MINUTE)) })
17 return t('ago.hours', { n: String(Math.floor(elapsed / HOUR)) })
18}
19
20/** The clock the header stamp reads: the engine's `now`, and the engine's last check. */
21export type Clock = { now: number; checkedAt?: number }
22
23/** "checked 12s ago": the freshest of the engine's last check, the model's own check and its read. '' hides the stamp. */
24export function checkedText(model: PanelModel, clock: Clock): string {
25 const at = Math.max(clock.checkedAt ?? 0, model.freshness?.checkedAt ?? 0, model.readAt)
26 const ago = agoText(clock.now, at)
27 return ago === '' ? '' : t('header.checked', { ago })
28}
29src/render/attention.tsx 154 lines1// Needs attention: what blocks, and what waits on the viewer. The freshness error, then the gates (a provider
2// puts the rows tied to the viewer first: a review asked of them, their approved PR), then the drift flags. A
3// row with a command offers it as a button that puts it in the prompt.
4import { t } from '../strings/en'
5import type { Gate, PanelModel } from '../../types'
6import { gateColor, gateGlyph, safely, toneColor, uniqueIds } from './kit'
7import type { Ctx, El } from './kit'
8
9type Row = { id: string; glyph: string; color: string | undefined; title: string; where: string; isBold: boolean; href?: string; command?: string }
10
11/**
12 * The gate's section, or "Section, after item" when it holds after an item of it. A row about the viewer is
13 * about the item itself ("Section, item"), not what comes after it; one about a PR outside the plan says what
14 * the PR is (its caption: title and author).
15 */
16function whereOf(model: PanelModel, gate: Gate): string {
17 const section = model.sections.find(one => one.id === gate.sectionId)
18 if (section === undefined) return gate.isAttentionOnly === true ? (gate.caption ?? '') : ''
19 const after = section.items.find(one => one.id === gate.afterItemId)
20 if (after !== undefined) return t(gate.isAttentionOnly === true ? 'attention.whereOn' : 'attention.whereAfter', { section: section.title, item: after.title })
21 return section.title
22}
23
24export function rowsOf(model: PanelModel): Row[] {
25 const rows: Row[] = []
26 const error = model.freshness?.error ?? ''
27 if (error !== '') rows.push({ id: 'error', glyph: '!', color: 'error', title: error, where: '', isBold: true })
28 for (const gate of model.gates) {
29 if (gate.title === '') continue
30 rows.push({
31 id: 'gate:' + gate.id,
32 glyph: gateGlyph(gate),
33 color: gateColor(gate),
34 title: gate.title,
35 where: whereOf(model, gate),
36 isBold: true,
37 ...(gate.href !== undefined && gate.href !== '' ? { href: gate.href } : {}),
38 ...(gate.command !== undefined && gate.command !== '' ? { command: gate.command } : {}),
39 })
40 }
41 for (const flag of model.drift) {
42 if (flag.label === '') continue
43 rows.push({ id: 'drift:' + flag.id, glyph: '≠', color: toneColor(flag.tone), title: flag.label, where: '', isBold: false })
44 }
45 return rows
46}
47
48/** The row's command as a button that puts it in the prompt, under the title. */
49function commandEl(ctx: Ctx, row: Row, key: string): El | false {
50 const { Box, Button } = ctx.kit
51 const command = row.command ?? ''
52 if (command === '') return false
53 return (
54 <Box paddingLeft={2} flexShrink={1}>
55 <Button key={'attention:fill:' + key} variant="secondary" onPress={safely(() => ctx.handlers.onFillNext(command))}>
56 {command}
57 </Button>
58 </Box>
59 )
60}
61
62function rowEl(ctx: Ctx, row: Row, key: string): El {
63 const { Box, Text, Link } = ctx.kit
64 const isBoxed = ctx.surface !== 'terminal'
65 const glyph = (
66 <Box width={1} flexShrink={0}>
67 <Text bold color={row.color}>
68 {row.glyph}
69 </Text>
70 </Box>
71 )
72 const titleText = (
73 <Text bold={row.isBold} color={row.color} wrap="wrap">
74 {row.title}
75 </Text>
76 )
77 // The title wins: it keeps up to 24 columns (its own length when shorter) and the where gives way first.
78 const title = (
79 <Box flexShrink={1} minWidth={ctx.bp === 'S' ? undefined : Math.min(Array.from(row.title).length, 24)}>
80 {row.href !== undefined ? (
81 <Link href={row.href} label={row.title}>
82 {titleText}
83 </Link>
84 ) : (
85 titleText
86 )}
87 </Box>
88 )
89 // The where may be cut to nothing: with no floor of its own it would hold its full length on the desktop
90 // and run past the card's border (a long PR caption did).
91 const where =
92 row.where !== '' ? (
93 <Box flexShrink={4} minWidth={0} overflow="hidden">
94 <Text dimColor wrap="truncate-end">
95 {row.where}
96 </Text>
97 </Box>
98 ) : (
99 false
100 )
101 const command = commandEl(ctx, row, key)
102 const line =
103 ctx.bp === 'S' ? (
104 <Box flexDirection="column" flexShrink={1}>
105 <Box gap={1}>
106 {glyph}
107 {title}
108 </Box>
109 {where !== false && <Box paddingLeft={2}>{where}</Box>}
110 </Box>
111 ) : (
112 <Box gap={1} flexShrink={1} flexGrow={1} minWidth={0}>
113 {glyph}
114 {title}
115 <Box flexGrow={1} />
116 {where}
117 </Box>
118 )
119 const body =
120 command === false ? (
121 line
122 ) : (
123 <Box flexDirection="column" flexShrink={1} flexGrow={1} minWidth={0}>
124 {line}
125 {command}
126 </Box>
127 )
128 return isBoxed ? (
129 <Box borderStyle="round" borderColor={row.color ?? 'subtle'} paddingX={1}>
130 {body}
131 </Box>
132 ) : (
133 body
134 )
135}
136
137export function attention(ctx: Ctx, model: PanelModel): El | false {
138 const { Box, Text } = ctx.kit
139 const rows = rowsOf(model)
140 if (rows.length === 0) return false
141 const keys = uniqueIds(rows.map(row => row.id))
142 const heading = t('attention.title', { count: String(rows.length) })
143 return (
144 <Box flexDirection="column">
145 {heading !== '' && (
146 <Text bold dimColor wrap="truncate-end">
147 {heading}
148 </Text>
149 )}
150 {rows.map((row, index) => rowEl(ctx, row, keys[index]!))}
151 </Box>
152 )
153}
154src/render/header.tsx 116 lines1// The header: where am I. Title, badge, freshness stamp and the Refresh button,
2// laid out per breakpoint so the title is what gives way and nothing overlaps.
3import { checkedText } from '../freshness'
4import type { Clock } from '../freshness'
5import { t } from '../strings/en'
6import type { PanelModel } from '../../types'
7import { showsCurrentItem } from './now'
8import { clockTime, safely } from './kit'
9import type { Ctx, El } from './kit'
10
11function badgeColor(mode: PanelModel['header']['mode']): string {
12 switch (mode) {
13 case 'side-job':
14 return 'warning'
15 case 'stage':
16 return 'claude'
17 case 'inferred':
18 return 'suggestion'
19 default:
20 return 'subtle'
21 }
22}
23
24function stampOf(model: PanelModel | null, clock: Clock | undefined): string {
25 if (model === null) return ''
26 if (clock !== undefined) return checkedText(model, clock)
27 return t('header.readAt', { time: clockTime(model.readAt) })
28}
29
30/** Row 1: title | (M/L) badge and stamp | Refresh. Row 2 (S only): badge and stamp. Then the error line. */
31export function header(ctx: Ctx, model: PanelModel | null, clock: Clock | undefined): El {
32 const { Box, Text, Button } = ctx.kit
33 const { bp, handlers } = ctx
34 const title = model === null ? t('pane.title') : model.header.title.toUpperCase()
35 const badge = model === null ? '' : model.header.badge
36 const stamp = stampOf(model, clock)
37 const isDimBadge = model?.header.mode === 'idle'
38 const error = model?.freshness?.error ?? ''
39 // The subtitle (where the plan stands) is dropped when the Now card already names the current item.
40 const subtitle = model === null || showsCurrentItem(model) ? '' : model.header.subtitle
41
42 // On M/L the title shrinks first, down to a small floor of its own; the badge and the stamp
43 // then give way (truncated); the Refresh button never shrinks. On S they sit on row 2, alone.
44 const badgeShrink = bp === 'S' ? 0 : 0.25
45 const badgeEl =
46 badge !== '' && model !== null ? (
47 <Box flexShrink={badgeShrink}>
48 <Text bold color={badgeColor(model.header.mode)} dimColor={isDimBadge} wrap="truncate-end">
49 {badge}
50 </Text>
51 </Box>
52 ) : (
53 false
54 )
55 const refresh = bp === 'S' ? '↻' : `↻ ${t('header.refresh')}`.trimEnd()
56
57 return (
58 <Box flexDirection="column">
59 <Box gap={1}>
60 {title !== '' && (
61 <Box flexShrink={1} minWidth={Math.min(Array.from(title).length, 8)}>
62 <Text bold wrap="truncate-end">
63 {title}
64 </Text>
65 </Box>
66 )}
67 <Box flexGrow={1} />
68 {bp !== 'S' && badgeEl}
69 {bp !== 'S' && stamp !== '' && (
70 <Box flexShrink={0.25}>
71 <Text dimColor wrap="truncate-end">
72 {stamp}
73 </Text>
74 </Box>
75 )}
76 <Box flexShrink={0}>
77 <Button key="refresh" hotkey="r" onPress={safely(handlers.onRefresh)}>
78 {refresh}
79 </Button>
80 </Box>
81 </Box>
82 {bp === 'S' && (badge !== '' || stamp !== '') && (
83 <Box gap={1}>
84 {badgeEl}
85 {stamp !== '' && (
86 <Box flexShrink={1}>
87 <Text dimColor wrap="truncate-end">
88 {stamp}
89 </Text>
90 </Box>
91 )}
92 </Box>
93 )}
94 {subtitle !== '' && (
95 <Text dimColor wrap="wrap">
96 {subtitle}
97 </Text>
98 )}
99 {error !== '' && (
100 <Box gap={1}>
101 <Box flexShrink={0}>
102 <Text bold color="error">
103 !
104 </Text>
105 </Box>
106 <Box flexShrink={1}>
107 <Text color="error" wrap="wrap">
108 {error}
109 </Text>
110 </Box>
111 </Box>
112 )}
113 </Box>
114 )
115}
116src/render/kit.ts 113 lines1// Shared by the pane's drawing modules: the element kit, the press handlers,
2// the breakpoint and the small pure helpers every block uses.
3import type { Elements, RenderSurface } from 'claude-code'
4import type { Gate, Tone } from '../../types'
5
6export type Kit = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button' | 'Link'> & {
7 /** Absent on the terminal. */
8 Svg?: Elements['desktop']['Svg']
9}
10export type El = ReturnType<Kit['Box']>
11
12export type Handlers = {
13 onRefresh: () => void | Promise<void>
14 /** `isOpen` is what the section should be after the press. */
15 onToggle: (sectionId: string, isOpen: boolean) => void | Promise<void>
16 /** A provider action button. */
17 onAction: (actionId: string) => void | Promise<void>
18 /** "put in prompt" on the Next block. */
19 onFillNext: (command: string) => void | Promise<void>
20}
21
22/** narrow S < 48, medium M 48-79, wide L >= 80 (the Pane's `bodyColumns`). */
23export type Bp = 'S' | 'M' | 'L'
24
25/** A width that is no finite number reads as medium. */
26export function breakpoint(columns: number): Bp {
27 if (!Number.isFinite(columns)) return 'M'
28 return columns < 48 ? 'S' : columns < 80 ? 'M' : 'L'
29}
30
31/**
32 * Ids made unique for use as keys: the first of an id keeps it, a repeat gets `~<index>`.
33 * Unique ids come back as they are.
34 */
35export function uniqueIds(ids: string[]): string[] {
36 const seen = new Set<string>()
37 return ids.map((id, index) => {
38 const out = seen.has(id) ? `${id}~${index}` : id
39 seen.add(id)
40 return out
41 })
42}
43
44/** `text` cut to `max` characters with a trailing … (a model string, not copy). */
45export function clip(text: string, max: number): string {
46 const chars = Array.from(text)
47 return chars.length <= max ? text : chars.slice(0, max - 1).join('') + '…'
48}
49
50/** What every block needs to draw itself. */
51export type Ctx = {
52 kit: Kit
53 surface: RenderSurface
54 bp: Bp
55 columns: number
56 handlers: Handlers
57}
58
59/** A press handler that never leaves a rejection unhandled. */
60export function safely(run: () => void | Promise<void>): () => void {
61 return () => {
62 void (async () => {
63 try {
64 await run()
65 } catch {
66 // A failed press changes nothing on screen.
67 }
68 })()
69 }
70}
71
72export function toneColor(tone: Tone): string | undefined {
73 switch (tone) {
74 case 'accent':
75 return 'claude'
76 case 'success':
77 return 'success'
78 case 'warning':
79 return 'warning'
80 case 'error':
81 return 'error'
82 default:
83 return undefined
84 }
85}
86
87export function gateColor(gate: Pick<Gate, 'tone'>): string {
88 switch (gate.tone) {
89 case 'error':
90 return 'error'
91 case 'success':
92 return 'success'
93 case 'accent':
94 return 'claude'
95 default:
96 return 'warning'
97 }
98}
99
100/** ‖ parked, ? waiting on a person (you, for a review), ✓ approved, otherwise ⊘ when blocking and ‖ when only a warning. */
101export function gateGlyph(gate: Pick<Gate, 'kind' | 'tone'>): string {
102 if (gate.kind === 'parked') return '‖'
103 if (gate.kind === 'awaiting-approval' || gate.kind === 'questions-unanswered' || gate.kind === 'review-requested') return '?'
104 if (gate.kind === 'approved' || gate.kind === 'approved-comments') return '✓'
105 return gate.tone === 'error' ? '⊘' : '‖'
106}
107
108export function clockTime(epochMs: number): string {
109 const date = new Date(epochMs)
110 const pad = (n: number) => String(n).padStart(2, '0')
111 return `${pad(date.getHours())}:${pad(date.getMinutes())}`
112}
113src/render/now.tsx 226 lines1// The Now card: what the session is on, what is next, and the provider's actions.
2import { t } from '../strings/en'
3import type { ContextLine, Item, PanelModel, Section } from '../../types'
4import { safely, toneColor, uniqueIds } from './kit'
5import type { Ctx, El } from './kit'
6import { itemLook } from './item'
7
8const MODE_BORDER: Record<PanelModel['header']['mode'], string> = {
9 stage: 'claude',
10 'side-job': 'warning',
11 inferred: 'suggestion',
12 idle: 'subtle',
13}
14
15/** The first current item, with its section and its place among the section's executed items. */
16function currentOf(model: PanelModel): { item: Item; section: Section; index: number; total: number } | null {
17 for (const section of model.sections) {
18 const item = section.items.find(one => one.isCurrent)
19 if (item === undefined) continue
20 const counted = section.items.filter(one => one.status !== 'skipped')
21 return { item, section, index: counted.indexOf(item) + 1, total: counted.length }
22 }
23 return null
24}
25
26/** True when the Now card names a current item (its place duplicates the header's subtitle). */
27export function showsCurrentItem(model: PanelModel): boolean {
28 return model.header.mode !== 'side-job' && model.header.mode !== 'idle' && currentOf(model) !== null
29}
30
31function dimLabel(ctx: Ctx, text: string): El | false {
32 const { Text } = ctx.kit
33 return (
34 text !== '' && (
35 <Text bold dimColor wrap="truncate-end">
36 {text}
37 </Text>
38 )
39 )
40}
41
42function dimCaption(ctx: Ctx, text: string | undefined): El | false {
43 const { Text } = ctx.kit
44 return (
45 text !== undefined &&
46 text !== '' && (
47 <Text dimColor wrap="wrap">
48 {text}
49 </Text>
50 )
51 )
52}
53
54/** One line about the side-job session (its branch, its PR, its distance from main): dim, wrapped, a link when it has one. */
55function contextLine(ctx: Ctx, line: ContextLine): El | false {
56 const { Text, Link } = ctx.kit
57 if (line.text === '') return false
58 const text = (
59 <Text dimColor={line.tone === undefined} color={line.tone === undefined ? undefined : toneColor(line.tone)} wrap="wrap">
60 {line.text}
61 </Text>
62 )
63 return line.href !== undefined && line.href !== '' ? (
64 <Link href={line.href} label={line.text}>
65 {text}
66 </Link>
67 ) : (
68 text
69 )
70}
71
72/** The Now card; false when it would hold only a label (no item, headline, caption or Next). */
73export function nowCard(ctx: Ctx, model: PanelModel): El | false {
74 const { Box, Text } = ctx.kit
75 const { header } = model
76 const mode = header.mode
77
78 let top: (El | false)[]
79 if (mode === 'side-job' || mode === 'idle') {
80 const key = mode === 'side-job' ? 'now.sideJob' : 'now.idle'
81 const headline = t(`${key}.headline`)
82 top = [
83 dimLabel(ctx, t(`${key}.label`)),
84 headline !== '' && (
85 <Text bold wrap="wrap">
86 {headline}
87 </Text>
88 ),
89 dimCaption(ctx, header.caption),
90 ...(mode === 'side-job' ? (header.context ?? []).map(line => contextLine(ctx, line)) : []),
91 ]
92 } else {
93 const current = currentOf(model)
94 if (current === null) {
95 if (header.caption === '' && (!model.next || model.next.command === '')) return false
96 top = [dimLabel(ctx, t('now.nextUp')), dimCaption(ctx, header.caption)]
97 } else {
98 const look = itemLook(current.item)
99 // A current item that lives only in this worktree says where, in the Now card's own words.
100 const word = current.item.landing === 'local' ? t('now.landing.local') : look.word
101 const base = header.base ?? ''
102 // A current item the plan does not count (skipped) has no place to name.
103 const where =
104 current.index === 0 ? '' : t('now.where', { section: current.section.title, index: String(current.index), total: String(current.total) })
105 top = [
106 <Box gap={1}>
107 {dimLabel(ctx, t('now.label'))}
108 {where !== '' && (
109 <Box flexShrink={1}>
110 <Text dimColor wrap="truncate-end">
111 {where}
112 </Text>
113 </Box>
114 )}
115 </Box>,
116 <Box gap={1}>
117 <Box flexShrink={0}>
118 <Text bold color={look.color} dimColor={look.color === undefined}>
119 {look.glyph}
120 </Text>
121 </Box>
122 <Box flexShrink={1}>
123 <Text bold wrap="wrap">
124 {current.item.title}
125 </Text>
126 </Box>
127 {word !== '' && (
128 <Box flexShrink={0}>
129 <Text color={look.wordColor}>{word}</Text>
130 </Box>
131 )}
132 </Box>,
133 dimCaption(ctx, base),
134 dimCaption(ctx, current.item.caption !== undefined && current.item.caption !== '' ? current.item.caption : header.caption),
135 ]
136 }
137 }
138
139 const next = nextBlock(ctx, model)
140 return (
141 <Box flexDirection="column" borderStyle="round" borderColor={MODE_BORDER[mode]} paddingX={1}>
142 {top}
143 {next !== false && (
144 <Box flexDirection="column" marginTop={1}>
145 {next}
146 </Box>
147 )}
148 </Box>
149 )
150}
151
152/** "Next /aidlc" over the dim reason, and the Put in prompt button: beside it from M, below it on S. Hidden without a command. */
153function nextBlock(ctx: Ctx, model: PanelModel): El | false {
154 const { Box, Text, Button } = ctx.kit
155 const next = model.next
156 if (!next || next.command === '') return false
157 const label = t('next.label')
158 const fill = t('next.fill')
159 const text = (
160 <Box flexDirection="column" flexShrink={1}>
161 <Box gap={1}>
162 {label !== '' && (
163 <Box flexShrink={0}>
164 <Text dimColor>{label}</Text>
165 </Box>
166 )}
167 <Box flexShrink={1}>
168 <Text bold color="claude" wrap="truncate-end">
169 {next.command}
170 </Text>
171 </Box>
172 </Box>
173 {next.reason !== '' && (
174 <Text dimColor wrap="wrap">
175 {next.reason}
176 </Text>
177 )}
178 </Box>
179 )
180 const button =
181 fill !== '' ? (
182 <Box flexShrink={0}>
183 <Button
184 key="next:fill"
185 hotkey="p"
186 variant={model.header.mode === 'side-job' ? 'secondary' : 'primary'}
187 onPress={safely(() => ctx.handlers.onFillNext(next.command))}
188 >
189 {fill}
190 </Button>
191 </Box>
192 ) : (
193 false
194 )
195 if (ctx.bp === 'S') {
196 return (
197 <Box flexDirection="column">
198 {text}
199 {button}
200 </Box>
201 )
202 }
203 return (
204 <Box gap={1} justifyContent="space-between">
205 {text}
206 {button}
207 </Box>
208 )
209}
210
211export function actionsRow(ctx: Ctx, model: PanelModel): El | false {
212 const { Box, Button } = ctx.kit
213 const actions = (model.actions ?? []).filter(action => action.label !== '')
214 if (actions.length === 0) return false
215 const keys = uniqueIds(actions.map(action => action.id))
216 return (
217 <Box gap={1} flexWrap="wrap">
218 {actions.map((action, index) => (
219 <Button key={'action:' + keys[index]} onPress={safely(() => ctx.handlers.onAction(action.id))}>
220 {action.label}
221 </Button>
222 ))}
223 </Box>
224 )
225}
226src/render/plan.tsx 310 lines1// The Plan block: the overall ring and summary, then every section.
2import { t } from '../strings/en'
3import type { Gate, PanelModel, Section } from '../../types'
4import { safely, uniqueIds } from './kit'
5import type { Ctx, El } from './kit'
6import { foldGutters, foldRow, gateCard, itemRow, openCap } from './item'
7import { clampPercent, mixOf, ring, strip } from './ring'
8
9const BIG_RING = 40
10const SMALL_RING = 16
11const LONG_AFTER = { S: 6, M: 10, L: 16 } as const
12const OPEN_CAP = { S: 8, M: 12, L: 20 } as const
13const STRIP_FROM_ITEMS = 10
14
15/** The Plan fold's state key: `#` keeps it clear of every section id. */
16const PLAN_KEY = '#plan'
17
18/** The overall percent and the line that says it: the provider's own label, else "50% done, 11 of 22". */
19function overallText(model: PanelModel): { value: number | undefined; summary: string } {
20 const { percent, counts } = model.overall
21 // One value for the ring and the summary; a percent that is no number draws neither.
22 const value = percent !== undefined && Number.isFinite(percent) ? clampPercent(percent) : undefined
23 // The provider's own label (e.g. "74% of effort · 189/287 tasks") replaces the renderer's summary.
24 const own = model.overall.label ?? ''
25 const summary =
26 own !== ''
27 ? own
28 : value === undefined
29 ? ''
30 : t('plan.summary', { percent: String(value), done: String(counts.done ?? 0), total: String(counts.total) })
31 return { value, summary }
32}
33
34/** `isOpen` is set only where the provider folds the whole plan: the title is then the toggle (▾ open, ▸ folded). */
35export function planHeading(ctx: Ctx, model: PanelModel, isOpen?: boolean): El {
36 const { Box, Text, Button } = ctx.kit
37 const { value, summary } = overallText(model)
38 const title = t('plan.title')
39 // The text sits beside the ring in a Box that shrinks; a long label wraps instead of being cut.
40 const labels = (
41 <Box flexDirection={ctx.kit.Svg ? 'column' : 'row'} gap={ctx.kit.Svg ? 0 : 1} flexShrink={1}>
42 {title !== '' && (
43 <Box flexShrink={0}>
44 {isOpen === undefined ? (
45 <Text bold dimColor>
46 {title}
47 </Text>
48 ) : (
49 <Button plain key="plan:fold" onPress={safely(() => ctx.handlers.onToggle(PLAN_KEY, !isOpen))}>
50 <Text bold dimColor>{`${isOpen ? '▾' : '▸'} ${title}`}</Text>
51 </Button>
52 )}
53 </Box>
54 )}
55 {summary !== '' && (
56 <Box flexShrink={1}>
57 <Text bold wrap="wrap">
58 {summary}
59 </Text>
60 </Box>
61 )}
62 </Box>
63 )
64 return (
65 <Box gap={1} paddingBottom={ctx.kit.Svg ? 1 : 0}>
66 {value !== undefined && ring(ctx, value, BIG_RING, true)}
67 {labels}
68 </Box>
69 )
70}
71
72/** The gates the plan draws: not the rows that belong under Needs attention only. */
73export const planGates = (model: PanelModel): Gate[] => model.gates.filter(gate => gate.isAttentionOnly !== true)
74
75export function orphanGates(model: PanelModel): Gate[] {
76 const known = new Set(model.sections.map(section => section.id))
77 return planGates(model).filter(gate => !known.has(gate.sectionId))
78}
79
80/** The folded plan: one row with the toggle, the summary, and the ring; a ‖ when a gate hides in it. */
81function foldedPlan(ctx: Ctx, model: PanelModel): El {
82 const { Box, Text, Button } = ctx.kit
83 const { value, summary } = overallText(model)
84 const title = t('plan.title')
85 const hint = t('plan.foldedHint')
86 const hasGate = planGates(model).length > 0
87 return (
88 <Box gap={1}>
89 <Box flexShrink={0}>
90 <Button plain key="plan:fold" onPress={safely(() => ctx.handlers.onToggle(PLAN_KEY, true))}>
91 <Text bold dimColor>{`▸ ${title}`}</Text>
92 </Button>
93 </Box>
94 {summary !== '' && (
95 <Box flexShrink={1}>
96 <Text dimColor wrap="truncate-end">
97 {summary}
98 </Text>
99 </Box>
100 )}
101 <Box flexGrow={1} />
102 <Box flexShrink={0} gap={1}>
103 {hasGate && (
104 <Text bold color="warning">
105 ‖
106 </Text>
107 )}
108 {hint !== '' && ctx.bp !== 'S' && <Text dimColor>{hint}</Text>}
109 {value !== undefined && ring(ctx, value, SMALL_RING, false)}
110 </Box>
111 </Box>
112 )
113}
114
115export function plan(ctx: Ctx, model: PanelModel, expanded: Record<string, boolean>): El {
116 const { Box } = ctx.kit
117 // A provider may fold the whole plan (a side job); the person's own choice wins.
118 const isFoldable = model.isPlanCollapsed !== undefined
119 const isOpen = !isFoldable || (expanded[PLAN_KEY] ?? !model.isPlanCollapsed)
120 if (!isOpen) return foldedPlan(ctx, model)
121 // A repeated section id gets a distinct key (and fold state); its gates belong to the first.
122 const ids = uniqueIds(model.sections.map(section => section.id))
123 // One width for every count, from the widest ("18/18 ✓"), so the rings and counts line up.
124 const countWidth = Math.max(0, ...model.sections.map(section => Array.from(countText(section)).length))
125 const firstProposed = model.sections.findIndex(section => section.isProposed === true)
126 return (
127 <Box flexDirection="column">
128 {model.sections.length > 0 && planHeading(ctx, model, isFoldable ? true : undefined)}
129 {orphanGates(model).map(gate => gateCard(ctx, gate))}
130 {model.sections.map((section, index) =>
131 sectionEl(
132 ctx,
133 section,
134 ids[index]!,
135 ids[index] === section.id ? planGates(model).filter(gate => gate.sectionId === section.id) : [],
136 expanded,
137 countWidth,
138 index === firstProposed,
139 ),
140 )}
141 </Box>
142 )
143}
144
145/** Done/total as the section's count column says it ("3/3 ✓" when built); '' when there is nothing to say. */
146function countText(section: Section): string {
147 if (section.isProposed === true || section.status === 'skipped') return ''
148 const isBuilt = section.status === 'built'
149 const count = section.counts && section.counts.total > 0 ? t('section.count', { done: String(section.counts.done), total: String(section.counts.total) }) : ''
150 if (count === '') return isBuilt ? '✓' : ''
151 return isBuilt ? `${count} ✓` : count
152}
153
154/** A section with no items and nothing counted: no ring, a dim title. */
155const isEmptySection = (section: Section): boolean => section.items.length === 0 && !(section.counts && section.counts.total > 0)
156
157function sectionEl(
158 ctx: Ctx,
159 section: Section,
160 id: string,
161 gates: Gate[],
162 expanded: Record<string, boolean>,
163 countWidth: number,
164 isFirstProposed: boolean,
165): El {
166 const { Box, Text } = ctx.kit
167 if (section.isProposed === true) {
168 // The first proposed card follows a dim "Proposed" divider; the cards carry no tag of their own.
169 const divider = isFirstProposed ? t('section.proposedHeading') : ''
170 return (
171 <Box flexDirection="column">
172 {divider !== '' && (
173 <Box marginTop={1}>
174 <Text dimColor>{divider}</Text>
175 </Box>
176 )}
177 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
178 <Text bold dimColor wrap="truncate-end">
179 {section.title}
180 </Text>
181 {section.caption !== undefined && section.caption !== '' && (
182 <Text dimColor wrap="wrap">
183 {section.caption}
184 </Text>
185 )}
186 </Box>
187 </Box>
188 )
189 }
190
191 const isOpen = expanded[id] ?? !section.isCollapsed
192 const heading = sectionHeading(ctx, section, id, isOpen, isOpen ? [] : gates, countWidth)
193 if (!isOpen) return <Box flexDirection="column">{heading}</Box>
194
195 // Gates belong to the first item with their id, so a repeated id never doubles a gate.
196 const owner = new Map<string, number>()
197 section.items.forEach((item, index) => {
198 if (!owner.has(item.id)) owner.set(item.id, index)
199 })
200 const atTop = gates.filter(gate => gate.afterItemId === undefined || !owner.has(gate.afterItemId))
201 const afterIndex = (index: number) => gates.filter(gate => gate.afterItemId !== undefined && owner.get(gate.afterItemId) === index)
202
203 // Done-folding: a long section hides the done items nobody needs to see, but only behind a row that can bring them back.
204 const cap = LONG_AFTER[ctx.bp]
205 const isAll = expanded[`${id}#all`] === true
206 const folding = foldRow(section.items, index => afterIndex(index).length > 0, cap, isAll, count => t('section.doneFold', { count: String(count) }))
207 const { hidden } = folding
208 const moreText = folding.text
209 const less = moreText !== '' && isAll ? t('section.doneHide') : ''
210
211 // The open-items cap, after done-folding: the first few and the current one, behind a row that shows the rest.
212 const isRest = expanded[`${id}#rest`] === true
213 const visible = section.items.map((_, index) => index).filter(index => !hidden.has(index))
214 const capped = openCap(visible, index => section.items[index]!.isCurrent, OPEN_CAP[ctx.bp], isRest, count =>
215 t('section.moreItems', { count: String(count) }),
216 )
217 const drawn = visible.filter(index => !capped.capped.has(index))
218 // Fold gutters over what is drawn.
219 const gutters = foldGutters(drawn.map(index => section.items[index]!))
220 const folds = new Map(drawn.map((index, at) => [index, gutters[at] ?? ''] as const))
221 const fewer = isRest && capped.text !== '' ? t('section.fewerItems') : ''
222
223 return (
224 <Box flexDirection="column">
225 {heading}
226 {section.caption !== undefined && section.caption !== '' && (
227 <Box paddingLeft={2}>
228 <Text dimColor wrap="wrap">
229 {section.caption}
230 </Text>
231 </Box>
232 )}
233 {atTop.map(gate => gateCard(ctx, gate))}
234 {hidden.size > 0 && foldButton(ctx, `more:${id}`, `✓ ${moreText}`, () => ctx.handlers.onToggle(`${id}#all`, true))}
235 {less !== '' && foldButton(ctx, `less:${id}`, less, () => ctx.handlers.onToggle(`${id}#all`, false))}
236 {drawn.map(index => (
237 <Box flexDirection="column">
238 {itemRow(ctx, section.items[index]!, folds.get(index) ?? '')}
239 {afterIndex(index).map(gate => gateCard(ctx, gate))}
240 </Box>
241 ))}
242 {capped.capped.size > 0 && foldButton(ctx, `rest:${id}`, capped.text, () => ctx.handlers.onToggle(`${id}#rest`, true))}
243 {fewer !== '' && foldButton(ctx, `restless:${id}`, fewer, () => ctx.handlers.onToggle(`${id}#rest`, false))}
244 </Box>
245 )
246}
247
248function foldButton(ctx: Ctx, key: string, label: string, press: () => void | Promise<void>): El {
249 const { Box, Button } = ctx.kit
250 return (
251 <Box paddingLeft={2}>
252 <Button plain key={key} onPress={safely(press)}>
253 {label}
254 </Button>
255 </Box>
256 )
257}
258
259/** ▾/▸ + title on the left; on the right the hidden-gate mark, the strip, the ring and the count (✓ when built), or "skipped". */
260function sectionHeading(ctx: Ctx, section: Section, id: string, isOpen: boolean, hiddenGates: Gate[], countWidth: number): El {
261 const { Box, Text, Button } = ctx.kit
262 const hasError = hiddenGates.some(gate => gate.tone === 'error')
263 return (
264 <Box gap={1}>
265 <Box flexShrink={1}>
266 <Button plain key={'section:' + id} dimColor={section.status === 'skipped' || isEmptySection(section)} onPress={safely(() => ctx.handlers.onToggle(id, !isOpen))}>
267 <Text bold={section.status === 'active'} wrap="truncate-end">{`${isOpen ? '▾' : '▸'} ${section.title}`}</Text>
268 </Button>
269 </Box>
270 <Box flexGrow={1} />
271 <Box flexShrink={0} gap={1}>
272 {hiddenGates.length > 0 && (
273 <Text bold color={hasError ? 'error' : 'warning'}>
274 {hasError ? '⊘' : '‖'}
275 </Text>
276 )}
277 {!isOpen && ctx.bp !== 'S' && section.items.length >= STRIP_FROM_ITEMS && !isFinished(section) && strip(ctx, mixOf(section.items), ctx.bp === 'L' ? 96 : 64)}
278 {sectionTail(ctx, section, countWidth)}
279 </Box>
280 </Box>
281 )
282}
283
284/** Built or at 100%: the ring and "n/n ✓" already say it, so no strip. */
285const isFinished = (section: Section): boolean => section.status === 'built' || (section.percent ?? 0) >= 100
286
287function sectionTail(ctx: Ctx, section: Section, countWidth: number): El | false {
288 const { Box, Text } = ctx.kit
289 if (section.status === 'skipped') {
290 const word = t('section.skipped')
291 return word !== '' && <Text dimColor>{word}</Text>
292 }
293 const isBuilt = section.status === 'built'
294 const text = countText(section)
295 const hasRing = section.percent !== undefined && Number.isFinite(section.percent) && !isEmptySection(section)
296 if (!hasRing && text === '') return false
297 return (
298 <Box gap={1}>
299 {hasRing && ring(ctx, section.percent ?? 0, SMALL_RING, false)}
300 <Box minWidth={countWidth} justifyContent="flex-end">
301 {text !== '' && (
302 <Text dimColor={!isBuilt} color={isBuilt ? 'success' : undefined}>
303 {text}
304 </Text>
305 )}
306 </Box>
307 </Box>
308 )
309}
310