SLOPSHOPPER

Project Panel

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…

newpanebandguardcommandmodel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · project-panel
│ ┃ panel ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ Project [ ↻ Refresh ] │ ⏺ Read(src/auth.ts) │ ┃ │ No plan found for this session yet. │ ⎿ Read 6 lines │ ┃ ╰──────────────────────────────────────────╯ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · panel
╭──────────────────────────────────────────────────────────╮ │ Project [ ↻ Refresh ] │ │ No plan found for this session yet. │ ╰──────────────────────────────────────────────────────────╯
README

project-panel

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:

ColumnsLayout
under 48narrowone 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 79mediumone 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 upwidewith 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.

Install

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

Providers

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.

ProviderReadsStatus
aidlcan AI-DLC v2 workspace (aidlc/spaces/)shipped
ledgera task ledger (WORKPLAN.md and kin) read against its pull requestsshipped (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.

The ledger provider

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.

What it detects

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:

  1. the path named by task-ledger: in the front matter of docs/DELIVERY.md;
  2. a root WORKPLAN.md, ROADMAP.md, PLAN.md, TODO.md (any case), in that order;
  3. the same names under docs/;
  4. 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.

How status is derived

EvidenceStatus
a merged PR for the taskdone
an open PRin progress, with a #N chip
the PR carries the qa:failed labelfailed
only sub-slice PRs (T06a, T06b for a task T06)partial if one merged, in progress if only open ones
nothing, or only closed PRswhat 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.

What it runs

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.

Forges: GitHub and GitLab

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:

  1. the forge option is none: no forge, no requests, no complaint;
  2. no readable origin: no forge, and the header says so;
  3. the forge option is github or gitlab: that forge for the origin's host, no probe;
  4. the host is github.com: GitHub;
  5. glab auth status --hostname <host> succeeds: GitLab;
  6. gh auth status --hostname <host> succeeds: GitHub Enterprise;
  7. otherwise GitLab anyway (a self-hosted host that is not GitHub is far more often GitLab), whose calls then report the real problem.

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 ispull request, #33merge request, !275
repositorygh repo view --json url,defaultBranchRefglab api --hostname <host> projects/<url-encoded path>
listgh 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 --limitno flag for open (glab's default), --merged or --closed
status (open only)in the same list: review decision, merge state, check rollupat 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 changegh pr view <n> --json files.../merge_requests/<n>/diffs?per_page=100 (paged), else .../changes on a GitLab without /diffs
remote fingerprintgh pr list ... --json number,state,mergedAt,headRefOidglab mr list ... -P 30 -o updated_at -S desc
AI-DLC remote fingerprintthe same lists as a loadthe 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:

CauseMeantShown as
missingthe CLI does not start (and <cli> --version does not either)glab missing
not logged inthe call failed and <cli> auth status --hostname <host> failsglab not logged in to gitlab.example.test
unreachablethe call timed out, or failed with a network-looking errorcould not reach gitlab.example.test
failedanything elseglab failed
no originthe repository has no readable originno origin remote

The causes are found only after a failure, with two cheap probes (--version, auth status, 5 s each).

Product sections: AI proposes, you pin

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.

Safety

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.

The AI-DLC provider

What it reads

From the session's own working tree (its branch, uncommitted changes included):

SourceUsed for
aidlc/spaces/detection — walked up from the session root; aidlc-docs/ (v1) is ignored
aidlc/.aidlc-sessions/<session>.binding.jsonthe intent this session is bound to
aidlc/active-space, intents/active-intentthe shared cursor when the session is unbound
intents/intents.jsonthe intent's display slug
<intent>/aidlc-state.mdphases, stages, markers, current stage, parked state
<intent>/.aidlc-engine/active-directive.jsonthe step on screen, when fresher than the state
<intent>/inception/units-generation/unit-of-work-dependency.md, unit-of-work.mdthe 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 rooteach 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/HEADthe branch, for stage-vs-side-job mode
.claude/settings.json at the repository's topwhether 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):

CommandUsed for
git show origin/<default>:<intent>/aidlc-state.mdwhere each stage stands on the default branch
git remote get-url origin (then, off github.com, glab auth status / gh auth status), cached per repositorywhich 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

Source 54 files
hooks/register.tsx 388 lines
1import { 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}
388
src/model.ts 31 lines
1// 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}
31
src/refresh.ts 798 lines
1// 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}
798
src/render.tsx 93 lines
1// 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}
93
src/strings/en.ts 287 lines
1// 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}
287
src/detect.ts 24 lines
1// 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}
24
src/freshness.ts 29 lines
1// 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}
29
src/render/attention.tsx 154 lines
1// 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}
154
src/render/header.tsx 116 lines
1// 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}
116
src/render/kit.ts 113 lines
1// 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}
113
src/render/now.tsx 226 lines
1// 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}
226
src/render/plan.tsx 310 lines
1// 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