SLOPSHOPPER

cockpit

Cockpit Board: one side panel with the account's usage windows, this chat's context and cache (warm or cold, the cold price, Keep warm, the Handoff note and…

newpanebandspinnerguardcommand
v3.15.0MITupdated 2026-10-10servaes/cockpit/cockpit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cockpit
│ ┃ Cockpit Board ✕ › fix the failing auth test and add an╭────────────────────────────╮ │ ┃ ╭──────────────────────────────────────────╮ │ cockpit │ │ ┃ │ ▶ See changes step 1 of 3 │ ⏺ Read(src/auth.ts) │ Handoff: writing the note… │ │ ┃ │ 1 2 3 │ ⎿ Read 6 lines ╰────────────────────────────╯ │ ┃ │ src/auth.ts │ ⏺ Update(src/auth.ts) ╭────────────────────────────────────────────╮ │ ┃ │ Edit +2 -1 │ ⎿ Added 2 lines, re│ cockpit │ │ ┃ │ │ ⏺ Bash(rm -rf build &&│ Handoff note saved and copied (0 read, 0 │ │ ┃ │ - if (!claims) throw new Error('invalid… │ ⎿ Denied by cockpit│ re-written, $0.00). Paste it as a new │ │ ┃ │ + if (!claims || claims.exp < Date.now(… │ │ chat's first message. │ │ ┃ │ + await audit('refresh', claims.sub) │ ● Done. refresh now re╰────────────────────────────────────────────╯ │ ┃ │ │ │ ┃ │ [ ◀ Prev ] [ Next ▶ ] [ Close ] │ ✻ Worked for 42s · done 4:20 PM │ ┃ ╰──────────────────────────────────────────╯ │ › /cockpit │ ⎿ cockpit: Cockpit Board on /work/app (follows the cwd). │ │ ⟨Claude Code's own drawing⟩ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────── │ ◆ Agent routing suggestion │ Type your message to get a suggestion, or pick how to run it: │ [ Do it here ] [ Hand to a cheaper agent ] [ Open in a new chat ] [ Split across agents ] [ Plan first, then build │ Codex second opinion: not installed [ Why not? ] ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ╭─────────────────────────────────────────────────────────────────────────────────────────────────── │ ◆ Agent routing suggestion │ Type your message to get a suggestion, or pick how to run it: │ [ Do it here ] [ Hand to a cheaper agent ] [ Open in a new chat ] [ Split across agents ] [ Plan f │ Codex second opinion: not installed [ Why not? ] ╰───────────────────────────────────────────────────────────────────────────────────────────────────
Pane · Cockpit Board
╭──────────────────────────────────────────────────────────╮ │ ▶ See changes step 1 of 3 │ │ 1 2 3 │ │ src/auth.ts │ │ Edit +2 -1 │ │ │ │ - if (!claims) throw new Error('invalid token') │ │ + if (!claims || claims.exp < Date.now() / 1000) thr │ │ + await audit('refresh', claims.sub) │ │ │ │ [ ◀ Prev ] [ Next ▶ ] [ Close ] │ ╰──────────────────────────────────────────────────────────╯
README

cockpit

A personal Claude Code mod: one side panel, titled "Cockpit Board", and one plugin. Everything the board shows ships inside it: the file tree, the crew skill with its worker agents and progress band, the goal meter, Replay Theater, the Caution guard (inside Needs you), Ship and cache-tax. No other mod is needed.

Install

In Claude Code:

/plugin marketplace add servaes/cockpit
/plugin install cockpit@my-mods

What the board shows

On the desktop (since 3.0; compact since 3.1) the board is drawn, not typed: two zones, ACCOUNT (Session and Week, one thin row each) and THIS CHAT under a rule. Each section is one heading row (icon, title, a status pill, what it means, the value), a thin bar and at most one line of detail; no boxes. Session, Week, Context and Cache speak one scale, the same words and colours everywhere: fine (green), watch (amber), act now (red), and the bar takes that colour. The usage bars carry a pace marker (how much of the window has gone by) and the time to reset. Each cache button has its price first in its own legend (≈ $0.02/ping · …, ≈ $0.07 · …). The terminal draws the same blocks as text rows.

The account's windows (no header; the rows say it), from $.session.usage():

  • Session — the 5-hour window: a word (fine, high, near cap), the bar, the percent, when it resets and how long that is (resets Fri 1:30 AM (in 2h40m)), then one short "so what" line (40% of the session gone, 5% used · room to spare).
  • Week — the 7-day window, the same way (in 1d2h).

After each percent, in brackets and not bold, what it is worth on your plan: the plan's price a month over 4.35 weeks (60% (~$28) on Max 20x). The plan comes from the tier Claude Code saved in ~/.claude.json, or from the plan setting. A session percent counts as the share of the week it takes, learned from the readings (about 0.1 week % per session % until it has seen 5 points).

Every section's icon takes the colour of its tag (green fine, amber watch, red act now), so the heading row says the state at a glance.

THIS CHAT · 45.0M tokens ≈ $2.21 (if API) (the heading carries the tokens this chat's requests have used, its agents' included, and their price at API rates; on a subscription that is a yardstick, not a bill) — everything about the conversation you are in, each part separated by a thin line. Each section carries an icon and a colour (◔ Context, ⚡ Cache, ◆ Progress, ◎ Goals, ⚙ Agents, ⚠ Needs you, ▤ Files, ⇡ Ship):

  1. Context — how full the context window is, with token-weather's forecast words and a one-line "so what" under it (also when there is no reading yet). After the tokens (200k / 1.0M) comes what messages cost at API list prices: Last msg: $X (the last turn's calls summed: cache reads, cache writes and output) and Next ≈ $Y (the context read from the cache plus ~1k tokens out). Once the cache has gone cold (an hour since the last call) it reads ⚠ Next ≈ $Z, the whole context written again at the cache-write rate. The prices come live from Anthropic's pricing page (platform.claude.com/docs/en/about-claude/pricing.md), read at session start and once a day; the CACHE_PRICES table in register.tsx is the fallback when it cannot be reached.
  2. Cache — the prompt cache, from cache-tax. A reply leaves the whole chat in a one-hour cache, so the next turn reads it for pennies; after an idle hour the next send re-writes it all at the cache-write rate. The row shows warm with the hour draining (a short bar and the minutes left) or COLD with how long. The lines under it have the tokens in the cache, what a cold re-read would cost and what a warm turn costs. Then one button per row, each with its legend to the right (the legends carry the keepwarm and handoff state; there is no separate state line):
  3. ♨ Keep warm 6h / ■ Stop warming — a tool-less ping every 50 idle minutes keeps the cache read instead of re-written, for six hours (/keepwarm 90m for a window of your own, /keepwarm always, /keepwarm off). Pings cost tokens: about one cache read each, 80 of them for one cold re-write on Fable.
  4. ✎ Handoff now — one fork over the chat writes the note a new chat can start from (goal, state, decisions, files, next steps, open questions), saves it under ~/.claude/mods-data/cockpit/handoff/<session>.md and copies it. It also writes itself 49 minutes after the last reply on a chat over 50k tokens, while the cache is still warm, so it costs a read, not a re-write, and the hour starts over (/handoff auto off to stop that).
  5. /handoff prints the note in the chat as markdown and copies it, to paste as a new chat's first message. When the saved note still covers the last reply (the automatic one, say), it shows that one without a new fork, so it costs nothing.

The guard: a plain message typed into a chat over 50k tokens whose cache went cold is dropped once with its price; send it again to pay it, and keepwarm then holds the cache for three hours. /cache-tax guard warn only shows the price; /cache-tax prints the card (state, size, prices, keepwarm, handoff, break-even, cold writes paid). On a subscription the dollars are API-equivalent yardsticks, not a bill: a cold re-write eats a bite of the 5-hour and weekly limits.

  1. Goals — this chat's /goal plan: progress bar, tasks done, elapsed or finished time. With no goal running, a box to type what done looks like and a Create goal button, which sends /goal <text> as typing it would.
  2. Progress — fills by itself. A /cockpit:crew run shows as its band and plan (phase, bar, percent, title, crew, one row per planned task with tier and model). Otherwise Claude's own task list, which it writes as it works (the TaskCreate/TaskUpdate and TodoWrite tools), shows as tasks done out of total with one row each and a time estimate (≈ 4m left: the time per finished task so far, times the tasks left). Otherwise this chat's /goal. Otherwise, while Claude works with no plan, a ticking timer where the state word goes (no bar: a moving one only distracts), then working, the steps and tool calls so far and the tool running now (Bash · claude plugin test); idle says how long the last turn took. The board also adds one line to the system prompt asking Claude to keep a task list on work longer than a couple of minutes, so the bar and the estimate fill more often. On a large task it also asks Claude to offer /cockpit:crew in one line and wait for your choice; it never starts it on its own.
  3. Agents — this chat's helper agents (running, done, cost, tokens, time, then up to six rows). /agents-info brings up the board; there is no separate agents panel.
  4. Needs you — everything that waits on you, in one place, whatever the kind. A shell command that could wipe files or history (a recursive or forced delete, a hard git reset, git clean, a force push, a migration) is measured with read-only helpers (find, du, git) and held here with Proceed / Cancel until you answer, for up to ten minutes, exactly as the Caution guard always did. A small rm where losing it costs nothing runs without a hold and is listed as let through: nothing to delete, or at most 50 files and 10 MB inside the project or a temp folder. In bypass permissions mode the bar is 1,000 files and 500 MB, anywhere. A top folder (/, the home folder or one just under it) is always held. A hand-off Claude files with mcp__cockpit__handoff (an API key to paste, a store form, a DNS record, a login, a payment) shows as a row with the value to paste under it and two buttons: Open <site> ↗ (opens the page and puts the value on your clipboard) and Done ✓ (tells Claude, and resumes a held Ship). The wrong-folder nudge is a row here too: after three shell commands that cd into another project, Move chat moves the chat there once and Not now keeps it quiet for the session. With nothing waiting the section is one line: nothing waiting · N solved this chat.

CHANGES (a third zone, under a rule; its heading says where things stand: 3 files · not live yet, shipping, held on you, live 18:42) — what the chat produced:

  1. File Changes — how many files Claude changed this chat, then the files themselves (up to six, with +added −deleted lines when the folder is a git repo), then two buttons on one row: ▶ See code changes (Replay Theater's step-through of this chat's edits, one at a time, in the board) and ◫ See visual changes (the screen before and after: the screenshot you pasted and the newest one Claude took, side by side with a numbered pin per ask, then the asks as rows, done or open; Fix the rest sends the open ones back to Claude, Looks good ✓ closes it). A pasted screenshot tells Claude to treat it as a visual bug report and to report the new screenshot with mcp__cockpit__look.
  2. Ship — six fixed steps to live: origin up to date, build passed, copy check (typos, AI slop, the English default), commit + push, deploy to prod (Vercel or Railway from the repo's files, push only otherwise), live check + screenshot. ▶ Ship it (or /ship, or saying "pode deploy", "ship it") hands Claude the checklist as context; Claude reports each step with mcp__cockpit__ship and the row shows the step bar, the elapsed time and the six rows. A step that needs you (a domain to verify, a key) turns the pill to act now, the hand-off lands in Needs you, and Done resumes. Once live: the URL, Open <host> ↗ (copies the link too) and ▶ Ship again. The last deploy per folder is remembered (last live 2h ago).

Then the tree: its header buttons say what they do (↑ up, ⌂ home, ↻ refresh, ○ hidden, Σ sizes, ⊟ fold, ⊘ unselect, × clear), and below them git status, lines changed, shimmer on the files Claude reads and writes, search, sizes, double-click to open.

Only the guard runs anything, and only the command you approve. The cache block makes model calls: one fork per keepwarm ping and one per handoff note, nothing else. The board adds three short sections to the system prompt (cockpit:progress, the task-list request; cockpit:ship, how to report a ship, file a hand-off and report a look; cockpit:crew, how to read a route note); nothing else of the prompt is touched. A message you send that calls for more than plain work here carries one short route note as context (Cockpit route for this message: Helper · Haiku low ... Delegate this to one subagent), from the same rule that lights the crew row; a reply like "ok" or "pode deploy" carries none. With the crew row's Codex switch on, a decision or a hard-to-undo step asks Claude for a read-only second opinion from Codex (codex exec --sandbox read-only --ephemeral, on a temp folder holding only the material it needs), and an image is made through Codex; Codex is found on PATH or inside the ChatGPT app. The Ship buttons, Done, Move chat and Fix the rest send a short message as you; nothing else is sent on its own. Note that the guard reads every line of a Bash call, the commands inside $( ) and backquotes too; the body of a literal here-document (<<'EOF') is the command's input and is skipped, while an expanding one (<<EOF) is read, since a $( ) in it runs.

What else is inside

  • Commands: /cockpit, /board and /filetree open the board (/cockpit <path> pins another folder); /agents-info brings up the board; /goal <what done looks like> starts a goal and /goals brings up the board and lists every chat's; /replay steps through the last turn's edits; /ship starts the ship checklist (/ship cancel resets it); /keepwarm, /cache-tax and /handoff drive the cache block.
  • Skill and agents: /cockpit:crew <task> runs a big task with a crew: Claude plans it, hands each piece to one of five worker agents (from crew-fable for the hardest to crew-light for the simplest) and checks their work before it is done (this was savvy-flow). The workers are listed by the Agent tool as cockpit:crew-fable, cockpit:crew-heavy, cockpit:crew-careful, cockpit:crew-medium and cockpit:crew-light.
  • Tools for Claude: mcp__cockpit__progress and mcp__cockpit__step (the crew's progress), mcp__cockpit__tasks (the goal meter's plan), mcp__cockpit__ship (a ship step), mcp__cockpit__handoff (something only you can do), mcp__cockpit__look (a screenshot taken to check a visual change).
  • Scrolling: on the desktop the whole board scrolls natively, cards and tree together (the tree is drawn whole, up to 400 rows); the terminal keeps moving the tree row by row under the cards.
  • Everything in one pane: agents, goals and Replay draw inside the Cockpit Board; nothing opens a pane of its own.
  • Next steps (from Thariq Shihipar's next-steps, MIT): after a turn, up to three likely next prompts above the input, under "What you could do next" (press 1, 2 or 3 to put one in the prompt box as a draft, 0 to hide them; the first is also the Tab ghost text). Settings: minAnswerChars, suggestSkills. The separate next-steps@claude-community plugin is disabled on purpose (it would draw twice).
  • The crew row, always above the prompt (◆ Agent routing suggestion, typed or not): five buttons named for what they do (Do it here, Hand to a cheaper agent, Open in a new chat, Split across agents, Plan first, then build), plus "Codex second opinion" with Turn on and Turn off. While you type, the route the draft calls for moves up to "Suggested:", lit, with the lane spelled out (Opus · high effort · the design skill) and a "Why:" line; the others sit under "Or run it another way". The route note a sent message carries keeps the short names (Here, Helper, New chat, Crew, Plan) and the arrow form below. The kind of work (hard-to-undo, architecture, image, video, chart, document, UI design, research, mechanical, build, fix, quick) sets the model and effort it deserves and the specialist to use, then the place (→ Plan · Opus high · Codex 2nd opinion (hard to undo: plan first, then run), → Here · Sonnet medium · dataviz (a chart)). A rebase always plans first; a decision in a chat below Opus points to a new chat on Opus rather than switching here and losing the cache; mechanical work and research go to a worker, but only from a chat dearer than the worker; a long new build in a chat on Opus or Fable past 80k tokens goes to a new chat; Crew lights only when you ask for the crew, agents or parallel work, or list a build in three or more parts. The words come from real prompts in Portuguese and English. The rules and the benchmark behind them (five tasks, three ways, measured cost and a blind judge) are in docs/crew-2.0-prd.md. A button only rewrites the draft (Helper puts a delegate-to-one-cheap-subagent ask before it, Crew puts /cockpit:crew, Plan the plan-first ask, Here takes any of those off) or, for New chat, writes this chat's handoff note (the fresh one when there is one, else one fork over the warm cache) and opens the app's new-chat link on the draft, naming the project folder and the note to read first: you click Trust workspace in the app and press Enter there; the new chat moves itself to the project folder. Nothing is sent. At most ten crew chats at a time.
  • Crew chats, a card under Agents: every chat New chat opened, followed through the transcript the app writes for it (found by a marker in its first message): waiting for your Enter, working or finished, its model, an estimated cost and its last words. Finished chats stay until you press Forget finished chats; they stay in the app's sidebar either way. The Codex switch (off by default, stored under crew.codex) only turns on where a codex command is installed; otherwise it says so.
  • Above the prompt, while you type, in the same box under the crew row and the next steps: what the message will cost if sent, at API list prices: ✎ this message ≈ $0.15–$0.60 if API · small edit (~6 calls, first guess) · ~180 tokens typed. The floor is certain (the context read from the cache, or written again with a ⚠ when the cache is cold, plus what was typed); the rest is the work the text asks for, read as a profile (quick answer, small edit, build, with agents) and shaped by your own past turns of that kind once there are three (median to 90th percentile; stored under estimate.history). Crew and goal progress still show only on the board.
  • Settings (in /config): plan for the dollar figure beside Session and Week (auto, Pro, Max 5x, Max 20x, none); language for the board's crew texts (auto, en, ru); activity, glyphs, follow and column for the tree, as filetree had them.

Each part came from a published mod; see THIRD_PARTY_LICENSES.md.

Working on it

  • Lives in ~/.claude/my-mods/cockpit/, installed from the local marketplace my-mods (~/.claude/my-mods/). Claude Code loads a copy taken at install, so after an edit bump the version in plugin.json and marketplace.json, then run claude plugin marketplace update my-mods and claude plugin update cockpit@my-mods.
  • One hooks module: the engine loads one per plugin and follows $ only into functions declared at the top of that module, so every hook and helper that takes $ is in hooks/register.tsx, each event hooked once. Pure helpers sit beside it.
  • The separate filetree, savvy-progress, savvy-flow, goal-meter, replay-theater and cache-tax plugins must stay uninstalled or disabled while cockpit is on, or commands, tools, panes and the cold-send guard run twice. Do not install blast-radius alongside either.
  • Turn off: claude plugin disable cockpit@my-mods.
  • Check: claude plugin validate ~/.claude/my-mods/cockpit; tests: claude plugin test ~/.claude/my-mods/cockpit.
Source 11 files
hooks/register.tsx 7009 lines
1// cockpit: one mod, one side panel. Everything the Cockpit Board shows lives in this file:
2//   - the board itself (the usage windows with a "so what" each, THIS CHAT: context, progress,
3//     goals, agents, the Caution guard, and the files block), drawn above the tree;
4//   - the file tree, from Kurt Buhler's filetree mod (MIT): the pane, git status, shimmer on the
5//     files Claude touches, search, sizes, open-on-double-click, follow the cwd;
6//   - savvy-progress, from johnnyvizz's claude-kit (MIT): the /cockpit:crew progress band, the
7//     agents panel (/agents-info), the `progress` and `step` tools, every subagent's cost and time;
8//     the crew skill (savvy-flow, renamed) and its five worker agents ship in this plugin too (skills/, agents/);
9//   - goal-meter, by Nate Herk (MIT): /goal with a task plan through the `tasks` tool, the band,
10//     the footer, /goals;
11//   - Replay Theater, an Anthropic sample (Apache-2.0): /replay steps through the last turn's edits;
12//   - next-steps, by Thariq Shihipar (MIT): up to three next prompts above the input after a turn,
13//     sharing that band with the cost estimate of the message being typed;
14//   - the Caution guard, adapted from Anthropic's Blast Radius sample (Apache-2.0), at the end.
15// The engine loads one hooks module per plugin and follows `$` only into functions declared at
16// the top of that module, so every hook and every helper that takes `$` is here; pure helpers
17// (tree.ts, git.ts, icons.ts, open.ts, rows.tsx, goal-*.mjs) sit beside it. Each event is hooked
18// once: where several parts listen to the same event, one hook calls them in turn.
19
20import { atom, read, update, type BuiltinToolResults, type CommandInfo, type EngineInterface, type Register, type Timer } from 'claude-code'
21import { openCommand } from './open'
22import { cardSvg, type CardRow, type CardSpec, compareSvg, dividerSvg, fitText, legendSvg, type Level, type Mark, textWidth, xml } from './board-svg'
23
24import type { Activity, AgentRun, FileNode, FileTree, Flow, Panel, Phase, PlannedTask, Theme } from '../types'
25import { BRANCH_ICON, chainOf, type GitAction, gitActions, readOnly, readTargets, resolve, TONES } from './git'
26import type { RowSpec, RowsProps, Seg } from './rows'
27import { CHEVRON_CLOSED, CHEVRON_OPEN, fileIcon, GIT_COLOR } from './icons'
28import {
29  ancestorsOf,
30  type Change,
31  DEFAULT_THEME,
32  dirname,
33  emptyTree,
34  formatSize,
35  inside,
36  isAbsolute,
37  join,
38  mapper,
39  parseGit,
40  parseNumstat,
41  parseTheme,
42  posix,
43  relative,
44  dropBelow,
45  useDrives,
46  rollCounts,
47  replaceChildren,
48  rollUp,
49  stamp,
50  toNodes,
51  underAny,
52  visibleRows,
53} from './tree'
54import { minutes, clock, clip, bar, basename } from './goal-fmt.mjs'
55import { makeMasker } from './goal-privacy.mjs'
56import { newGoal, applyAction, progress, eta, parseCheck, isStopWord, normalizeTasks, TOOL_SPEC, instruction, nudge, strictDeny } from './goal-plan.mjs'
57
58
59// =============================================================================
60// State the whole board shares (savvy-progress's values, under this plugin), and the tool names
61// =============================================================================
62
63const flow = atom({ plugin: 'cockpit', key: 'flow' } as const, null)
64const agents = atom({ plugin: 'cockpit', key: 'agents' } as const, [])
65const panel = atom({ plugin: 'cockpit', key: 'panel' } as const, {
66  isCompact: false,
67  isDoneCollapsed: false,
68  autoOpenedFor: '',
69})
70const nowAtom = atom({ plugin: 'cockpit', key: 'now' } as const, 0)
71// Every token this chat's requests sent and got back (the main thread's and its agents'), for THIS CHAT's header.
72const chatTokens = atom({ plugin: 'cockpit', key: 'chatTokens' } as const, 0)
73
74const TOOL = 'mcp__cockpit__progress'
75const STEP_TOOL = 'mcp__cockpit__step'
76const SHIP_TOOL = 'mcp__cockpit__ship'
77const HANDOFF_TOOL = 'mcp__cockpit__handoff'
78const LOOK_TOOL = 'mcp__cockpit__look'
79const NEED_BUTTON_W = 22
80const PHASES: readonly Phase[] = ['plan', 'design', 'delegate', 'review', 'close']
81const ACCENT = '#8f8cf4'
82// `crew-careful`, or `cockpit:crew-careful` as the Agent tool lists an agent a plugin ships.
83const bareAgentType = (t: string): string => t.replace(/^[^:]*:/, '')
84// A crew worker: `crew-*`, or `savvy-*` as the agents were named before the rename.
85const isCrewType = (t: string): boolean => /^(crew|savvy)-/.test(bareAgentType(t))
86
87
88// =============================================================================
89// The board: what the Cockpit draws above the tree
90// =============================================================================
91
92const MAX_PLAN_ROWS = 8
93const normText = (t: string): string => t.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
94type Window = { kind: string; percentUsed: number; resetsAt?: string }
95type Context = { tokens: number; window: number; percent: number }
96// This chat's goal stays on the board this long after it finished.
97const RECENT_GOAL_MS = 12 * 3600_000
98
99// --- Claude's own task list, as it writes it with the task tools (main conversation only)
100type AutoTask = { id: string; title: string; status: 'pending' | 'in_progress' | 'completed'; activeForm?: string }
101let autoTasks: AutoTask[] = []
102
103// --- the guard's history rows and the files Claude changed this session
104type Held = { label: string; command: string; outcome: string; reason: string; startedAt: number; endedAt: number }
105const MAX_RISK_ROWS = 5
106const MAX_CHANGED_ROWS = 6
107// Each section's icon and colour, so the eye finds it fast.
108const SECTION: Record<string, { icon: string; color: string }> = {
109  Session: { icon: '◷', color: '#7fb3ff' },
110  Week: { icon: '▦', color: '#7fb3ff' },
111  Context: { icon: '◔', color: '#ffb74d' },
112  Cache: { icon: '⚡', color: '#4fc3f7' },
113  Progress: { icon: '◆', color: '#8f8cf4' },
114  Goals: { icon: '◎', color: '#5fbf8f' },
115  Agents: { icon: '⚙', color: '#4dd0e1' },
116  Caution: { icon: '⚠', color: '#ffd54f' },
117  'Needs you': { icon: '⚠', color: '#ffd54f' },
118  Files: { icon: '▤', color: '#f97316' },
119  Ship: { icon: '⇡', color: '#f97316' },
120}
121
122// --- the turn in flight, for the Progress row when no plan exists
123const live = { startedAt: 0, steps: 0, tools: 0, now: '', lastMs: 0, lastSteps: 0, profile: 'quick' as Profile }
124// When the warm cache's countdown last redrew, in wall time: at most once a real second, however fast a clock ticks.
125let cacheDrawnAt = 0
126// Claude's own plan for the turn in flight, from the `step` tool: steps planned, finished, the one in progress.
127const turnPlan = { total: 0, done: 0, note: '' }
128// The main chat's past turns, kept across sessions: how long each took and what kind of prompt it was.
129type PastTurn = { ms: number; steps: number; profile: Profile }
130let pastTurns: PastTurn[] = []
131const PAST_TURNS_KEEP = 200
132const PAST_TURNS_MIN = 5
133
134/**
135 * How long the turn in flight still needs when Claude gave no plan: of the past turns of the same kind
136 * (all of them while too few) that ran longer than this one so far, the median length, less the time
137 * spent. `over` once this turn has outlasted nearly all of them; null before enough history.
138 */
139function turnGuess(now: number): { kind: 'guess'; ms: number; n: number } | { kind: 'over' } | null {
140  const spent = now - live.startedAt
141  const alike = pastTurns.filter(t => t.profile === live.profile)
142  const pool = alike.length >= PAST_TURNS_MIN ? alike : pastTurns
143  if (pool.length < PAST_TURNS_MIN) return null
144  const longer = pool.filter(t => t.ms > spent).map(t => t.ms)
145  if (longer.length < 2) return { kind: 'over' }
146  return { kind: 'guess', ms: Math.max(1000, quantile(longer, 0.5) - spent), n: pool.length }
147}
148let tasksStartedAt = 0
149const taskDoneAt = new Map<string, number>()
150
151// The board beside the chat shows Claude's task list as the Progress bar; this asks for one on long work.
152const PROGRESS_SECTION = {
153  id: 'cockpit:progress',
154  scope: 'session',
155  text: 'A Cockpit Board beside this chat shows your task list as a progress bar with a time estimate. For work that will take more than a couple of minutes or several steps, create the tasks with TaskCreate (or TodoWrite) before starting and mark each one completed as you finish it, so the person can follow along. For a smaller turn that still takes several tool calls, call mcp__cockpit__step once early with `total` (your plan in 2-8 steps) and `done: 0`, batched with your first other tool calls, and again with `done` as each step finishes: the bar and its time left come from it. When a task is large (many files, several independent parts, or likely well over half an hour), offer in one line before starting to run it with /cockpit:crew, which splits the work across a crew of worker agents (faster on big jobs, but it spends more tokens), and wait for the person to answer; never start /cockpit:crew on your own.',
156} as const
157
158/** A few words for the tool Claude is running: the tool and its file, command or target. */
159function toolWords(e: any): string {
160  const t = String(e.tool || '')
161  const head = (s: unknown, n: number) => String(s ?? '').split('\n')[0].trim().slice(0, n)
162  if (t === 'Bash') return `Bash · ${head(e.command, 48)}`
163  if (t === 'Edit' || t === 'Write' || t === 'Read' || t === 'NotebookEdit') return `${t} · ${basename(String(e.file_path || e.notebook_path || ''))}`
164  if (t === 'Agent') return `Agent · ${head(e.description || e.subagent_type, 40)}`
165  if (t === 'Grep' || t === 'Glob') return `${t} · ${head(e.pattern, 30)}`
166  if (t.startsWith('mcp__')) return t.replace(/^mcp__/, '').replace(/__/g, ' · ').slice(0, 48)
167  return t
168}
169
170function noteLiveTool(e: any, now: number): void {
171  live.tools += 1
172  live.now = toolWords(e)
173  stampTasks(now)
174}
175
176/** When the task list began and when each task was finished, for the estimate. */
177function stampTasks(now: number): void {
178  if (autoTasks.length === 0) {
179    tasksStartedAt = 0
180    taskDoneAt.clear()
181    return
182  }
183  if (!tasksStartedAt) tasksStartedAt = now
184  for (const t of autoTasks) if (t.status === 'completed' && !taskDoneAt.has(t.id)) taskDoneAt.set(t.id, now)
185}
186
187/** How long the task list still needs: the time per finished task so far, times the tasks left. */
188function taskEtaMs(now: number): number | null {
189  const done = autoTasks.filter(t => t.status === 'completed').length
190  const left = autoTasks.length - done
191  if (!tasksStartedAt || done === 0 || left === 0) return null
192  return ((now - tasksStartedAt) / done) * left
193}
194
195/** `≈ 4m left (at 15:32)`: how long, then the clock time it should be done by. */
196function etaWords(ms: number, now: number): string {
197  const at = clock24(new Date(now + ms).toISOString())
198  return ms < 60_000 ? `< 1m left (at ${at})` : `≈ ${cacheDuration(ms)} left (at ${at})`
199}
200
201const changedFiles = new Set<string>()
202
203// --- layout
204const LABEL_W = 11
205const SUB_W = 10
206const PCT_W = 5
207// The bar is short on purpose. On the desktop the block glyphs draw about 1.6× wider than the
208// proportional text the columns are measured in, so a long bar ran over the number and the note
209// beside it; there the bar's cell grows by that much so the text next to it stays readable.
210const BAR_MAX = 16
211const DESKTOP_BAR_STRETCH = 1.6
212const MAX_AGENT_ROWS = 6
213const MAX_GOAL_ROWS = 5
214const RUNNING = 'cyan'
215const DONE = 'green'
216const FAILED = 'red'
217const GLYPH: Record<AgentRun['status'], string> = { running: '●', done: '✓', failed: '✗' }
218const MARK_GLYPH: Record<string, string> = { running: '●', done: '✓', failed: '✗', planned: '◷', held: '●', dot: '·', none: '' }
219// Desktop: about how tall a tree row, a button row and the goal box draw, in CSS pixels.
220const ROW_PX = 20
221const BUTTON_PX = 34
222const INPUT_PX = 40
223// The cache buttons' shared width, in columns: the longest label (■ Stop warming) and its padding.
224const CACHE_BUTTON_W = 20
225
226// Each band carries a one-line "so what" for the row under the gauge.
227const FORECAST = [
228  { upTo: 25, icon: '☀', word: 'Clear', color: 'yellow', hint: 'Plenty of room. Work as normal.' },
229  { upTo: 50, icon: '☁', word: 'Cloudy', color: 'cyan', hint: 'Fine for now. Big files and long replies add up.' },
230  { upTo: 75, icon: '☂', word: 'Showers', color: 'blue', hint: 'Getting full. Claude may lose early details; wrap up.' },
231  { upTo: 90, icon: '☇', word: 'Storm', color: 'magenta', hint: 'Nearly full. Finish up; a trim (compact) is close.' },
232  { upTo: Infinity, icon: '↯', word: 'Compact soon', color: 'red', hint: 'The chat gets summarized any moment. Save key facts now.' },
233]
234
235type GaugeRow = {
236  k: string
237  label: string
238  sub: string
239  subColor?: string
240  bold?: boolean
241  barText?: string
242  /** The bar's fill in percent, for the desktop's drawn bar. */
243  fill?: number
244  barColor?: string
245  pct?: string
246  note?: string
247}
248
249
250async function startCockpit($: EngineInterface, e: any): Promise<void> {
251    const notes: string[] = [`start: surface=${String(e.surface)} interactive=${String(e.isInteractive)}`]
252    const commands: [string, string, string][] = [
253      ['cockpit', 'Open the Cockpit Board: usage, this chat, the guard and the file tree; /cockpit <path> pins another folder', '[path]'],
254      ['board', 'Open the Cockpit Board (same as /cockpit)', '[path]'],
255      ['filetree', 'Open the Cockpit Board (same as /cockpit)', '[path]'],
256      ['replay', "Replay Theater: step through the last turn's file edits", ''],
257    ]
258    for (const [name, description, argumentHint] of commands) {
259      try {
260        const got = await $.command.register(argumentHint ? { name, description, argumentHint } : { name, description })
261        notes.push(`registered /${name}: ${JSON.stringify(got)}`)
262      } catch (err) {
263        notes.push(`register /${name} failed: ${String(err)}`)
264      }
265    }
266    // A startup log beside the mod, so a problem with the command can be read off disk.
267    const log = async (more: string[]) => {
268      try {
269        const home = (await $.env.get('HOME')) || ''
270        await $.fs.write(`${home}/.claude/my-mods/cockpit/.last-start.log`, [...notes, ...more].join('\n') + '\n')
271      } catch {
272        // nothing to do without a writable home
273      }
274    }
275    void log([])
276    $.clock.after(1500, () => {
277      void (async () => {
278        try {
279          const names = (await $.command.list()).map(c => `${c.name} <- ${c.source}${c.plugin ? ' ' + c.plugin : ''}`)
280          await log([`commands after 1.5 s (${names.length}):`, ...names.filter(n => /cockpit|board|filetree|agents-info|goals|replay/.test(n))])
281        } catch (err) {
282          await log([`command.list failed: ${String(err)}`])
283        }
284      })()
285    })
286}
287
288async function drawCockpit($: EngineInterface, e: any, next: any): Promise<unknown> {
289    if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
290    if (e.surface === 'terminal' && e.props.placement === 'inline') return drawTree($, e)
291
292    const { windows, context, cost } = await readUsage($)
293    const flowNow = await read($, flow)
294    const list = await read($, agents)
295    const now = Math.max(await read($, nowAtom), ...list.map(a => a.startedAt), 0)
296    const agentRows = pick(list)
297    const nowMs = await $.clock.now()
298    // This chat's goal is the goal meter's own, in memory: running, or finished within the last 12 hours.
299    const myGoal = G && (G.status === 'running' || nowMs - (G.endedAt || G.updatedAt || 0) < RECENT_GOAL_MS) ? G : null
300    const held = getHeld() as { report: { lines: string[]; more?: number } | null } | null
301    const history = getHistory() as Held[]
302
303    const ui = $.ui.resolve(e)
304    const { Box, Text, Button, Input } = ui
305    const width = Math.max(24, e.props.bodyColumns)
306    const stretch = e.surface === 'desktop' ? DESKTOP_BAR_STRETCH : 1
307    const barW = Math.max(6, Math.min(BAR_MAX, Math.floor((width - LABEL_W - SUB_W - PCT_W - 22) / stretch)))
308    const barCellW = Math.ceil(barW * stretch)
309
310    const Cell = ({ w, children, end }: { w: number; children?: unknown; end?: boolean }) => (
311      <Box width={w} flexShrink={0} justifyContent={end ? 'flex-end' : 'flex-start'}>{children as never}</Box>
312    )
313    const Row = ({ k, label, sub, subColor, bold, barText, barColor, pct, note }: GaugeRow) => (
314      <Box key={k} flexDirection="row">
315        <Cell w={LABEL_W}>
316          {SECTION[label] ? (
317            // the icon in its tag's colour, the name in the section's
318            <Box flexDirection="row"><Text color={subColor ?? SECTION[label].color}>{`${SECTION[label].icon} `}</Text><Text bold color={SECTION[label].color} wrap="truncate-end">{label}</Text></Box>
319          ) : <Text bold wrap="truncate-end">{label}</Text>}
320        </Cell>
321        <Cell w={SUB_W}><Text color={subColor} bold={bold} dimColor={!subColor && sub.startsWith('no')} wrap="truncate-end">{sub}</Text></Cell>
322        {barText !== undefined && <Cell w={barCellW}><Text color={barColor}>{barText}</Text></Cell>}
323        {pct !== undefined && <Cell w={PCT_W} end><Text>{pct}</Text></Cell>}
324        {note !== undefined && <Box marginLeft={2} flexShrink={1}><Text dimColor wrap="truncate-end">{note}</Text></Box>}
325      </Box>
326    )
327    const Rule = (k: string) => (
328      <Box key={k} flexDirection="row">
329        <Text dimColor wrap="truncate-end">{'─'.repeat(width)}</Text>
330      </Box>
331    )
332    const Head = (k: string, text: string) => (
333      <Box key={k} flexDirection="row">
334        <Text bold>{text}</Text>
335      </Box>
336    )
337    const Blank = (k: string) => (
338      <Box key={k} flexDirection="row">
339        <Text> </Text>
340      </Box>
341    )
342    const Hint = (k: string, text: string) => (
343      <Box key={k} flexDirection="row">
344        <Cell w={LABEL_W}><Text> </Text></Cell>
345        <Box flexShrink={1}><Text dimColor wrap="truncate-end">{`↳ ${text}`}</Text></Box>
346      </Box>
347    )
348    // Two or three buttons on one row, one legend for all of them.
349    const ButtonsRow = (k: string, items: { key: string; label: string; onPress: () => void; primary?: boolean }[], legend: string) => (
350      <Box key={`${k}:row`} flexDirection="row">
351        <Cell w={LABEL_W}><Text> </Text></Cell>
352        {items.map(b => (
353          <Box key={`${b.key}:cell`} flexShrink={0} marginRight={1}>{b.primary ? <Button key={b.key} label={b.label} variant="primary" onPress={b.onPress} /> : <Button key={b.key} label={b.label} onPress={b.onPress} />}</Box>
354        ))}
355        <Box marginLeft={1} flexShrink={1}><Text dimColor wrap="truncate-end">{legend}</Text></Box>
356      </Box>
357    )
358    // One button per row, its legend to the right.
359    const ButtonRow = (k: string, label: string, legend: string, onPress: () => void, primary = false) => (
360      <Box key={`${k}:row`} flexDirection="row">
361        <Cell w={LABEL_W}><Text> </Text></Cell>
362        <Box flexShrink={0}>{primary ? <Button key={k} label={label} variant="primary" onPress={onPress} /> : <Button key={k} label={label} onPress={onPress} />}</Box>
363        <Box marginLeft={2} flexShrink={1}><Text dimColor wrap="truncate-end">{legend}</Text></Box>
364      </Box>
365    )
366
367    // Progress: savvy-progress's band and plan for a flow; else Claude's task list; else this chat's goal; else a nudge.
368    const mine = myGoal
369    let progressRow: GaugeRow
370    let planItems: (CardRow & { k: string })[] = []
371    // How long the work in Progress still needs, from the pace so far: the crew's tasks, Claude's task
372    // list or the goal's plan. Shown beside the bar, as the windows show when they reset.
373    let etaMs: number | null = null
374    if (flowNow && (flowNow.total > 0 || flowNow.phase !== 'plan' || flowNow.isFinished)) {
375      const ratio = flowNow.isFinished ? 1 : flowNow.total ? flowNow.done / flowNow.total : 0
376      const pct = Math.round(ratio * 100)
377      const planned = (flowNow.tasks ?? []).map((t, i) => ({ ...t, n: i + 1 }))
378      const runFor = (t: PlannedTask) => list.find(a => normText(a.description) === normText(t.title))
379      const crew = list.length + planned.filter(t => !runFor(t)).length
380      if (!flowNow.isFinished && flowNow.done > 0 && flowNow.total > flowNow.done && list.length) {
381        const since = Math.min(...list.map(a => a.startedAt))
382        etaMs = ((nowMs - since) / flowNow.done) * (flowNow.total - flowNow.done)
383      }
384      progressRow = {
385        k: 'progress',
386        label: 'Progress',
387        sub: flowLabel(flowNow),
388        subColor: flowNow.isFinished ? DONE : RUNNING,
389        bold: true,
390        barText: gauge(pct, barW), fill: pct,
391        barColor: flowNow.isFinished || pct >= 100 ? DONE : 'blue',
392        pct: `${pct}%`,
393        note: `${flowNow.isFinished ? '✓' : '●'} ${flowNow.title} · ×${crew}${!flowNow.isFinished && flowNow.running ? ` · ${flowNow.running} running` : ''}`,
394      }
395      planItems = planned.slice(0, MAX_PLAN_ROWS).map(t => {
396        const run = runFor(t)
397        const status = run ? run.status : 'planned'
398        const tier = t.tier in TIER_COLOR ? t.tier : 'other'
399        const tail = run
400          ? `${modelName(run.model)} · ${fmtTokens(run.tokens)} ≈${fmtCost(run.costUsd)} ${fmtTime((run.endedAt ?? Math.max(now, run.startedAt)) - run.startedAt)}`
401          : `${t.tier ? `crew-${t.tier}` : 'planned'}${TIER_MODEL[tier] ? ` · ${TIER_MODEL[tier]}` : ''}${t.after.length ? ` · after ${t.after.join(', ')}` : ''}`
402        return {
403          k: `plan:${t.n}`, mark: status, color: status === 'done' ? DONE : status === 'failed' ? FAILED : TIER_COLOR[tier],
404          text: `${t.n}. ${t.title}`, strong: status === 'running', dim: status === 'planned', tail,
405        }
406      })
407    } else if (autoTasks.length > 0) {
408      const total = autoTasks.length
409      const done = autoTasks.filter(t => t.status === 'completed').length
410      const pct = Math.round((100 * done) / total)
411      const current = autoTasks.find(t => t.status === 'in_progress')
412      const allDone = done === total
413      etaMs = taskEtaMs(nowMs)
414      progressRow = {
415        k: 'progress',
416        label: 'Progress',
417        sub: allDone ? 'Done' : `Tasks ${done}/${total}`,
418        subColor: allDone ? DONE : RUNNING,
419        bold: true,
420        barText: gauge(pct, barW), fill: pct,
421        barColor: allDone ? DONE : 'blue',
422        pct: `${pct}%`,
423        note: `${allDone ? '✓ all tasks done' : current ? `● ${current.activeForm || current.title}` : '◷ next task not started yet'}`,
424      }
425      planItems = autoTasks.slice(0, MAX_PLAN_ROWS).map((t, i) => ({
426        k: `task:${t.id}`,
427        mark: t.status === 'completed' ? 'done' : t.status === 'in_progress' ? 'running' : 'planned',
428        color: t.status === 'completed' ? DONE : t.status === 'in_progress' ? RUNNING : 'gray',
429        text: `${i + 1}. ${t.title}`, strong: t.status === 'in_progress', dim: t.status === 'pending',
430      }))
431    } else if (mine) {
432      const p = progress(mine)
433      const running = mine.status === 'running'
434      etaMs = eta(mine, nowMs)?.ms ?? null
435      progressRow = { k: 'progress', label: 'Progress', sub: running ? 'goal' : 'done ✓', subColor: running ? RUNNING : DONE, bold: true, barText: gauge(p.pct, barW), fill: p.pct, barColor: running ? (p.pct >= 100 ? DONE : 'blue') : DONE, pct: `${p.pct}%`,
436        note: running ? `${mine.title} · ${p.doneN}/${p.n} tasks · ${fmtTime(nowMs - mine.startedAt)}` : `${mine.title} · in ${fmtTime((mine.endedAt || mine.updatedAt) - mine.startedAt)}` }
437    } else if (live.startedAt) {
438      // A single prompt: Claude's own step plan when it gave one, else the time this kind of turn usually
439      // takes; with neither (too little history, or longer than usual) no bar, the timer in its place.
440      const elapsed = nowMs - live.startedAt
441      const doing = `${live.steps} step${live.steps === 1 ? '' : 's'} · ${live.tools} tool call${live.tools === 1 ? '' : 's'}${live.now ? ` · ${live.now}` : ''}`
442      const guess = turnGuess(nowMs)
443      if (turnPlan.total > 0) {
444        const done = Math.min(turnPlan.done, turnPlan.total)
445        const pct = Math.round((100 * done) / turnPlan.total)
446        etaMs = done > 0 && done < turnPlan.total ? (elapsed / done) * (turnPlan.total - done) : guess?.kind === 'guess' ? guess.ms : null
447        progressRow = {
448          k: 'progress', label: 'Progress', sub: `Steps ${done}/${turnPlan.total}`, subColor: RUNNING, bold: true,
449          barText: gauge(pct, barW), fill: pct, barColor: 'blue', pct: `${pct}%`,
450          note: `● ${turnPlan.note || 'working'}`,
451        }
452      } else if (guess?.kind === 'guess') {
453        const pct = Math.min(95, Math.round((100 * elapsed) / (elapsed + guess.ms)))
454        etaMs = guess.ms
455        progressRow = {
456          k: 'progress', label: 'Progress', sub: fmtTime(elapsed), subColor: RUNNING, bold: true,
457          barText: gauge(pct, barW), fill: pct, barColor: 'blue', pct: `~${pct}%`,
458          note: `typical for your last ${guess.n} turns · ${doing}`,
459        }
460      } else {
461        progressRow = {
462          k: 'progress', label: 'Progress', sub: fmtTime(elapsed), subColor: RUNNING, bold: true,
463          note: `${guess?.kind === 'over' ? 'longer than your usual turn · ' : ''}working · ${doing}`,
464        }
465      }
466    } else {
467      progressRow = { k: 'progress', label: 'Progress', sub: 'idle', note: live.lastMs
468        ? `last turn took ${fmtTime(live.lastMs)} (${live.lastSteps} steps) · a task list fills this bar`
469        : "fills by itself from Claude's task list as it works · /goal sets a target · /cockpit:crew runs a big task with agents" }
470    }
471    const progressEta = etaMs !== null && etaMs > 0 ? etaWords(etaMs, nowMs) : ''
472    // The turn's count-up timer beside Progress, whatever fills the bar; the bare turn already shows it as its state.
473    const turnClock = live.startedAt ? fmtTime(nowMs - live.startedAt) : ''
474    const progressTimer = turnClock && progressRow.sub !== turnClock ? `⏱ ${turnClock}` : ''
475
476    // Context: the tokens against the window, then what the last message cost and the next one will
477    const f = context ? forecastFor(context.percent) : null
478    const msgCost = msgCostNote(nowMs)
479    const contextRow: GaugeRow = context && f
480      ? {
481          k: 'context',
482          label: 'Context',
483          sub: `${f.icon} ${f.word}`,
484          subColor: f.color,
485          bold: true,
486          barText: gauge(context.percent, barW), fill: context.percent,
487          barColor: f.color,
488          pct: `${Math.round(context.percent)}%`,
489          note: `${fmtTokens(context.tokens)} / ${fmtTokens(context.window)}${msgCost ? ` · ${msgCost}` : ''}`,
490        }
491      : { k: 'context', label: 'Context', sub: 'no reading' }
492    const contextHint = f ? f.hint : "No reading yet. It shows after Claude's first reply."
493
494    // Usage: the session (5-hour) and week (7-day) windows, each with when it resets, how long
495    // that is, and a one-line "so what" under its bar.
496    const shown = mainWindows(windows)
497    const usageBlock: unknown[] =
498      shown.length === 0
499        ? [Row({ k: 'usage:none', label: 'Usage', sub: 'no reading' })]
500        : shown.flatMap(w => {
501            const inTime = w.resetsAt ? timeLeft(w.resetsAt, nowMs) : ''
502            return [
503              Row({
504                k: `usage:${w.kind}`,
505                label: labelOf(w.kind),
506                sub: wordOf(w.percentUsed),
507                subColor: toneOf(w.percentUsed),
508                barText: gauge(w.percentUsed, barW), fill: w.percentUsed,
509                barColor: toneOf(w.percentUsed),
510                pct: `${Math.round(w.percentUsed)}%`,
511                note: [windowWorth(w) ? `(${windowWorth(w)})` : '', w.resetsAt ? `resets ${when(w.resetsAt)}${inTime ? ` (in ${inTime})` : ''}` : ''].filter(Boolean).join(' · '),
512              }),
513              Hint(`usage:hint:${w.kind}`, usageSoWhat(w, nowMs)),
514            ]
515          })
516
517    // Agents
518    const agentsHead: GaugeRow = list.length === 0
519      ? { k: 'agents:head', label: 'Agents', sub: 'none yet' }
520      : { k: 'agents:head', label: 'Agents', sub: '', note: agentsHeadline(list, now) }
521
522    // Goals: this chat's only
523    const myGoals = myGoal ? [myGoal] : []
524    const goalRows: GaugeRow[] =
525      myGoals.length === 0
526        ? [{ k: 'goals:none', label: 'Goals', sub: 'none yet' }]
527        : myGoals.map((g, i) => {
528            const p = progress(g)
529            const running = g.status === 'running'
530            return {
531              k: `goal:${i}`,
532              label: i === 0 ? 'Goals' : '',
533              sub: running ? 'running' : g.status === 'met' ? 'done ✓' : g.status,
534              subColor: running ? RUNNING : g.status === 'met' ? DONE : undefined,
535              barText: gauge(p.pct, barW), fill: p.pct,
536              barColor: running ? (p.pct >= 100 ? DONE : 'blue') : g.status === 'met' ? DONE : 'gray',
537              pct: `${p.pct}%`,
538              note: running
539                ? `${g.title} · ${p.doneN}/${p.n} tasks · ${fmtTime(nowMs - g.startedAt)}`
540                : `${g.title} · ${g.status === 'met' ? 'done ✓' : g.status} in ${fmtTime((g.endedAt || g.updatedAt) - g.startedAt)}`,
541            }
542          })
543
544    // Needs you: everything that waits on the person, in one place. A risky command the guard holds
545    // (its box draws beneath), a hand-off (a key, a form, a DNS record), the wrong-folder nudge.
546    const waiting = held !== null && held.report !== null
547    const nv = needsView(waiting, held as never, history)
548    const riskHead: GaugeRow = { k: 'needs:head', label: 'Needs you', sub: nv.sub, subColor: nv.subColor, bold: nv.level === 'act', note: nv.phrase }
549    const holdBox = waiting ? drawHold(ui, held as never) : null
550    const holdHeight = waiting && held ? holdRows(held.report as never) : 0
551
552    const cache = cacheView(nowMs, barW)
553    // THIS CHAT's header carries what the chat has taken so far: tokens, and their price at API rates.
554    const tokensSoFar = await read($, chatTokens)
555    const chatSummary = [tokensSoFar > 0 ? `${fmtTokens(tokensSoFar)} tokens` : '', cost != null ? `≈ ${cacheUsd(cost)} (if API)` : ''].filter(Boolean).join(' ')
556    // The list rows of each section, as data: the terminal draws them as text, the desktop in its cards.
557    const agentItems: (CardRow & { k: string })[] = agentRows.map(a => ({
558      k: `agent:${a.id}`, mark: a.status, color: statusColor(a.status), text: a.description || a.type, strong: true, tail: detail(a, now),
559    }))
560    const needRows: (CardRow & { k: string })[] = nv.rows
561    // Crew chats: the real chats the New chat button opened, followed through their transcripts
562    const crewItems = crewRows(nowMs)
563    const crewHead: GaugeRow = crewItems.length === 0
564      ? { k: 'crew:head', label: 'Crew chats', sub: 'none yet' }
565      : { k: 'crew:head', label: 'Crew chats', sub: '', note: crewHeadline() }
566    const crewDone = crewChats.filter(c => c.status === 'idle' || c.status === 'gone').length
567    const t = await get($)
568    const changedItems: (CardRow & { k: string })[] = [...changedFiles].slice(0, MAX_CHANGED_ROWS).map(path => {
569      const loc = t.diff[path]
570      const rel = t.root && path.startsWith(t.root + '/') ? path.slice(t.root.length + 1) : path
571      return { k: `changed:${path}`, mark: 'dot', color: SECTION.Files.color, text: rel, ...(loc ? { add: loc[0], del: loc[1] } : {}) }
572    })
573    const filesMore = changedFiles.size > MAX_CHANGED_ROWS ? `+ ${changedFiles.size - MAX_CHANGED_ROWS} more` : ''
574    const edits = replayState.replay.length
575    // The same pattern as the cache buttons: a plain button, the figure first in its legend, what it does in brackets.
576    const replayLabel = '▶ See code changes'
577    const sv = shipView(nowMs, t, changedFiles.size)
578    const cv = compareView(nowMs)
579    const changesSummary = `${changedFiles.size} file${changedFiles.size === 1 ? '' : 's'} · ${sv.summary}`
580    const visualLabel = compare.open ? '◫ Hide visual changes' : '◫ See visual changes'
581    const filesLegend = `${edits ? `${edits} edit${edits === 1 ? '' : 's'} (steps through this chat's changes, one at a time)` : "no edits yet (steps through this chat's changes once there are some)"} · ${cv.shots ? `${cv.shots} screenshot${cv.shots === 1 ? '' : 's'}` : 'paste a screenshot to compare'}`
582    const replayLegend = edits ? `${edits} edit${edits === 1 ? '' : 's'} (steps through this chat's changes, one at a time)` : "no edits yet (steps through this chat's changes once there are some)"
583    const goalBox = (
584      <Input key="goal:new" label="◎ " placeholder="what does done look like?" submitLabel="Create goal" onSubmit={(v: string) => void createGoal($, v)} />
585    )
586    const scroll = e.props.scroll
587
588    // Desktop (visual 3.0): every block a drawn card; the buttons and the goal box stay real elements.
589    if (e.surface === 'desktop' && 'Svg' in ui) {
590      const { Svg } = ui as typeof ui & { Svg: (p: { source: string; alt: string; width: number; height: number }) => unknown }
591      const W = Math.max(240, Math.min(900, width * 8 - 8))
592      let px = 0
593      const draw = (k: string, d: { source: string; height: number }, alt: string) => {
594        px += d.height
595        return <Svg key={k} source={d.source} alt={alt} width={W} height={d.height} />
596      }
597      const card = (k: string, spec: CardSpec, alt: string) => draw(k, cardSvg(W, spec), alt)
598      const fromRow = (r: GaugeRow, icon: string): CardSpec => ({
599        icon,
600        label: r.label,
601        color: SECTION[r.label]?.color ?? ACCENT,
602        ...(r.sub ? { state: r.sub, stateColor: r.subColor } : {}),
603        ...(r.pct ? { value: r.pct } : {}),
604        ...(r.fill !== undefined ? { bar: { pct: r.fill, color: r.barColor ?? ACCENT } } : {}),
605      })
606      const say = (r: GaugeRow) => [r.label, r.sub, r.pct, r.note].filter(Boolean).join(' ')
607      const sayRows = (items: CardRow[]) => items.map(i => ` · ${i.text}${i.tail ? ` ${i.tail}` : ''}`).join('')
608      // A button with its legend beside it (the price first), set in under the section it belongs to.
609      // Stacked buttons share one width (`w`, in columns) so their legends start in one column.
610      const Action = (k: string, label: string, legend: string, onPress: () => void, primary = false, w?: number) => {
611        px += BUTTON_PX
612        // the legend in the cards' small type, in what the button leaves of the row (a column is ~8px)
613        const legendW = Math.max(80, W - (3 + (w ?? [...label].length + 4)) * 8)
614        return (
615          <Box key={`${k}:row`} flexDirection="row" alignItems="center" marginLeft={2} marginBottom={1}>
616            <Box flexShrink={0} {...(w ? { width: w } : {})}>{primary ? <Button key={k} label={label} variant="primary" onPress={onPress} /> : <Button key={k} label={label} onPress={onPress} />}</Box>
617            <Box marginLeft={1} flexShrink={1}><Svg source={legendSvg(legendW, legend).source} alt={legend} width={legendW} height={16} /></Box>
618          </Box>
619        )
620      }
621      // Two or three buttons on one row, one legend for all of them (See code changes · See visual changes).
622      const Actions = (k: string, items: { key: string; label: string; onPress: () => void; primary?: boolean; on?: boolean }[], legend: string) => {
623        px += BUTTON_PX
624        const used = items.reduce((n, b) => n + [...b.label].length + 4, 0) + 3
625        const legendW = Math.max(60, W - used * 8)
626        return (
627          <Box key={`${k}:row`} flexDirection="row" alignItems="center" marginLeft={2} marginBottom={1}>
628            {items.map(b => (
629              <Box key={`${b.key}:cell`} flexShrink={0} marginRight={1}>
630                {b.primary ? <Button key={b.key} label={b.label} variant="primary" onPress={b.onPress} /> : <Button key={b.key} label={b.label} onPress={b.onPress} />}
631              </Box>
632            ))}
633            <Box marginLeft={1} flexShrink={1}><Svg source={legendSvg(legendW, legend).source} alt={legend} width={legendW} height={16} /></Box>
634          </Box>
635        )
636      }
637      const usageAlt = shown.map(w => `${labelOf(w.kind)} ${wordOf(w.percentUsed)} ${Math.round(w.percentUsed)}%${w.resetsAt ? ` resets ${resetWords(w, nowMs)}` : ''} ↳ ${usageSoWhat(w, nowMs)}`)
638      const goalSpec: CardSpec = myGoals.length === 0 ? { icon: 'goals', label: 'Goals', color: SECTION.Goals.color, state: 'none yet' } : { ...fromRow(goalRows[0], 'goals'), ...(goalRows[0].note ? { phrase: goalRows[0].note } : {}) }
639      const ctx = context ? contextLevel(context.percent) : null
640      const desk: unknown[] = [
641        // ACCOUNT: the session and week windows, one thin row each
642        draw('head:account', dividerSvg(W, 'ACCOUNT'), 'ACCOUNT'),
643        ...(shown.length === 0
644          ? [card('usage', { icon: 'session', label: 'Usage', color: SECTION.Session.color, level: 'none', phrase: 'no reading yet' }, 'Usage no reading')]
645          : shown.map((w, i) => card(`usage:${w.kind}`, { ...usageSpec(w, nowMs), open: i === shown.length - 1 }, usageAlt[i]))),
646        // THIS CHAT, set apart by a rule: what it has taken so far, then everything about it
647        draw('head:chat', dividerSvg(W, 'THIS CHAT', chatSummary, true), `THIS CHAT${chatSummary ? ` · ${chatSummary}` : ''}`),
648        card('context', {
649          icon: 'context', label: 'Context', color: SECTION.Context.color,
650          level: ctx ? ctx.level : 'none', phrase: ctx ? ctx.phrase : 'no reading yet',
651          ...(context && ctx ? { value: `${Math.round(context.percent)}%`, bar: { pct: context.percent, color: LEVEL_COLOR[ctx.level], ticks: [50, 75, 90] }, aside: `${fmtTokens(context.tokens)} / ${fmtTokens(context.window)}${msgCost ? ` · ${msgCost}` : ''}` } : {}),
652        }, `${say(contextRow)} ↳ ${contextHint}`),
653        card('cache', {
654          icon: 'cache', label: 'Cache', color: SECTION.Cache.color, level: cache.level, phrase: cache.phrase,
655          ...(cache.row.pct ? { value: cache.row.pct } : {}),
656          // The bar runs the way every other meter on the board does: how much of the warm hour is gone,
657          // short and green after a reply, full and red once the cache is cold.
658          ...(cache.row.fill !== undefined ? { bar: { pct: 100 - cache.row.fill, color: LEVEL_COLOR[cache.level] } } : {}),
659          ...(cache.aside ? { aside: cache.aside } : {}), open: true,
660        }, `${say(cache.row)}${cache.hint ? ` ↳ ${cache.hint}` : ''}`),
661        Action('cache:keepwarm', cache.keep, cache.keepHint, () => void toggleKeepwarm($), false, CACHE_BUTTON_W),
662        Action('cache:handoff', cache.handoff, cache.handoffHint, () => void writeHandoff($, false), false, CACHE_BUTTON_W),
663        // Goals first: what done looks like, then the progress toward it
664        card('goals', { ...goalSpec, open: myGoals.length === 0 }, goalRows.map(say).join(' · ')),
665        ...(myGoals.length === 0 ? [(px += INPUT_PX, <Box key="goals:new" marginLeft={2} marginBottom={1}>{goalBox}</Box>)] : []),
666        card('progress', { ...fromRow(progressRow, 'progress'), ...(progressTimer ? { label: `Progress  ${progressTimer}` } : {}), ...(progressRow.note ? { phrase: progressRow.note } : {}), ...(progressEta && progressRow.fill !== undefined ? { aside: progressEta } : {}), rows: planItems },
667          `${say(progressRow)}${progressEta ? ` · ${progressEta}` : ''}${sayRows(planItems)}`),
668        card('agents', { ...fromRow(agentsHead, 'agents'), ...(agentsHead.note ? { phrase: agentsHead.note } : {}), rows: agentItems },
669          `${say(agentsHead)}${sayRows(agentItems)}`),
670        card('crew', { icon: 'agents', label: 'Crew chats', color: SECTION.Agents.color, ...(crewHead.sub ? { state: crewHead.sub } : {}), ...(crewHead.note ? { phrase: crewHead.note } : {}), rows: crewItems },
671          `Crew chats ${crewHead.sub || crewHead.note || ''}${sayRows(crewItems)}`),
672        ...(crewDone > 0 ? [Action('crew:forget', 'Forget finished chats', `${crewDone} finished (they stay in the app's sidebar)`, () => void crewForget($))] : []),
673        card('needs', { icon: 'caution', label: 'Needs you', color: SECTION['Needs you'].color, level: nv.level, phrase: nv.phrase, rows: needRows,
674          ...(nv.paste ? { more: nv.paste } : {}), open: waiting || nv.buttons.length > 0 },
675          `Needs you ${nv.phrase}${sayRows(needRows)}`),
676        holdBox,
677        ...nv.buttons.map(b => Action(b.key, b.label, b.legend, () => void pressButton($, b.key), b.primary, NEED_BUTTON_W)),
678        // CHANGES: what the chat produced, what went live, the screen before and after
679        draw('head:changes', dividerSvg(W, 'CHANGES', changesSummary, true), `CHANGES · ${changesSummary}`),
680        card('files', { icon: 'files', label: 'File Changes', color: SECTION.Files.color, state: `${changedFiles.size} changed`, stateColor: changedFiles.size ? SECTION.Files.color : undefined,
681          ...(sv.filesPhrase ? { phrase: sv.filesPhrase } : {}), rows: changedItems, ...(filesMore ? { more: filesMore } : {}), open: true },
682          `File Changes · ${changedFiles.size} changed this session${changedItems.map(i => ` · ${i.text}${i.add !== undefined ? ` +${i.add} −${i.del}` : ''}`).join('')}${filesMore ? ` ${filesMore}` : ''}`),
683        Actions('files:see', [
684          { key: 'files:replay', label: replayLabel, onPress: () => void replayNow($) },
685          { key: 'files:visual', label: visualLabel, onPress: () => void toggleCompare($), on: compare.open },
686        ], filesLegend),
687        ...(compare.open
688          ? [
689              draw('compare', compareSvg(W, cv.before, cv.after, cv.asks), cv.alt),
690              ...(cv.asks.some(a => !a.done) ? [Action('compare:fix', 'Fix the rest', cv.fixLegend, () => void pressFixRest($), true, NEED_BUTTON_W)] : []),
691              Action('compare:ok', 'Looks good ✓', 'closes the compare', () => void pressLooksGood($), false, NEED_BUTTON_W),
692            ]
693          : []),
694        card('ship', { icon: 'ship', label: 'Ship', color: SECTION.Ship.color, ...(sv.level ? { level: sv.level } : { state: sv.state, stateColor: sv.stateColor }), phrase: sv.phrase,
695          value: sv.value, bar: { pct: sv.pct, color: sv.barColor }, ...(sv.aside ? { aside: sv.aside } : {}), rows: sv.rows, open: true },
696          `Ship ${sv.state} ${sv.phrase} ${sv.value}${sayRows(sv.rows)}`),
697        ...sv.buttons.map(b => Action(b.key, b.label, b.legend, () => void pressButton($, b.key), b.primary, NEED_BUTTON_W)),
698      ]
699      // The desktop scrolls the whole board natively, cards and tree together (ui.scroll passes the
700      // wheel to the engine), so the tree is drawn whole, up to DESKTOP_TREE_ROWS, not windowed.
701      paneSurface = 'desktop'
702      const tree = await drawTree($, { ...e, props: { ...e.props, scroll: { offset: scroll?.offset ?? 0, bodyRows: DESKTOP_TREE_ROWS } } })
703      return (
704        <Box flexDirection="column">
705          {desk as never}
706          {tree}
707        </Box>
708      )
709    }
710
711    // Terminal: the same sections as text rows.
712    paneSurface = 'terminal'
713    const Item = (i: CardRow & { k: string }) => (
714      <Box key={i.k} flexDirection="row">
715        <Cell w={LABEL_W}><Text color={i.color}>{`  ${MARK_GLYPH[i.mark] ?? '·'}`}</Text></Cell>
716        <Box flexShrink={i.mark === 'held' || i.mark === 'failed' || i.k.startsWith('risk') ? 0 : 1} minWidth={8}><Text bold={i.strong} dimColor={i.dim} wrap="truncate-end">{i.mark === 'dot' ? `· ${i.text}` : i.text}</Text></Box>
717        {i.add !== undefined && <Box marginLeft={2} flexShrink={0}><Text color={ADD_COLOR}>{`+${i.add}`}</Text><Text> </Text><Text color={DEL_COLOR}>{`−${i.del}`}</Text></Box>}
718        {i.tail && <Box marginLeft={2} flexShrink={1}><Text dimColor wrap="truncate-end">{i.tail}</Text></Box>}
719      </Box>
720    )
721    const rows: unknown[] = [
722      // The account's windows, no header: Session and Week say it themselves
723      ...usageBlock,
724      Blank('blank:1'),
725      Rule('rule:chat'),
726      // THIS CHAT: everything about the conversation you are in
727      Head('head:chat', `THIS CHAT${chatSummary ? ` · ${chatSummary}` : ''}`),
728      Rule('rule:chat:under'),
729      Row(contextRow),
730      Hint('context:hint', contextHint),
731      Rule('rule:cache'),
732      // Cache: warm or cold with the hour draining; the size and prices on their own line under it;
733      // one button per row with its legend beside it (the legends carry the keepwarm and handoff state)
734      Row(cache.row),
735      ...(cache.hint === null ? [] : [Hint('cache:hint', cache.hint)]),
736      ButtonRow('cache:keepwarm', cache.keep, cache.keepHint, () => void toggleKeepwarm($)),
737      ButtonRow('cache:handoff', cache.handoff, cache.handoffHint, () => void writeHandoff($, false)),
738      // Goals first: what done looks like, then the progress toward it
739      Rule('rule:goals'),
740      ...goalRows.map(r => Row(r)),
741      ...(myGoals.length === 0
742        ? [
743            <Box key="goals:new" flexDirection="row">
744              <Cell w={LABEL_W}><Text> </Text></Cell>
745              <Box flexGrow={1} flexShrink={1}>{goalBox}</Box>
746            </Box>,
747          ]
748        : []),
749      Rule('rule:progress'),
750      Row({ ...progressRow, note: [progressTimer, progressRow.note, progressEta].filter(Boolean).join(' · ') }),
751      ...planItems.map(Item),
752      Rule('rule:agents'),
753      Row(agentsHead),
754      ...agentItems.map(Item),
755      Rule('rule:crew'),
756      Row(crewHead),
757      ...crewItems.map(Item),
758      ...(crewDone > 0 ? [ButtonRow('crew:forget', 'Forget finished chats', `${crewDone} finished (they stay in the app's sidebar)`, () => void crewForget($))] : []),
759      Rule('rule:needs'),
760      Row(riskHead),
761      ...needRows.map(Item),
762      ...(nv.paste ? [Hint('needs:paste', nv.paste)] : []),
763      ...(holdBox ? [holdBox] : []),
764      ...nv.buttons.map(b => ButtonRow(b.key, b.label, b.legend, () => void pressButton($, b.key), b.primary)),
765    ]
766    // CHANGES: the files Claude changed with their line counts, the two See buttons, the compare when open, then Ship.
767    const filesRows: unknown[] = [
768      Blank('blank:changes'),
769      Rule('rule:changes'),
770      Head('head:changes', `CHANGES · ${changesSummary}`),
771      Rule('rule:changes:under'),
772      <Box key="files:head" flexDirection="row">
773        <Text bold color={SECTION.Files.color} wrap="truncate-end">{`${SECTION.Files.icon} File Changes`}</Text>
774        <Text dimColor wrap="truncate-end">{`  ${changedFiles.size} changed this session${sv.filesPhrase ? ` · ${sv.filesPhrase}` : ''}`}</Text>
775      </Box>,
776      ...changedItems.map(i => Item({ ...i, mark: 'none', text: `· ${i.text}` })),
777      ...(filesMore ? [Hint('files:more', filesMore)] : []),
778      ButtonsRow('files:see', [
779        { key: 'files:replay', label: replayLabel, onPress: () => void replayNow($) },
780        { key: 'files:visual', label: visualLabel, onPress: () => void toggleCompare($) },
781      ], filesLegend),
782      ...(compare.open
783        ? [
784            Hint('compare:head', `BEFORE ${cv.before.sub} · AFTER ${cv.after.sub}${compare.afterPath ? ` · ${compare.afterPath}` : ''}`),
785            ...cv.asks.map((a, i) => Item({ k: `compare:${i}`, mark: a.done ? 'done' : 'held', color: a.done ? DONE : 'yellow', text: `${i + 1}. ${a.text}`, tail: a.done ? 'done' : 'open' })),
786            ...(cv.asks.some(a => !a.done) ? [ButtonRow('compare:fix', 'Fix the rest', cv.fixLegend, () => void pressFixRest($), true)] : []),
787            ButtonRow('compare:ok', 'Looks good ✓', 'closes the compare', () => void pressLooksGood($)),
788          ]
789        : []),
790      Rule('rule:ship'),
791      Row({ k: 'ship:head', label: 'Ship', sub: sv.state, subColor: sv.stateColor, bold: sv.level === 'act', barText: gauge(sv.pct, barW), barColor: sv.barColor, pct: sv.value, note: [sv.phrase, sv.aside].filter(Boolean).join(' · ') }),
792      ...sv.rows.map(Item),
793      ...sv.buttons.map(b => ButtonRow(b.key, b.label, b.legend, () => void pressButton($, b.key), b.primary)),
794    ]
795
796    // Tell the tree it has fewer rows, so the pane does not overflow by the sections' height.
797    const taken = rows.length + filesRows.length + holdHeight
798    const tree = await drawTree($, scroll ? { ...e, props: { ...e.props, scroll: { ...scroll, bodyRows: Math.max(5, scroll.bodyRows - taken) } } } : e)
799
800    return (
801      <Box flexDirection="column">
802        {rows as never}
803        {filesRows as never}
804        {tree}
805      </Box>
806    )
807}
808
809const LEVEL_COLOR: Record<Level, string> = { fine: 'green', watch: 'amber', act: 'red', none: 'gray' }
810
811/** A window as one desktop row: the board's status word with what it means, the bar with the pace
812 *  marker (how much of the window has gone by), the percent, and how long until it resets. */
813function usageSpec(w: Window, now: number): CardSpec {
814  const session = w.kind.toLowerCase().startsWith('five_hour')
815  const len = session ? WINDOW_MS.five_hour : WINDOW_MS.seven_day
816  const reset = w.resetsAt ? Date.parse(w.resetsAt) : NaN
817  const gone = Number.isNaN(reset) ? undefined : Math.max(0, Math.min(100, (100 * (len - (reset - now))) / len))
818  const used = Math.round(w.percentUsed)
819  const inTime = w.resetsAt ? timeLeft(w.resetsAt, now) : ''
820  const [level, phrase]: [Level, string] =
821    used >= 90 ? ['act', 'nearly spent']
822      : gone !== undefined && used > gone + 10 ? ['watch', 'ease off, may run out early']
823        : used >= 70 ? ['watch', 'getting high']
824          : gone !== undefined && used < gone - 10 ? ['fine', 'room to spare'] : ['fine', 'on pace']
825  return {
826    icon: session ? 'session' : 'week',
827    label: labelOf(w.kind),
828    color: SECTION[session ? 'Session' : 'Week']?.color ?? ACCENT,
829    level,
830    phrase,
831    value: `${used}%`,
832    ...(windowWorth(w) ? { valueNote: `(${windowWorth(w)})` } : {}),
833    bar: { pct: w.percentUsed, color: LEVEL_COLOR[level], ...(gone === undefined ? {} : { marker: gone }) },
834    // `↻ 2h24m (at 15:11)` for the session, `↻ 2d18h (Sat 11:00)` for the week: how long, then when.
835    ...(inTime && w.resetsAt ? { aside: `↻ ${inTime} (${session ? `at ${clock24(w.resetsAt)}` : `${DAYS[new Date(w.resetsAt).getDay()]} ${clock24(w.resetsAt)}`})` } : {}),
836  }
837}
838
839/** An ISO timestamp as `15:11`, in this machine's time zone. */
840function clock24(iso: string): string {
841  const d = new Date(iso)
842  return `${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`
843}
844
845/** The context window on the board's scale. */
846function contextLevel(pct: number): { level: Level; phrase: string } {
847  if (pct >= 90) return { level: 'act', phrase: 'compact soon, save key facts' }
848  if (pct >= 75) return { level: 'watch', phrase: 'nearly full, wrap up' }
849  if (pct >= 50) return { level: 'watch', phrase: 'filling up' }
850  return { level: 'fine', phrase: 'plenty of room' }
851}
852
853// --- what the account's windows are worth: the plan's monthly price over its weeks. A week's percent
854// is that share of a week's price; a session's percent counts as the share of the week it took, learned
855// from the readings (how far the week moves while the session moves), 1 session % ≈ 0.1 week % before.
856const WEEKS_PER_MONTH = 4.35
857const SESSION_TO_WEEK_DEFAULT = 0.1
858const LEARN_AFTER_SESSION_PCT = 5
859let planSetting = 'auto'
860const plan = {
861  usd: null as number | null,
862  ratio: { dw: 0, ds: 0 },
863  last: null as null | { s: number; sAt: number; w: number; wAt: number },
864}
865
866/** $ a month for the plan: the setting's own figure, or the account's tier as Claude Code saved it. */
867function planUsdOf(setting: string, tier: string): number | null {
868  if (setting === 'none') return null
869  const set = /\$(\d+)/.exec(setting)
870  if (set) return Number(set[1])
871  const t = tier.toLowerCase()
872  return /20x/.test(t) ? 200 : /5x/.test(t) ? 100 : /max/.test(t) ? 100 : /pro/.test(t) ? 20 : null
873}
874
875async function planStart($: EngineInterface): Promise<void> {
876  let tier = ''
877  try {
878    const cfg = JSON.parse(String(await $.fs.read(`${home}/.claude.json`)))
879    tier = String(cfg?.oauthAccount?.organizationRateLimitTier || cfg?.oauthAccount?.userRateLimitTier || '')
880  } catch {
881    tier = ''
882  }
883  plan.usd = planUsdOf(planSetting, tier)
884  const saved = (await $.store.get('plan.ratio')) as { dw?: number; ds?: number } | undefined
885  if (saved && typeof saved.dw === 'number' && typeof saved.ds === 'number') plan.ratio = { dw: saved.dw, ds: saved.ds }
886}
887
888/** Learns how much of the week a session percent takes: both windows read again inside the same resets. */
889async function planLearn($: EngineInterface, windows: Window[]): Promise<void> {
890  const [s, w] = [windows.find(x => x.kind.toLowerCase().startsWith('five_hour')), windows.find(x => x.kind.toLowerCase() === 'seven_day')]
891  if (!s || !w || !s.resetsAt || !w.resetsAt) return
892  const now = { s: s.percentUsed, sAt: Date.parse(s.resetsAt), w: w.percentUsed, wAt: Date.parse(w.resetsAt) }
893  const last = plan.last
894  plan.last = now
895  if (!last || Math.abs(now.sAt - last.sAt) > 60_000 || Math.abs(now.wAt - last.wAt) > 60_000) return
896  const ds = now.s - last.s
897  if (ds <= 0) return
898  plan.ratio = { dw: plan.ratio.dw + Math.max(0, now.w - last.w), ds: plan.ratio.ds + ds }
899  await $.store.set('plan.ratio', plan.ratio)
900}
901
902/** What a window's percent is worth on the plan, as `~$15`; '' off a known plan. */
903function windowWorth(w: Window): string {
904  if (plan.usd === null) return ''
905  const week = plan.usd / WEEKS_PER_MONTH
906  const ratio = plan.ratio.ds >= LEARN_AFTER_SESSION_PCT ? plan.ratio.dw / plan.ratio.ds : SESSION_TO_WEEK_DEFAULT
907  const usd = (w.kind.toLowerCase().startsWith('five_hour') ? w.percentUsed * ratio : w.percentUsed) * (week / 100)
908  return `~$${usd >= 10 ? Math.round(usd) : usd.toFixed(1)}`
909}
910
911// --- data
912
913async function readUsage($: EngineInterface): Promise<{ windows: Window[]; context: Context | null; cost: number | null }> {
914  try {
915    const u = await $.session.usage()
916    const windows = (u.rateLimits ?? []).filter(w => typeof w.percentUsed === 'number')
917    void planLearn($, windows).catch(() => {})
918    const c = u.context
919    const context: Context | null =
920      c && c.window ? { tokens: c.tokens ?? 0, window: c.window, percent: c.percent ?? ((c.tokens ?? 0) / c.window) * 100 } : null
921    const cost = u.cost && typeof u.cost.usd === 'number' && u.cost.usd > 0 ? u.cost.usd : null
922    return { windows, context, cost }
923  } catch {
924    return { windows: [], context: null, cost: null }
925  }
926}
927
928/** The phase label savvy-progress's band shows: Plan, Design, Tasks 1/3, Review 1/3, Done. */
929function flowLabel(f: Flow): string {
930  if (f.isFinished) return 'Done'
931  if (f.phase === 'plan') return 'Plan'
932  if (f.phase === 'design') return 'Design'
933  const count = f.total ? `${f.done}/${f.total}` : `${f.running} running`
934  return `${f.phase === 'review' ? 'Review' : 'Tasks'} ${count}`
935}
936
937/** Running agents first (newest first), then the most recent finished ones. */
938function pick(list: AgentRun[]): AgentRun[] {
939  const running = list.filter(a => a.status === 'running').reverse()
940  const finished = list.filter(a => a.status !== 'running').reverse()
941  return [...running, ...finished].slice(0, MAX_AGENT_ROWS)
942}
943
944function agentsHeadline(list: AgentRun[], now: number): string {
945  if (list.length === 0) return ''
946  const running = list.filter(a => a.status === 'running').length
947  const failed = list.filter(a => a.status === 'failed').length
948  const done = list.length - running - failed
949  const cost = list.reduce((s, a) => s + a.costUsd, 0)
950  const tokens = list.reduce((s, a) => s + a.tokens, 0)
951  const start = Math.min(...list.map(a => a.startedAt))
952  const end = Math.max(...list.map(a => a.endedAt ?? Math.max(now, a.startedAt)))
953  const parts = [`${running} running`, `${done} done`]
954  if (failed) parts.push(`${failed} failed`)
955  parts.push(`≈${fmtCost(cost)}`, `${fmtTokens(tokens)} tok`, fmtTime(end - start))
956  return parts.join(' · ')
957}
958
959function detail(a: AgentRun, now: number): string {
960  // A crew worker says which one it is first: `crew-careful · Opus 5.5 · …`.
961  const worker = tierOf(a.type) !== 'other' ? `crew-${tierOf(a.type)} · ` : ''
962  const steps = a.stepTotal ? `${worker}${a.stepDone ?? 0}/${a.stepTotal} · ` : worker
963  const ended = a.endedAt ?? Math.max(now, a.startedAt)
964  return `${steps}${modelName(a.model)} · ${fmtTokens(a.tokens)} ≈${fmtCost(a.costUsd)} ${fmtTime(ended - a.startedAt)}`
965}
966
967// --- usage windows
968
969const WINDOW_MS: Record<string, number> = { five_hour: 5 * 3600_000, seven_day: 7 * 24 * 3600_000 }
970
971const isSession = (w: Window): boolean => w.kind.toLowerCase() === 'five_hour'
972const isWeek = (w: Window): boolean => w.kind.toLowerCase() === 'seven_day'
973
974/** The session and week windows only, in that order: the per-model and spend windows stay out. */
975function mainWindows(windows: Window[]): Window[] {
976  const session = windows.find(isSession) ?? windows.find(w => w.kind.toLowerCase().startsWith('five_hour'))
977  const week = windows.find(isWeek) ?? windows.find(w => w.kind.toLowerCase().startsWith('seven_day'))
978  return [session, week].filter((w): w is Window => w !== undefined)
979}
980
981/** One line under a window's bar: how much of it has gone by against how much is used, and what that means. */
982function usageSoWhat(w: Window, now: number): string {
983  const name = w.kind.toLowerCase().startsWith('five_hour') ? 'session' : 'week'
984  const len = name === 'session' ? WINDOW_MS.five_hour : WINDOW_MS.seven_day
985  const used = Math.round(w.percentUsed)
986  if (used >= 90) return `Nearly spent (${used}%) · back ${resetWords(w, now)}`
987  const reset = w.resetsAt ? Date.parse(w.resetsAt) : NaN
988  if (Number.isNaN(reset)) return `${used}% used`
989  const gone = Math.max(0, Math.min(100, Math.round((100 * (len - (reset - now))) / len)))
990  if (used < gone - 10) return `${gone}% of the ${name} gone, ${used}% used · room to spare`
991  if (used > gone + 10) return `${gone}% of the ${name} gone, ${used}% used · ease off, it may run out early`
992  return `${gone}% of the ${name} gone, ${used}% used · right on pace`
993}
994
995/** `Fri 1:30 AM (in 2h40m)`, or `soon` with no reset time. */
996function resetWords(w: Window, now: number): string {
997  if (!w.resetsAt) return 'soon'
998  const inTime = timeLeft(w.resetsAt, now)
999  return `${when(w.resetsAt)}${inTime ? ` (in ${inTime})` : ''}`
1000}
1001
1002/** One word for a window's bar: fine, high, near cap. */
1003const wordOf = (pct: number): string => (pct >= 90 ? 'near cap' : pct >= 70 ? 'high' : 'fine')
1004
1005/** How long until an ISO timestamp: `45m`, `2h40m`, `1d2h`; empty once it has passed. */
1006function timeLeft(iso: string, now: number): string {
1007  const ms = Date.parse(iso) - now
1008  if (Number.isNaN(ms) || ms <= 0) return ''
1009  const mins = Math.round(ms / 60_000)
1010  const d = Math.floor(mins / 1440)
1011  const hr = Math.floor((mins % 1440) / 60)
1012  const m = mins % 60
1013  if (d) return hr ? `${d}d${hr}h` : `${d}d`
1014  if (hr) return m ? `${hr}h${m}m` : `${hr}h`
1015  return `${Math.max(1, m)}m`
1016}
1017
1018/** `five_hour` → Session, `seven_day` → Week, `seven_day_fable` → Fable wk, `spend_limit` → Spend. */
1019function labelOf(kind: string): string {
1020  const k = kind.toLowerCase()
1021  const model = /(fable|mythos|opus|sonnet|haiku)/.exec(k)?.[1]
1022  const name = model ? model.charAt(0).toUpperCase() + model.slice(1) : ''
1023  if (k.startsWith('five_hour')) return name ? `${name} 5h` : 'Session'
1024  if (k.startsWith('seven_day')) return name ? `${name} wk` : 'Week'
1025  if (k.startsWith('spend')) return 'Spend'
1026  return kind.slice(0, SUB_W - 1)
1027}
1028
1029const forecastFor = (percent: number) => FORECAST.find(b => percent < b.upTo) ?? FORECAST[FORECAST.length - 1]
1030
1031const toneOf = (pct: number): string => (pct >= 90 ? 'red' : pct >= 70 ? 'yellow' : 'blue')
1032
1033const gauge = (pct: number, width: number): string => {
1034  const filled = Math.max(0, Math.min(width, Math.round((width * pct) / 100)))
1035  return '█'.repeat(filled) + '░'.repeat(width - filled)
1036}
1037
1038const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
1039
1040/** An ISO timestamp as `Fri 1:30 AM`, in this machine's time zone. */
1041function when(iso: string): string {
1042  const d = new Date(iso)
1043  if (Number.isNaN(d.getTime())) return iso
1044  const hr = d.getHours()
1045  const m = String(d.getMinutes()).padStart(2, '0')
1046  const h12 = hr % 12 === 0 ? 12 : hr % 12
1047  return `${DAYS[d.getDay()]} ${h12}:${m} ${hr < 12 ? 'AM' : 'PM'}`
1048}
1049
1050// --- formatting
1051
1052const statusColor = (s: AgentRun['status']): string => (s === 'running' ? RUNNING : s === 'done' ? DONE : FAILED)
1053
1054
1055// =============================================================================
1056// The file tree (from the filetree mod)
1057// =============================================================================
1058
1059const TREE = { plugin: 'cockpit', key: 'tree' } as const
1060const THEME = { plugin: 'cockpit', key: 'theme' } as const
1061const ACTIVITY = { plugin: 'cockpit', key: 'activity' } as const
1062const PANE = 'cockpit'
1063const NO_SURFACE_TEXT = 'The Cockpit Board is a side panel, and this session has no screen attached to draw it on. Open this chat in the Claude desktop app (Code tab) or run claude in a terminal, then type /cockpit again.'
1064const SHIMMER = Object.fromEntries(Object.entries(TONES).map(([k, v]) => [k, { bright: v.bright, dim: v.dim }]))
1065const BRANCH_ROW = '#branch'
1066const FLASH_MS = 2700
1067const RUNNING_MAX_MS = 600_000
1068const NO_REPO_DEPTH = 4
1069const DOUBLE_MS = 450
1070const FIND_LIMIT = 200
1071const READ_REVEAL_LIMIT = 12
1072const SEARCH_REVEAL_LIMIT = 60
1073const ACTIVITY_TTL_MS = 45_000
1074const ADD_COLOR = '#98c379'
1075const DEL_COLOR = '#e06c75'
1076const THEME_FILE = '.local/state/omarchy/current/theme/colors.toml'
1077const THEME_POLL_MS = 2000
1078const FONT_SCRIPT =
1079  'if command -v fc-list >/dev/null 2>&1; then f=$(fc-list ":charset=$1" file | head -n1 | cut -d: -f1); ' +
1080  'else f=$(ls "$HOME"/Library/Fonts/*Nerd* /Library/Fonts/*Nerd* 2>/dev/null | head -n1); fi; ' +
1081  '[ -n "$f" ] || { echo missing; exit 0; }; m=$(stat -c %Z "$f" 2>/dev/null || stat -f %c "$f"); p=$PPID; ' +
1082  'while [ -n "$p" ] && [ "$p" -gt 1 ]; do c=$(ps -o comm= -p "$p" 2>/dev/null); c=$(basename "$c" 2>/dev/null | tr -d " "); case "$c" in ' +
1083  'ghostty|kitty|alacritty|Alacritty|foot|footclient|wezterm-gui|konsole|gnome-terminal-|xterm|urxvt|st|Terminal|iTerm2) ' +
1084  'e=$(ps -o etime= -p "$p" 2>/dev/null | awk -F\'[-:]\' \'{n=NF; s=$n+60*$(n-1); if (n>2) s+=3600*$(n-2); if (n>3) s+=86400*$(n-3); print s}\'); [ -n "$e" ] && [ $(( $(date +%s) - e )) -lt "$m" ] && echo stale || echo ok; exit 0;; esac; ' +
1085  'p=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d " "); done; echo ok'
1086const PRUNE = ['.git', 'node_modules', 'target', '.venv', '__pycache__', 'dist', '.next']
1087const HOME_PRUNE = ['Library', 'AppData', '.Trash']
1088const SIZE_TIMEOUT_MS = 15_000
1089const SIZE_JOBS = 2
1090const SIZE_WALK_LIMIT = 50_000
1091
1092let blink: Timer | null = null
1093let themePoll: Timer | null = null
1094let themeMtime: number | null = null
1095let generation = 0
1096let lastPress = { key: '', at: 0 }
1097let noNerd = false
1098let glyphSetting = 'auto'
1099let follow = true
1100let followClaude = true
1101let scanning: Promise<void> | null = null
1102let scanJobs: Job[] = []
1103let gitRun: Promise<void> | null = null
1104let gitNext: Promise<void> | null = null
1105let queuedReads: string[] = []
1106let showReads = true
1107let showWrites = true
1108let searchIndex: { root: string; paths: Promise<string[]> } | null = null
1109let activityId = 0
1110let pointer = true
1111let view = { from: 0, max: 0 }
1112// Where the board was last drawn: the desktop scrolls it natively, the terminal row by row.
1113let paneSurface = ''
1114const DESKTOP_TREE_ROWS = 400
1115let lastSync = 0
1116let noDock = false
1117let home = ''
1118let platform: Promise<'linux' | 'darwin' | 'win32'> | null = null
1119let dirty: { root: string; files: Record<string, Change> } = { root: '', files: {} }
1120let markId = 0
1121let listSeq = 0
1122let lastRoot = ''
1123let sizeDefault = false
1124let sizeEpoch = 0
1125let sizeActive = 0
1126let sizeQueue: string[] = []
1127const sizing = new Set<string>()
1128const listLatest = new Map<string, number>()
1129const unreadable = new Set<string>()
1130const background = new Map<string, Background>()
1131
1132function osName($: EngineInterface): Promise<'linux' | 'darwin' | 'win32'> {
1133  platform ??= (async () => {
1134    if ((await $.env.get('OS')) === 'Windows_NT') return 'win32'
1135    try {
1136      return (await $.process.run(['uname', '-s'], { timeoutMs: 3_000 })).stdout.trim() === 'Darwin' ? 'darwin' : 'linux'
1137    } catch {
1138      return 'linux'
1139    }
1140  })()
1141  return platform
1142}
1143
1144async function cwdOf($: EngineInterface): Promise<string> {
1145  return posix(await $.session.cwd())
1146}
1147
1148function clean(segs: Seg[]): Seg[] {
1149  for (const seg of segs) for (const k of Object.keys(seg) as (keyof Seg)[]) if (seg[k] === undefined) delete seg[k]
1150  return segs
1151}
1152
1153function faint(hex: string): string {
1154  const v = parseInt(hex.slice(1), 16)
1155  const ch = (shift: number) => Math.round(0x26 + (((v >> shift) & 255) - 0x26) * 0.3)
1156  return `#${[16, 8, 0].map(x => ch(x).toString(16).padStart(2, '0')).join('')}`
1157}
1158
1159async function get($: EngineInterface): Promise<FileTree> {
1160  return { ...emptyTree(''), ...(await $.state.get(TREE)).value }
1161}
1162
1163async function put($: EngineInterface, fn: (t: FileTree) => FileTree): Promise<void> {
1164  await update($, TREE, cur => fn({ ...emptyTree(''), ...cur }))
1165}
1166
1167function patch($: EngineInterface, fn: (t: FileTree) => Partial<FileTree>) {
1168  return put($, t => ({ ...t, ...fn(t) }))
1169}
1170
1171async function activities($: EngineInterface): Promise<Activity[]> {
1172  return (await $.state.get(ACTIVITY)).value ?? []
1173}
1174
1175async function setActivities($: EngineInterface, fn: (list: Activity[]) => Activity[]): Promise<void> {
1176  await update($, ACTIVITY, cur => fn(cur ?? []).slice(-6))
1177}
1178
1179async function listDir($: EngineInterface, dir: string): Promise<FileNode[] | null> {
1180  try {
1181    const entries = await $.fs.list(dir)
1182    const resolved = await Promise.all(
1183      entries.map(async e => {
1184        if (!e.isLink) return { name: e.name, kind: e.kind, mtimeMs: e.mtimeMs, size: e.size, isLink: false }
1185        try {
1186          const target = await $.fs.stat(join(dir, e.name))
1187          return target.kind === 'dir'
1188            ? { name: e.name, kind: 'dir' as const, mtimeMs: target.mtimeMs, size: 0, isLink: false }
1189            : { name: e.name, kind: 'file' as const, mtimeMs: target.mtimeMs, size: target.size, isLink: true }
1190        } catch {
1191          return { name: e.name, kind: 'other' as const, mtimeMs: 0, size: 0, isLink: true }
1192        }
1193      }),
1194    )
1195    unreadable.delete(dir)
1196    return toNodes(dir, resolved)
1197  } catch (err) {
1198    if (!(await exists($, dir))) return []
1199    if (!unreadable.has(dir)) {
1200      unreadable.add(dir)
hooks/open.ts 41 lines
1export const LINUX_LAUNCH = 'setsid -f -w "$@" </dev/null >/dev/null 2>&1 & p=$!; sleep 1; kill -0 "$p" 2>/dev/null && exit 0; wait "$p"'
2
3export const LINUX_OPEN = [
4  'set -f',
5  'f=$1',
6  'if command -v gio >/dev/null; then',
7  '  if [ -f "$f" ] && command -v file >/dev/null && encoding=$(file -b --mime-encoding -- "$f") && [ -n "$encoding" ] && [ "$encoding" != binary ]; then',
8  '    case $(gio info -a standard::content-type -- "$f" | sed -n \'s/.*standard::content-type: //p\') in',
9  '      video/* | audio/*)',
10  '        app=$(xdg-mime query default text/plain)',
11  '        IFS=:',
12  '        for dir in "${XDG_DATA_HOME:-$HOME/.local/share}" ${XDG_DATA_DIRS:-/usr/local/share:/usr/share}; do',
13  '          [ -n "$app" ] && [ -f "$dir/applications/$app" ] && exec gio launch "$dir/applications/$app" "$f"',
14  '        done',
15  '        echo "No text/plain desktop application found for $f" >&2',
16  '        exit 1',
17  '        ;;',
18  '    esac',
19  '  fi',
20  '  if (exec gio open "$f"); then exit 0; fi',
21  'fi',
22  'exec xdg-open "$f"',
23].join('\n')
24
25const WIN_OPEN = "$ErrorActionPreference = 'Stop'; [Diagnostics.Process]::Start([Diagnostics.ProcessStartInfo]@{ FileName = $env:PANE_OPEN_TARGET; UseShellExecute = $true }) | Out-Null"
26
27export function openCommand(os: 'linux' | 'darwin' | 'win32', target: string) {
28  const argv = os === 'linux'
29    ? ['sh', '-c', LINUX_LAUNCH, 'sh', 'sh', '-c', LINUX_OPEN, 'sh', target]
30    : os === 'darwin'
31      ? ['open', '--', target]
32      : ['powershell', '-NoProfile', '-NonInteractive', '-Command', WIN_OPEN]
33  return {
34    argv,
35    init: {
36      timeoutMs: 10_000,
37      ...(os === 'win32' ? { env: { PANE_OPEN_TARGET: /^[a-z][a-z0-9+.-]*:\/\//i.test(target) ? target : target.replace(/\//g, '\\') } } : {}),
38    },
39  }
40}
41
hooks/board-svg.ts 312 lines
1// The board's desktop look (visual 3.0): each block of the Cockpit Board drawn as one SVG card,
2// the way the agents panel already is. The terminal keeps its text
3// rows; the desktop draws proportional text, where block-glyph bars never lined up, so there the
4// gauges are real shapes. Pure functions only: the hooks module passes the data in.
5
6export const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
7
8export const xml = (s: string): string =>
9  s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c)
10
11// Rough advance of system UI text, in em; good enough to size a slot.
12const charEm = (ch: string): number =>
13  /[\s.,:;'|!il1()[\]·]/.test(ch) ? 0.3 : /[A-ZА-ЯЁmwшщжюМШЩЖЮ@%]/.test(ch) ? 0.72 : 0.56
14
15export const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
16
17/** Cuts `s` to fit `maxW` pixels, with an ellipsis when it had to cut. */
18export const fitText = (s: string, size: number, maxW: number): string => {
19  if (textWidth(s, size) <= maxW) return s
20  let out = ''
21  for (const ch of s) {
22    if (textWidth(out + ch + '…', size) > maxW) break
23    out += ch
24  }
25  return out + '…'
26}
27
28// One palette for both themes: neutrals flip with the system, tones read on either.
29export const TONE: Record<string, string> = {
30  blue: '#5b8def',
31  green: '#3fa66b',
32  amber: '#d99a26',
33  yellow: '#d9a520',
34  red: '#e0564f',
35  violet: '#8f8cf4',
36  cyan: '#2fb3c9',
37  teal: '#26a69a',
38  orange: '#f0803c',
39  magenta: '#c160c9',
40  gray: '#8d8b86',
41}
42
43/** A colour the terminal names (`cyan`, `green`) or a hex, as a hex the SVG can fill with. */
44export const hexOf = (c: string | undefined, fallback = TONE.gray): string => (!c ? fallback : c.startsWith('#') ? c : (TONE[c] ?? fallback))
45
46const CSS = `<style>
47.ink{fill:#1f1e1d}.sub{fill:#6b6a66}.mute{fill:#9a9893}.track{fill:#ebe9e4}.card{fill:#f7f6f3;stroke:#e7e5e0}.hair{stroke:#e7e5e0}.tick{fill:#d3d0c9}.pace{fill:#1f1e1d}.rule{fill:#c9c6bf}
48@media (prefers-color-scheme: dark){.ink{fill:#ecebe8}.sub{fill:#aaa8a2}.mute{fill:#76746f}.track{fill:#33322f}.card{fill:#222221;stroke:#30302e}.hair{stroke:#30302e}.tick{fill:#4a4946}.pace{fill:#ecebe8}.rule{fill:#4a4946}}
49.live{animation:p 1.6s ease-in-out infinite}@keyframes p{50%{opacity:.25}}
50@media (prefers-reduced-motion: reduce){.live{animation:none}}
51</style>`
52
53const svg = (W: number, H: number, body: string): string =>
54  `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${CSS}${body}</svg>`
55
56const text = (x: number, y: number, s: string, size: number, cls: string, extra = ''): string =>
57  `<text class="${cls}" x="${x}" y="${y}" font-family="${FONT}" font-size="${size}"${extra}>${xml(s)}</text>`
58
59const tinted = (x: number, y: number, s: string, size: number, color: string, extra = ''): string =>
60  `<text x="${x}" y="${y}" font-family="${FONT}" font-size="${size}" fill="${color}"${extra}>${xml(s)}</text>`
61
62const NUM = ' font-variant-numeric="tabular-nums"'
63const BOLD = ' font-weight="600"'
64
65/** A rounded pill with a word in it; returns the markup and its width. */
66const pill = (x: number, y: number, word: string, color: string): [string, number] => {
67  const w = Math.round(textWidth(word, 10.5) + 14)
68  return [
69    `<rect x="${x}" y="${y}" width="${w}" height="17" rx="8.5" fill="${color}" fill-opacity=".15"/>${tinted(x + w / 2, y + 12, word, 10.5, color, `${BOLD} text-anchor="middle"`)}`,
70    w,
71  ]
72}
73
74/** A bar: the track, the fill, optional ticks (in percent) and a pace marker (in percent). */
75const bar = (x: number, y: number, w: number, h: number, pct: number, color: string, ticks: number[] = [], marker?: number): string => {
76  const p = Math.max(0, Math.min(100, pct))
77  const fill = p > 0 ? Math.max(h, Math.round((w * p) / 100)) : 0
78  const tk = ticks.map(t => `<rect class="tick" x="${x + Math.round((w * t) / 100) - 0.5}" y="${y}" width="1" height="${h}"/>`).join('')
79  const mk =
80    marker === undefined
81      ? ''
82      : (() => {
83          const mx = x + Math.round((w * Math.max(0, Math.min(100, marker))) / 100)
84          return `<rect class="pace" x="${mx - 1}" y="${y - 3}" width="2" height="${h + 6}" rx="1"/>`
85        })()
86  return `<rect class="track" x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/>${tk}${fill ? `<rect x="${x}" y="${y}" width="${fill}" height="${h}" rx="${h / 2}" fill="${color}"/>` : ''}${mk}`
87}
88
89// Section icons drawn as shapes, 12 px, centred on (x, y): glyphs from the font came out at odd
90// sizes, and some (⚡) as colour emoji.
91const ICONS: Record<string, (x: number, y: number, c: string) => string> = {
92  session: (x, y, c) => `<circle cx="${x}" cy="${y}" r="5.5" fill="none" stroke="${c}" stroke-width="1.4"/><path d="M${x} ${y - 3}v3l2 1.5" fill="none" stroke="${c}" stroke-width="1.4" stroke-linecap="round"/>`,
93  week: (x, y, c) => `<rect x="${x - 5.5}" y="${y - 4.5}" width="11" height="10" rx="2" fill="none" stroke="${c}" stroke-width="1.4"/><path d="M${x - 5.5} ${y - 1.5}h11M${x - 2.5} ${y - 6.5}v3M${x + 2.5} ${y - 6.5}v3" stroke="${c}" stroke-width="1.4" stroke-linecap="round"/>`,
94  context: (x, y, c) => `<circle cx="${x}" cy="${y}" r="5.5" fill="none" stroke="${c}" stroke-width="1.5"/><path d="M${x} ${y}V${y - 5.5}A5.5 5.5 0 0 1 ${x + 5.5} ${y}z" fill="${c}"/>`,
95  cache: (x, y, c) => `<path d="M${x + 1} ${y - 6}L${x - 4} ${y + 1}h4l-1 5 5-7h-4z" fill="${c}"/>`,
96  progress: (x, y, c) => `<path d="M${x} ${y - 5.5}l5.5 5.5-5.5 5.5-5.5-5.5z" fill="${c}"/>`,
97  goals: (x, y, c) => `<circle cx="${x}" cy="${y}" r="5.5" fill="none" stroke="${c}" stroke-width="1.4"/><circle cx="${x}" cy="${y}" r="2.2" fill="${c}"/>`,
98  agents: (x, y, c) => `<circle cx="${x - 3}" cy="${y - 2.5}" r="2.2" fill="${c}"/><circle cx="${x + 3}" cy="${y - 2.5}" r="2.2" fill="${c}" fill-opacity=".6"/><path d="M${x - 6.5} ${y + 5.5}a3.5 3 0 0 1 7 0zM${x - 0.5} ${y + 5.5}a3.5 3 0 0 1 7 0z" fill="${c}"/>`,
99  caution: (x, y, c) => `<path d="M${x} ${y - 5.5}l6 10.5h-12z" fill="none" stroke="${c}" stroke-width="1.4" stroke-linejoin="round"/><path d="M${x} ${y - 1.5}v3" stroke="${c}" stroke-width="1.4" stroke-linecap="round"/><circle cx="${x}" cy="${y + 3.4}" r=".8" fill="${c}"/>`,
100  ship: (x, y, c) => `<path d="M${x} ${y + 2}V${y - 6}M${x - 3.5} ${y - 2.5}l3.5 -3.5 3.5 3.5" fill="none" stroke="${c}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M${x - 6} ${y + 2.5}v3.5h12v-3.5" fill="none" stroke="${c}" stroke-width="1.4" stroke-linejoin="round"/>`,
101  files: (x, y, c) => `<path d="M${x - 4} ${y - 6}h5l3 3v9h-8z" fill="none" stroke="${c}" stroke-width="1.4" stroke-linejoin="round"/><path d="M${x - 2} ${y + 0.5}h4M${x - 2} ${y + 3}h4" stroke="${c}" stroke-width="1.2" stroke-linecap="round"/>`,
102}
103
104export type Mark = 'done' | 'running' | 'planned' | 'failed' | 'held' | 'dot' | 'none'
105
106/** The status mark at the start of a list row. */
107const mark = (x: number, y: number, m: Mark, color: string): string => {
108  if (m === 'running') return `<circle class="live" cx="${x}" cy="${y}" r="3.5" fill="${color}"/>`
109  if (m === 'done') return `<circle cx="${x}" cy="${y}" r="6" fill="${color}" fill-opacity=".16"/><path d="M${x - 3} ${y}l2 2 4-4.2" fill="none" stroke="${color}" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>`
110  if (m === 'failed') return `<path d="M${x - 3.2} ${y - 3.2}l6.4 6.4M${x + 3.2} ${y - 3.2}l-6.4 6.4" stroke="${color}" stroke-width="1.6" stroke-linecap="round"/>`
111  if (m === 'held') return `<path d="M${x} ${y - 5}l5 9h-10z" fill="${color}" fill-opacity=".2" stroke="${color}" stroke-width="1.2" stroke-linejoin="round"/>`
112  if (m === 'dot') return `<circle cx="${x}" cy="${y}" r="2" fill="${color}"/>`
113  if (m === 'planned') return `<circle cx="${x}" cy="${y}" r="5" fill="none" stroke="${color}" stroke-opacity=".7" stroke-width="1.3" stroke-dasharray="2 2"/>`
114  return ''
115}
116
117// --- the one status scale every meter on the board speaks: the same three words, the same colours
118
119export type Level = 'fine' | 'watch' | 'act' | 'none'
120export const LEVEL: Record<Level, { word: string; color: string }> = {
121  fine: { word: 'fine', color: TONE.green },
122  watch: { word: 'watch', color: TONE.amber },
123  act: { word: 'act now', color: TONE.red },
124  none: { word: '—', color: TONE.gray },
125}
126
127// --- a zone's heading: ACCOUNT on top, THIS CHAT under a rule that sets the chat apart
128
129/** A button's legend in the board's small type: the price first, what the button does in brackets after it. */
130export function legendSvg(W: number, s: string, size = 10.5): { source: string; height: number } {
131  const H = 16
132  return { source: svg(W, H, text(0, 11.5, fitText(s, size, W), size, 'sub', NUM)), height: H }
133}
134
135export function dividerSvg(W: number, label: string, after = '', rule = false): { source: string; height: number } {
136  const top = rule ? 18 : 0
137  const lw = textWidth(label, 11) * 1.12 + label.length * 1.1
138  const rest = after ? fitText(after, 11, W - lw - 16) : ''
139  const body = `${rule ? `<rect class="rule" x="0" y="4" width="${W}" height="1.5" rx=".75"/>` : ''}
140${text(0, top + 14, label, 11, 'ink', `${BOLD} letter-spacing=".1em"`)}
141${rest ? text(lw + 8, top + 14, rest, 11, 'sub', NUM) : ''}`
142  return { source: svg(W, top + 24, body), height: top + 24 }
143}
144
145// --- a section: one heading row (icon, title, status pill, its phrase, the value), a thin bar,
146// at most a line of detail, then list rows. No frame: air and a hairline set sections apart.
147
148export type CardRow = {
149  mark: Mark
150  color?: string
151  text: string
152  strong?: boolean
153  dim?: boolean
154  tail?: string
155  add?: number
156  del?: number
157}
158
159export type CardSpec = {
160  icon: string
161  label: string
162  color: string
163  /** The icon's own colour when it is not the section's. */
164  iconColor?: string
165  /** The status pill: a Level speaks the board's one scale; `state` a word of the section's own. */
166  level?: Level
167  state?: string
168  stateColor?: string
169  /** What the status means, beside the pill. */
170  phrase?: string
171  value?: string
172  /** Plain small text right of the value, in brackets (what the percent is worth). */
173  valueNote?: string
174  bar?: { pct: number; color: string; ticks?: number[]; marker?: number }
175  /** Small text right of the bar (a reset time). */
176  aside?: string
177  lines?: { text: string; color?: string; strong?: boolean }[]
178  rows?: CardRow[]
179  more?: string
180  /** No hairline under it: the buttons that belong to it follow. */
181  open?: boolean
182}
183
184const IND = 20
185const HEAD_H = 22
186const ROW_H = 19
187const LINE_H = 15
188const SEP = 9
189
190export function cardHeight(c: CardSpec): number {
191  let y = HEAD_H
192  if (c.bar) y += 10
193  y += (c.lines?.length ?? 0) * LINE_H
194  if (c.rows?.length) y += 3 + c.rows.length * ROW_H
195  if (c.more) y += LINE_H
196  return y + SEP
197}
198
199export function cardSvg(W: number, c: CardSpec): { source: string; height: number } {
200  const height = cardHeight(c)
201  const color = hexOf(c.color)
202  const out: string[] = []
203  out.push(text(IND, 15, c.label, 12.5, 'ink', BOLD))
204  let x = IND + textWidth(c.label, 12.5) + 8
205  const noteW = c.value && c.valueNote ? textWidth(c.valueNote, 11) + 14 : 0
206  const valueW = c.value ? textWidth(c.value, 12) + 8 + noteW : 0
207  const level = c.level ? LEVEL[c.level] : null
208  const word = level ? level.word : c.state
209  // The icon takes its tag's colour, so the whole heading says the state; a section with no tag keeps its own.
210  const iconHex = hexOf(c.iconColor ?? (level ? level.color : c.state ? (c.stateColor ? hexOf(c.stateColor) : TONE.gray) : c.color))
211  out.push(ICONS[c.icon] ? ICONS[c.icon](7, 11, iconHex) : tinted(7, 15, c.icon, 11, iconHex, ' text-anchor="middle"'))
212  if (word) {
213    const sc = level ? level.color : c.stateColor ? hexOf(c.stateColor) : TONE.gray
214    const [p, pw] = pill(x, 3, fitText(word, 10.5, Math.max(20, W - valueW - x - 30)), sc)
215    out.push(p)
216    x += pw + 6
217  }
218  if (c.phrase) {
219    const room = W - valueW - x - 6
220    if (room > 24) out.push(text(x, 15, fitText(c.phrase, 11, room), 11, 'sub'))
221  }
222  if (c.value) out.push(text(W - noteW, 15, c.value, 12, 'ink', `${BOLD}${NUM} text-anchor="end"`))
223  if (noteW) out.push(text(W, 15, c.valueNote as string, 11, 'sub', `${NUM} text-anchor="end"`))
224  let y = HEAD_H
225  if (c.bar) {
226    // The aside takes what it needs, up to 60% of the row; the bar keeps the rest.
227    const aside = c.aside ? fitText(c.aside, 10.5, (W - IND) * 0.6) : ''
228    const asideW = aside ? textWidth(aside, 10.5) + 10 : 0
229    out.push(bar(IND, y + 1, W - IND - asideW, 4, c.bar.pct, hexOf(c.bar.color), c.bar.ticks, c.bar.marker))
230    if (aside) out.push(text(W, y + 6.5, aside, 10.5, 'mute', `${NUM} text-anchor="end"`))
231    y += 10
232  }
233  for (const l of c.lines ?? []) {
234    y += LINE_H
235    const s = fitText(l.text, 11, W - IND)
236    out.push(l.color ? tinted(IND, y - 3, s, 11, hexOf(l.color), l.strong ? BOLD : '') : text(IND, y - 3, s, 11, 'sub', l.strong ? BOLD : ''))
237  }
238  if (c.rows?.length) {
239    y += 3
240    for (const r of c.rows) {
241      const cy = y + ROW_H / 2
242      out.push(mark(IND + 5, cy, r.mark, hexOf(r.color, color)))
243      // The tail (or the line counts) takes what it needs, up to half the row; the text the rest.
244      const counts = r.add !== undefined || r.del !== undefined ? `+${r.add ?? 0} −${r.del ?? 0}` : ''
245      const tailText = counts || r.tail || ''
246      const avail = W - IND - 16
247      const tailW = tailText ? Math.min(avail * 0.5, textWidth(tailText, 10.5)) : 0
248      const label = fitText(r.text, 12, avail - tailW - (tailW ? 10 : 0))
249      out.push(text(IND + 16, cy + 4, label, 12, r.dim ? 'mute' : 'ink', r.strong ? ' font-weight="500"' : ''))
250      if (counts) {
251        const del = `−${r.del ?? 0}`
252        out.push(tinted(W, cy + 4, del, 10.5, TONE.red, `${NUM} text-anchor="end"`))
253        out.push(tinted(W - textWidth(del, 10.5) - 6, cy + 4, `+${r.add ?? 0}`, 10.5, TONE.green, `${NUM} text-anchor="end"`))
254      } else if (tailText) {
255        out.push(text(W, cy + 4, fitText(tailText, 10.5, tailW), 10.5, 'mute', `${NUM} text-anchor="end"`))
256      }
257      y += ROW_H
258    }
259  }
260  if (c.more) {
261    y += LINE_H
262    out.push(text(IND + 16, y - 3, c.more, 11, 'mute'))
263  }
264  if (!c.open) out.push(`<line class="hair" x1="${IND}" y1="${height - 0.5}" x2="${W}" y2="${height - 0.5}"/>`)
265  return { source: svg(W, height, out.join('')), height }
266}
267
268// --- the visual compare: the screen before (what the person pasted) and after (the newest
269// screenshot), side by side, with a numbered pin per ask, then the asks as numbered rows
270
271export type CompareAsk = { text: string; done: boolean; x?: number; y?: number }
272export type CompareSide = { label: string; sub: string; data?: string }
273
274export function compareSvg(W: number, before: CompareSide, after: CompareSide, asks: CompareAsk[]): { source: string; height: number } {
275  const gap = 10
276  const fw = Math.max(80, Math.floor((W - IND - gap) / 2))
277  const fh = Math.round(fw * 0.62)
278  const top = 18
279  const rowsY = top + fh + 10
280  const height = rowsY + asks.length * ROW_H + SEP
281  const out: string[] = []
282  const side = (s: CompareSide, x: number, pins: 'before' | 'after') => {
283    out.push(tinted(x, 12, s.label, 10, pins === 'after' ? TONE.green : TONE.gray, `${BOLD} letter-spacing=".08em"`))
284    out.push(text(x + textWidth(s.label, 10) * 1.2 + 6, 12, s.sub, 10, 'mute', NUM))
285    out.push(`<clipPath id="cp-${pins}"><rect x="${x}" y="${top}" width="${fw}" height="${fh}" rx="6"/></clipPath>`)
286    out.push(`<rect class="track" x="${x}" y="${top}" width="${fw}" height="${fh}" rx="6"/>`)
287    if (s.data) out.push(`<image href="data:image/png;base64,${s.data}" x="${x}" y="${top}" width="${fw}" height="${fh}" preserveAspectRatio="xMidYMin slice" clip-path="url(#cp-${pins})"/>`)
288    else {
289      out.push(`<rect x="${x + 0.5}" y="${top + 0.5}" width="${fw - 1}" height="${fh - 1}" rx="6" fill="none" class="hair" stroke-dasharray="3 3"/>`)
290      out.push(text(x + fw / 2, top + fh / 2 + 3, pins === 'before' ? 'the screenshot you pasted (in the chat)' : 'no screenshot yet', 10, 'mute', ' text-anchor="middle"'))
291    }
292    asks.forEach((a, i) => {
293      // pins sit where the ask is on the screen; with no image to sit on, they stack along the edge
294      const px = s.data && a.x !== undefined ? x + 4 + Math.round(a.x * (fw - 8)) : x + fw - 12
295      const py = s.data && a.y !== undefined ? top + 4 + Math.round(a.y * (fh - 8)) : top + 12 + i * 16
296      const c = pins === 'after' && a.done ? TONE.green : TONE.amber
297      out.push(`<circle cx="${px}" cy="${py}" r="7" fill="${c}"/>${tinted(px, py + 3.2, String(i + 1), 9, '#1a1210', `${BOLD} text-anchor="middle"`)}`)
298    })
299  }
300  side(before, IND, 'before')
301  side(after, IND + fw + gap, 'after')
302  asks.forEach((a, i) => {
303    const cy = rowsY + i * ROW_H + ROW_H / 2
304    const c = a.done ? TONE.green : TONE.amber
305    out.push(`<circle cx="${IND + 6}" cy="${cy}" r="7" fill="${c}"/>${tinted(IND + 6, cy + 3.2, String(i + 1), 9, '#1a1210', `${BOLD} text-anchor="middle"`)}`)
306    const tail = a.done ? 'done' : 'open'
307    out.push(text(IND + 20, cy + 4, fitText(a.text, 12, W - IND - 20 - textWidth(tail, 10.5) - 10), 12, 'ink', a.done ? '' : ' font-weight="500"'))
308    out.push(tinted(W, cy + 4, tail, 10.5, a.done ? TONE.gray : TONE.amber, `${NUM} text-anchor="end"`))
309  })
310  return { source: svg(W, height, out.join('')), height }
311}
312
hooks/git.ts 262 lines
1import { drivesOn, isAbsolute, posix } from './tree'
2
3export type GitAction = {
4  kind: string
5  verb: string
6  running: string
7  done: string
8  tone: string
9  icon: { nerd: string; plain: string }
10  init?: boolean
11}
12
13const ICON = {
14  commit: { nerd: '\u{f417}', plain: '●' },
15  push: { nerd: '\u{f0552}', plain: '↑' },
16  pull: { nerd: '\u{f0553}', plain: '↓' },
17  branch: { nerd: '\u{f418}', plain: '⑂' },
18  merge: { nerd: '\u{f419}', plain: '⑃' },
19  pr: { nerd: '\u{f407}', plain: '⇄' },
20  github: { nerd: '\u{f408}', plain: '◎' },
21  stash: { nerd: '\u{f01bc}', plain: '≡' },
22  undo: { nerd: '\u{f054c}', plain: '↶' },
23  tag: { nerd: '\u{f412}', plain: '⌖' },
24  git: { nerd: '\u{e702}', plain: '±' },
25}
26
27export const TONES: Record<string, { bright: string[]; dim: string[]; solid: string }> = {
28  orange: { bright: ['#f97316', '#fb923c', '#fdba74', '#ffedd5'], dim: ['#8a4316', '#a3562a', '#bd7444', '#d29267'], solid: '#f97316' },
29  green: { bright: ['#22c55e', '#4ade80', '#86efac', '#dcfce7'], dim: ['#14532d', '#166534', '#2f7a47', '#4f9a66'], solid: '#4ade80' },
30  teal: { bright: ['#14b8a6', '#2dd4bf', '#5eead4', '#ccfbf1'], dim: ['#0f5e57', '#16786f', '#2a9488', '#4fb3a8'], solid: '#2dd4bf' },
31  blue: { bright: ['#3b82f6', '#60a5fa', '#93c5fd', '#dbeafe'], dim: ['#1e3a8a', '#1d4ed8', '#3b6fd1', '#6b93dc'], solid: '#60a5fa' },
32  purple: { bright: ['#a855f7', '#c084fc', '#d8b4fe', '#f3e8ff'], dim: ['#581c87', '#6b21a8', '#8b47c4', '#a874d6'], solid: '#c084fc' },
33  cyan: { bright: ['#06b6d4', '#22d3ee', '#67e8f9', '#cffafe'], dim: ['#155e75', '#0e7490', '#2b8ea3', '#5aa9b8'], solid: '#22d3ee' },
34  red: { bright: ['#ef4444', '#f87171', '#fca5a5', '#fee2e2'], dim: ['#7f1d1d', '#991b1b', '#b54040', '#c96a6a'], solid: '#f87171' },
35}
36
37const GIT_VERBS: Record<string, Omit<GitAction, 'kind'>> = {
38  commit: { verb: 'commit', running: 'committing', done: 'committed', tone: 'green', icon: ICON.commit },
39  push: { verb: 'push', running: 'pushing', done: 'pushed', tone: 'teal', icon: ICON.push },
40  pull: { verb: 'pull', running: 'pulling', done: 'pulled', tone: 'blue', icon: ICON.pull },
41  fetch: { verb: 'fetch', running: 'fetching', done: 'fetched', tone: 'blue', icon: ICON.pull },
42  checkout: { verb: 'checkout', running: 'checking out', done: 'checked out', tone: 'blue', icon: ICON.branch },
43  switch: { verb: 'switch', running: 'switching', done: 'switched', tone: 'blue', icon: ICON.branch },
44  branch: { verb: 'branch', running: 'branching', done: 'branched', tone: 'blue', icon: ICON.branch },
45  merge: { verb: 'merge', running: 'merging', done: 'merged', tone: 'purple', icon: ICON.merge },
46  rebase: { verb: 'rebase', running: 'rebasing', done: 'rebased', tone: 'purple', icon: ICON.merge },
47  'cherry-pick': { verb: 'cherry-pick', running: 'cherry-picking', done: 'cherry-picked', tone: 'purple', icon: ICON.merge },
48  stash: { verb: 'stash', running: 'stashing', done: 'stashed', tone: 'orange', icon: ICON.stash },
49  reset: { verb: 'reset', running: 'resetting', done: 'reset', tone: 'red', icon: ICON.undo },
50  restore: { verb: 'restore', running: 'restoring', done: 'restored', tone: 'red', icon: ICON.undo },
51  revert: { verb: 'revert', running: 'reverting', done: 'reverted', tone: 'red', icon: ICON.undo },
52  add: { verb: 'add', running: 'staging', done: 'staged', tone: 'green', icon: ICON.git },
53  rm: { verb: 'rm', running: 'removing', done: 'removed', tone: 'red', icon: ICON.git },
54  mv: { verb: 'mv', running: 'moving', done: 'moved', tone: 'orange', icon: ICON.git },
55  tag: { verb: 'tag', running: 'tagging', done: 'tagged', tone: 'purple', icon: ICON.tag },
56  init: { verb: 'init', running: 'initialising', done: 'initialised', tone: 'green', icon: ICON.git, init: true },
57  clone: { verb: 'clone', running: 'cloning', done: 'cloned', tone: 'blue', icon: ICON.pull, init: true },
58}
59
60const GH_VERBS: Record<string, Omit<GitAction, 'kind'>> = {
61  'pr create': { verb: 'pr create', running: 'opening PR', done: 'opened PR', tone: 'purple', icon: ICON.pr },
62  'pr merge': { verb: 'pr merge', running: 'merging PR', done: 'merged PR', tone: 'purple', icon: ICON.merge },
63  'pr checkout': { verb: 'pr checkout', running: 'checking out PR', done: 'checked out PR', tone: 'blue', icon: ICON.pr },
64  'pr comment': { verb: 'pr comment', running: 'commenting on PR', done: 'commented on PR', tone: 'purple', icon: ICON.pr },
65  'pr review': { verb: 'pr review', running: 'reviewing PR', done: 'reviewed PR', tone: 'purple', icon: ICON.pr },
66  'repo clone': { verb: 'repo clone', running: 'cloning', done: 'cloned', tone: 'blue', icon: ICON.github, init: true },
67  'release create': { verb: 'release create', running: 'releasing', done: 'released', tone: 'purple', icon: ICON.tag },
68  'issue create': { verb: 'issue create', running: 'opening issue', done: 'opened issue', tone: 'purple', icon: ICON.github },
69}
70
71const READ_ONLY_GIT = new Set(['status', 'log', 'diff', 'show', 'blame', 'rev-parse', 'ls-files', 'grep', 'describe', 'config', 'remote', 'reflog', 'shortlog', 'help', 'version', 'check-ignore'])
72
73function segments(command: string, seps: string[] = []): string[][] {
74  const out: string[][] = []
75  let cur: string[] = []
76  let tok = ''
77  let quote = ''
78  let has = false
79  const endTok = () => {
80    if (has) cur.push(tok)
81    tok = ''
82    has = false
83  }
84  const endSeg = () => {
85    endTok()
86    if (cur.length) out.push(cur)
87    cur = []
88  }
89  for (let i = 0; i < command.length; i++) {
90    const ch = command[i] ?? ''
91    if (quote) {
92      if (ch === quote) quote = ''
93      else if (ch === '\\' && quote === '"' && i + 1 < command.length) tok += command[++i]
94      else tok += ch
95    } else if (ch === '"' || ch === "'") {
96      quote = ch
97      has = true
98    } else if (ch === '\\' && i + 1 < command.length) {
99      tok += command[++i]
100      has = true
101    } else if (ch === '#' && !has) {
102      while (i + 1 < command.length && command[i + 1] !== '\n') i++
103    } else if (ch === ' ' || ch === '\t') endTok()
104    else if (ch === '\n' || ch === ';') {
105      seps.push(';')
106      endSeg()
107    } else if (ch === '&' && (command[i - 1] === '>' || command[i + 1] === '>')) {
108      tok += ch
109      has = true
110    } else if (ch === '&' || ch === '|') {
111      const twice = command[i + 1] === ch
112      if (twice) i++
113      seps.push(twice ? ch + ch : ch)
114      endSeg()
115    } else {
116      tok += ch
117      has = true
118    }
119  }
120  endSeg()
121  if (!quote) return out
122  seps.push(';')
123  return command
124    .split(/&&|\|\||;|\||\n/)
125    .map(s => s.trim().split(/\s+/).filter(Boolean))
126    .filter(s => s.length > 0)
127}
128
129function stripGlobals(tokens: string[]): string[] {
130  const out = [...tokens]
131  if (out[0] === 'env') out.shift()
132  while (out.length && /^[A-Z_][A-Z0-9_]*=/.test(out[0] ?? '')) out.shift()
133  return out
134}
135
136function gitVerb(tokens: string[]): number {
137  let i = 1
138  while (i < tokens.length && (tokens[i] ?? '').startsWith('-')) {
139    if (tokens[i] === '-C' || tokens[i] === '-c') i++
140    i++
141  }
142  return i
143}
144
145function gitReadOnly(verb: string, rest: string[]): boolean {
146  if (!verb || READ_ONLY_GIT.has(verb)) return true
147  if (verb === 'stash') return ['list', 'show'].includes(rest[0] ?? '')
148  if (verb === 'branch') return rest.every(t => t.startsWith('-'))
149  if (verb === 'tag') return rest.length === 0 || rest.some(t => /^(-l|--list|-n\d*|--contains|--points-at|--merged|--no-merged|-v|--verify)$/.test(t))
150  return false
151}
152
153export function chainOf(command: string): { size: number; and: boolean } {
154  const seps: string[] = []
155  const size = segments(command, seps).length
156  return { size, and: seps.every(s => s === '&&') }
157}
158
159export function gitActions(command: string): GitAction[] {
160  const out: GitAction[] = []
161  for (const raw of segments(command)) {
162    const tokens = stripGlobals(raw)
163    const head = tokens[0]?.split('/').pop()
164    if (head === 'git') {
165      const i = gitVerb(tokens)
166      const verb = tokens[i] ?? ''
167      const rest = tokens.slice(i + 1)
168      if (gitReadOnly(verb, rest)) continue
169      const spec = verb === 'checkout' && rest.includes('--') ? GIT_VERBS.restore : GIT_VERBS[verb]
170      if (spec) out.push({ kind: `git ${verb}`, ...spec })
171    } else if (head === 'gh') {
172      const pair = `${tokens[1] ?? ''} ${tokens[2] ?? ''}`
173      const spec = GH_VERBS[pair]
174      if (spec) out.push({ kind: `gh ${pair}`, ...spec })
175    }
176  }
177  return out
178}
179
180export const BRANCH_ICON = ICON.branch
181
182const READERS = new Set(['rg', 'grep', 'egrep', 'fgrep', 'find', 'fd', 'fdfind', 'cat', 'head', 'tail', 'bat', 'less', 'more', 'ls', 'eza', 'tree', 'wc', 'sed', 'awk', 'jq'])
183const PATTERN_FIRST = new Set(['rg', 'grep', 'egrep', 'fgrep', 'sed', 'awk', 'jq'])
184const OUTPUT_PATHS = new Set(['rg', 'grep', 'egrep', 'fgrep', 'find', 'fd', 'fdfind'])
185
186function tidy(path: string): string {
187  const s = path.replace(/\/+$/, '')
188  return s === '' ? '/' : /^[A-Za-z]:$/.test(s) && isAbsolute(`${s}/`) ? `${s}/` : s
189}
190
191export function resolve(cwd: string, p: string, home = ''): string {
192  let path = posix(p)
193  if (home && (path === '~' || path.startsWith('~/'))) path = home + path.slice(1)
194  const drive = isAbsolute(path) && !path.startsWith('/') ? path.slice(0, 2) : ''
195  const parts = isAbsolute(path) ? [drive] : posix(cwd).replace(/\/+$/, '').split('/')
196  for (const seg of path.slice(drive.length).split('/')) {
197    if (!seg || seg === '.') continue
198    if (seg === '..') {
199      if (parts.length > 1) parts.pop()
200    } else parts.push(seg)
201  }
202  return tidy(parts.join('/'))
203}
204
205const WRITE_FLAGS = /^-(delete|exec|execdir|ok|okdir|fprint\w*|fls|x|X|-exec|-exec-batch)$/
206
207export function readOnly(command: string): boolean {
208  const bare = ` ${command}`.replace(/\d*>&\d/g, ' ').replace(/(\d*|&)>>?\s*\/dev\/null/g, ' ')
209  if (/[^>]>[^>&]|>>/.test(bare)) return false
210  return segments(command).every(raw => {
211    const tokens = stripGlobals(raw)
212    const head = tokens[0]?.split('/').pop() ?? ''
213    if (head === 'cd' || head === 'echo' || head === 'printf' || head === 'true' || head === 'pwd') return true
214    if (head === 'git') {
215      const i = gitVerb(tokens)
216      return gitReadOnly(tokens[i] ?? '', tokens.slice(i + 1))
217    }
218    if (head === 'sed') return !tokens.some(t => /^-i/.test(t))
219    if (head === 'find' || head === 'fd' || head === 'fdfind') return !tokens.some(t => WRITE_FLAGS.test(t))
220    return READERS.has(head)
221  })
222}
223
224export function readTargets(command: string, sessionCwd: string, stdout: string, home = ''): string[] {
225  let cwd = sessionCwd
226  const out = new Set<string>()
227  let listsPaths = false
228  for (const raw of segments(command)) {
229    const tokens = stripGlobals(raw).map(t => posix(t.replace(/^["']|["']$/g, '')))
230    const head = tokens[0]?.split('/').pop() ?? ''
231    if (head === 'cd' && tokens[1]) {
232      cwd = resolve(cwd, tokens[1], home)
233      continue
234    }
235    if (!READERS.has(head)) continue
236    if (head === 'sed' && tokens.some(t => /^-i/.test(t))) continue
237    if (OUTPUT_PATHS.has(head)) listsPaths = true
238    const args = tokens.slice(1)
239    let skipPattern = PATTERN_FIRST.has(head) && !args.some(t => t === '-e' || t === '-f' || t === '--files')
240    for (let i = 0; i < args.length; i++) {
241      const a = args[i] ?? ''
242      if (a.startsWith('-')) {
243        if (/^-(e|f|g|t|T|m|A|B|C|-glob|-type|-max-count|name|iname|maxdepth|mindepth)$/.test(a)) i++
244        continue
245      }
246      if (skipPattern) {
247        skipPattern = false
248        continue
249      }
250      if (/[*?<>|]/.test(a)) continue
251      out.add(resolve(cwd, a, home))
252    }
253  }
254  if (listsPaths) {
255    for (const line of stdout.split('\n').slice(0, 400)) {
256      const path = posix(line).match(drivesOn() ? /^((?:[A-Za-z]:)?[^:\0]+?)(?::\d+[:-]|$|:)/ : /^([^:\0]+?)(?::\d+[:-]|$|:)/)?.[1]?.trim()
257      if (path && !path.includes(' ')) out.add(resolve(cwd, path, home))
258    }
259  }
260  return [...out].slice(0, 60)
261}
262
hooks/rows.tsx 143 lines
1import type { ClientModule } from 'claude-code'
2
3export type Seg = { t: string; c?: string; b?: boolean; s?: boolean; i?: boolean; sh?: string; dim?: boolean; one?: boolean; spin?: boolean; bg?: string; tab?: string }
4export type RowSpec = { id: string; left: Seg[]; right: Seg[] }
5export type RowsProps = {
6  rows: RowSpec[]
7  active: string
8  activeBg: string
9  hoverBg: string
10  tones: Record<string, { bright: string[]; dim: string[] }>
11  spinner?: string[]
12  pointer?: boolean
13  bar?: { pos: number; size: number; thumb: string; track: string }
14}
15type Local = { hover: number; phase: number; drag: boolean; ref: { stop?: () => void; unpoint?: () => void } }
16
17const TICK_MS = 90
18const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
19
20function shimmer(i: number, phase: number, len: number, palette: string[]): string {
21  const band = ((phase * 1.6) % (len + 8)) - 4
22  const d = Math.abs(i - band)
23  return palette[d < 0.8 ? 3 : d < 1.8 ? 2 : d < 2.8 ? 1 : 0] ?? palette[0] ?? '#f97316'
24}
25
26const Rows: ClientModule<RowsProps, Local> = (props, surface) => {
27  const { Box, Text } = surface.elements
28  let state = surface.state
29  if (state === undefined) {
30    state = { hover: -1, phase: 0, drag: false, ref: {} }
31    surface.setState(state)
32  }
33  const lit = props.rows.some(r => r.left.some(s => s.sh || s.spin) || r.right.some(s => s.sh || s.spin))
34  if (lit && !state.ref.stop) {
35    state.ref.stop = surface.every(TICK_MS, () => {
36      const cur = surface.state
37      if (cur) surface.setState({ ...cur, phase: cur.phase + 1 })
38    })
39  } else if (!lit && state.ref.stop) {
40    state.ref.stop()
41    state.ref.stop = undefined
42  }
43  if (props.pointer === false) {
44    state.ref.unpoint?.()
45    state.ref.unpoint = undefined
46  } else state.ref.unpoint = surface.onPointer(e => {
47    const cur = surface.state ?? state
48    const span = Math.max(1, props.rows.length - 1)
49    const onBar = Boolean(props.bar) && e.x >= surface.columns - 1
50    if (props.bar && (cur.drag || (onBar && e.type === 'down' && (e.button ?? 'left') === 'left'))) {
51      if (e.type === 'up' || e.type === 'leave') {
52        surface.setState({ ...cur, drag: false })
53        return
54      }
55      if (e.type === 'down' || e.type === 'move') {
56        if (!cur.drag) surface.setState({ ...cur, drag: true, hover: -1 })
57        surface.post({ scrollTo: Math.max(0, Math.min(1, e.y / span)) })
58        return
59      }
60    }
61    if (e.type === 'leave' || e.y < 0 || e.y >= props.rows.length) {
62      if (cur.hover !== -1) surface.setState({ ...cur, hover: -1 })
63      return
64    }
65    if (e.type === 'move' && !e.button && cur.hover !== e.y) surface.setState({ ...cur, hover: e.y })
66    const row = props.rows[e.y]
67    if (e.type === 'down' && e.button === 'right' && row?.id) {
68      surface.post({ copy: row.id, shift: Boolean(e.shift) })
69      return
70    }
71    if (e.type !== 'down' || (e.button ?? 'left') !== 'left' || !row) return
72    let x = 0
73    for (const seg of row.left) {
74      const w = [...seg.t].length
75      if (seg.tab && e.x >= x && e.x < x + w) {
76        surface.post({ tab: seg.tab })
77        return
78      }
79      x += w
80    }
81    if (row.id) surface.post({ press: row.id, ctrl: Boolean(e.ctrl), shift: Boolean(e.shift) })
82  })
83  surface.onKey(e => surface.post({ key: e.key, ctrl: Boolean(e.ctrl), shift: Boolean(e.shift) }))
84  const frames = props.spinner?.length ? props.spinner : FRAMES
85  const draw = (s: Seg) => {
86    if (s.spin) {
87      return (
88        <Text color={s.c} bold={s.b}>
89          {s.t + (frames[state.phase % frames.length] ?? '')}
90        </Text>
91      )
92    }
93    const palette = s.sh ? (s.dim ? props.tones[s.sh]?.dim : props.tones[s.sh]?.bright) : undefined
94    if (!palette) {
95      return (
96        <Text color={s.c} backgroundColor={s.bg} bold={s.b} strikethrough={s.s} italic={s.i}>
97          {s.t}
98        </Text>
99      )
100    }
101    if (s.one) {
102      return (
103        <Text color={shimmer(-1, state.phase, s.t.length, palette)}>
104          {s.t}
105        </Text>
106      )
107    }
108    const chars = [...s.t]
109    return (
110      <Text bold={s.b}>
111        {chars.map((ch, i) => (
112          <Text color={shimmer(i, state.phase, chars.length, palette)}>{ch}</Text>
113        ))}
114      </Text>
115    )
116  }
117  return (
118    <Box flexDirection="column">
119      {props.rows.map((r, i) => (
120        <Box
121          flexDirection="row"
122          height={1}
123          overflow="hidden"
124          backgroundColor={r.id && r.id === props.active ? props.activeBg : r.id && i === state.hover ? props.hoverBg : undefined}
125        >
126          <Box flexShrink={1} overflow="hidden">
127            {r.left.map(draw)}
128          </Box>
129          <Box flexGrow={1} />
130          {r.right.map(draw)}
131          {props.bar && (
132            <Text color={i >= props.bar.pos && i < props.bar.pos + props.bar.size ? props.bar.thumb : props.bar.track}>
133              {i >= props.bar.pos && i < props.bar.pos + props.bar.size ? '┃' : '│'}
134            </Text>
135          )}
136        </Box>
137      ))}
138    </Box>
139  )
140}
141
142export default Rows
143
hooks/icons.ts 132 lines
1import type { FileNode } from '../types'
2
3const cp = (n: number) => String.fromCodePoint(n)
4
5export const CHEVRON_OPEN = cp(0xf47c)
6export const CHEVRON_CLOSED = cp(0xf460)
7
8const NAMES: Record<string, number> = {
9  'agents.md': 0xf0354,
10  'changelog.md': 0xf0354,
11  'contributing.md': 0xf0354,
12  'todo.md': 0xf0354,
13  'readme.md': 0xf0354,
14  license: 0xe60a,
15  dockerfile: 0xf0868,
16  makefile: 0xf1064,
17  'cmakelists.txt': 0xf1064,
18  justfile: 0xf05b7,
19  gemfile: 0xf0d2d,
20  rakefile: 0xf0d2d,
21  'cargo.toml': 0xe6b2,
22  'cargo.lock': 0xe6b2,
23  'pyproject.toml': 0xe6b2,
24  'requirements.txt': 0xf160e,
25  'go.mod': 0xf0afa,
26  'go.sum': 0xf07d3,
27  'package.json': 0xf0626,
28  'package-lock.json': 0xf0626,
29  'tsconfig.json': 0xf0626,
30  '.prettierrc': 0xf0626,
31  '.eslintrc': 0xf0626,
32  'pnpm-lock.yaml': 0xe6a8,
33  'yarn.lock': 0xe6a8,
34  '.gitignore': 0xf02a2,
35  '.gitmodules': 0xf0493,
36  '.editorconfig': 0xe652,
37}
38
39const EXTS: Record<string, number> = {
40  lua: 0xe620,
41  py: 0xe606,
42  pyi: 0xe606,
43  js: 0xe60c,
44  mjs: 0xe60c,
45  cjs: 0xe60c,
46  ts: 0xe628,
47  jsx: 0xe625,
48  tsx: 0xe7ba,
49  json: 0xe60b,
50  jsonc: 0xe60b,
51  yaml: 0xe6a8,
52  yml: 0xe6a8,
53  sh: 0xe795,
54  bash: 0xe795,
55  zsh: 0xe795,
56  fish: 0xe795,
57  md: 0xf0354,
58  markdown: 0xf0354,
59  css: 0xe6b8,
60  scss: 0xf031c,
61  html: 0xe736,
62  htm: 0xe736,
63  go: 0xe627,
64  rs: 0xe68b,
65  rb: 0xe791,
66  php: 0xe608,
67  java: 0xe738,
68  cs: 0xf031b,
69  sql: 0xe706,
70  graphql: 0xf20e,
71  gql: 0xf20e,
72  xml: 0xf05c0,
73  toml: 0xe6b2,
74  ini: 0xf0bc2,
75  conf: 0xf0493,
76  pdf: 0xeaeb,
77  svg: 0xf0721,
78  jpg: 0xf0225,
79  jpeg: 0xf0225,
80  png: 0xe60d,
81  gif: 0xf0d78,
82  webp: 0xf021f,
83  mp4: 0xf022b,
84  mkv: 0xf022b,
85  mov: 0xf022b,
86  mp3: 0xf0223,
87  wav: 0xf0223,
88  flac: 0xf0223,
89  zip: 0xf05c4,
90  gz: 0xf05c4,
91  xz: 0xf05c4,
92  tar: 0xf05c4,
93  qml: 0xf375,
94  cpp: 0xe61d,
95  cc: 0xe61d,
96  cxx: 0xe61d,
97  c: 0xe61e,
98  h: 0xf0af5,
99  hpp: 0xf0af5,
100  vue: 0xe6a0,
101  svelte: 0xe697,
102  csv: 0xe64a,
103  txt: 0xf09aa,
104}
105
106export function fileIcon(n: FileNode, open: boolean, isRepo: boolean): string {
107  if (n.kind === 'link') return cp(0xf481)
108  if (n.kind === 'dir') return cp(isRepo ? 0xf02a2 : open ? 0xe5fe : 0xe5ff)
109  const lower = n.name.toLowerCase()
110  const named = NAMES[lower]
111  if (named) return cp(named)
112  const dot = lower.lastIndexOf('.')
113  const ext = dot >= 0 ? lower.slice(dot + 1) : ''
114  return cp(EXTS[ext] ?? 0xf0214)
115}
116
117export const GIT_COLOR: Record<string, string> = {
118  A: '#98c379',
119  '?': '#98c379',
120  R: '#61afef',
121  C: '#61afef',
122  M: '#e5c07b',
123  T: '#e5c07b',
124}
125
126const PRIORITY = ['U', 'D', 'M', 'T', 'R', 'C', 'A', '?']
127
128export function stronger(a: string | undefined, b: string): string {
129  if (!a) return b
130  return PRIORITY.indexOf(b) < PRIORITY.indexOf(a) ? b : a
131}
132
hooks/tree.ts 331 lines
1import type { Branch, FileNode, FileTree, Theme } from '../types'
2import { stronger } from './icons'
3
4export type Entry = { name: string; kind: 'file' | 'dir' | 'other'; mtimeMs: number; size: number; isLink: boolean }
5export type Row = { node: FileNode; depth: number; open: boolean }
6
7const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
8
9export const DEFAULT_THEME: Theme = {
10  fg: '',
11  accent: '#5b9bd5',
12  muted: '#808a96',
13  urgent: '#d0605e',
14  selection: '#6b7280',
15  bg: '',
16}
17
18export function emptyTree(root: string): FileTree {
19  return {
20    root,
21    nodes: [],
22    expanded: [],
23    cursor: '',
24    selected: '',
25    query: '',
26    showHidden: true,
27    showSize: false,
28    dirSizes: {},
29    git: {},
30    diff: {},
31    ignored: [],
32    untrackedDirs: [],
33    top: '',
34    prefix: '',
35    branch: null,
36    counts: {},
37    flash: [],
38    flashDim: [],
39    flashOn: false,
40    flashTones: {},
41    scroll: null,
42  }
43}
44
45export function parseTheme(toml: string): Theme {
46  const get = (k: string) => toml.match(new RegExp(`^${k}\\s*=\\s*"(#[0-9a-fA-F]{6})"`, 'm'))?.[1]
47  return {
48    fg: get('foreground') ?? get('color7') ?? DEFAULT_THEME.fg,
49    accent: get('accent') ?? get('color4') ?? DEFAULT_THEME.accent,
50    muted: get('muted') ?? get('color8') ?? DEFAULT_THEME.muted,
51    urgent: get('red') ?? get('color1') ?? DEFAULT_THEME.urgent,
52    selection: get('selection') ?? DEFAULT_THEME.selection,
53    bg: get('dark_background') ?? get('background') ?? DEFAULT_THEME.bg,
54  }
55}
56
57export function join(dir: string, name: string): string {
58  return dir.endsWith('/') ? dir + name : `${dir}/${name}`
59}
60
61let drives = false
62
63export function dirname(path: string): string {
64  if (drives && /^[A-Za-z]:\/[^/]*$/.test(path)) return path.slice(0, 3)
65  const i = path.lastIndexOf('/')
66  return i <= 0 ? '/' : path.slice(0, i)
67}
68
69export function useDrives(on: boolean): void {
70  drives = on
71}
72
73export function drivesOn(): boolean {
74  return drives
75}
76
77export function posix(path: string): string {
78  if (!drives) return path
79  return path.replace(/\\/g, '/').replace(/^\/([A-Za-z])(\/|$)/, (_, d: string) => `${d.toUpperCase()}:/`)
80}
81
82export function dropBelow(nodes: FileNode[], dirs: string[]): FileNode[] {
83  if (dirs.length === 0) return nodes
84  const under = (id: string) => dirs.some(d => id !== d && inside(d, id))
85  const gone = new Set(dirs)
86  return nodes.filter(n => !under(n.id)).map(n => (gone.has(n.id) && n.loaded ? { ...n, loaded: false } : n))
87}
88
89export function isAbsolute(path: string): boolean {
90  return path.startsWith('/') || (drives && /^[A-Za-z]:\//.test(path))
91}
92
93export function inside(root: string, path: string): boolean {
94  return path === root || path.startsWith(root.endsWith('/') ? root : `${root}/`)
95}
96
97export function toNodes(dir: string, list: Entry[]): FileNode[] {
98  return list
99    .map(e => ({
100      id: join(dir, e.name),
101      parent: dir,
102      name: e.name,
103      kind: e.isLink ? ('link' as const) : e.kind === 'dir' ? ('dir' as const) : ('file' as const),
104      hidden: e.name.startsWith('.'),
105      mtime: e.mtimeMs,
106      size: e.size,
107      loaded: false,
108    }))
109    .sort((a, b) => (a.kind === 'dir' ? 0 : 1) - (b.kind === 'dir' ? 0 : 1) || collator.compare(a.name, b.name))
110}
111
112export function replaceChildren(nodes: FileNode[], listed: Map<string, FileNode[]>): FileNode[] {
113  if (listed.size === 0) return nodes
114  const keep = new Map<string, FileNode>()
115  for (const n of nodes) if (listed.has(n.parent)) keep.set(n.id, n)
116  const freshIds = new Set<string>()
117  for (const kids of listed.values()) for (const k of kids) freshIds.add(k.id)
118  const gone = new Set([...keep.keys()].filter(id => !freshIds.has(id)))
119  const under = (id: string) => {
120    if (gone.size === 0) return false
121    for (let dir = dirname(id), prev = id; dir !== prev; prev = dir, dir = dirname(dir)) if (gone.has(dir)) return true
122    return false
123  }
124  const fresh: FileNode[] = []
125  for (const kids of listed.values()) {
126    for (const k of kids) {
127      if (under(k.id)) continue
128      const old = keep.get(k.id)
129      fresh.push(old && old.kind === 'dir' && k.kind === 'dir' ? { ...k, loaded: old.loaded } : k)
130    }
131  }
132  const out = nodes.filter(n => !listed.has(n.parent) && !under(n.id))
133  return [...out.map(n => (listed.has(n.id) ? { ...n, loaded: true } : n)), ...fresh]
134}
135
136export type GitStatus = {
137  git: Record<string, string>
138  ignored: string[]
139  untrackedDirs: string[]
140  branch: Branch | null
141  files: Record<string, Change>
142}
143
144export type Change = 'new' | 'mod' | 'del'
145
146export function parseBranch(record: string): Branch {
147  const body = record.replace(/^## /, '')
148  if (body.startsWith('HEAD (no branch)')) return { head: 'detached', upstream: '', ahead: 0, behind: 0 }
149  const head = body.match(/^(?:No commits yet on |Initial commit on )?(.+?)(?:\.\.\.|\s|$)/)?.[1] ?? body
150  const upstream = body.match(/\.\.\.(\S+)/)?.[1] ?? ''
151  const ahead = Number(body.match(/ahead (\d+)/)?.[1] ?? 0)
152  const behind = Number(body.match(/behind (\d+)/)?.[1] ?? 0)
153  return { head, upstream, ahead, behind }
154}
155
156export function mapper(root: string, prefix: string): (rel: string) => string | null {
157  return rel => (rel.startsWith(prefix) ? join(root, rel.slice(prefix.length).replace(/\/$/, '')).replace(/\/$/, '') : null)
158}
159
160export function parseGit(stdout: string, root: string, prefix: string): GitStatus {
161  const map = mapper(root, prefix)
162  const git: Record<string, string> = {}
163  const ignored: string[] = []
164  const untrackedDirs: string[] = []
165  const files: Record<string, Change> = {}
166  let branch: Branch | null = null
167  const parts = stdout.split('\0')
168  for (let i = 0; i < parts.length; i++) {
169    const rec = parts[i] ?? ''
170    if (rec.startsWith('## ')) {
171      branch = parseBranch(rec)
172      continue
173    }
174    if (rec.length < 4) continue
175    const xy = rec.slice(0, 2)
176    const raw = rec.slice(3)
177    if (xy[0] === 'R' || xy[0] === 'C') i++
178    const path = map(raw)
179    if (!path) continue
180    if (xy === '!!') {
181      ignored.push(path)
182      continue
183    }
184    if (xy === '??' && raw.endsWith('/')) untrackedDirs.push(path)
185    else files[path] = xy === '??' || (xy.includes('A') && !xy.includes('D')) ? 'new' : xy.includes('D') && !/U/.test(xy) ? 'del' : 'mod'
186    const letter =
187      xy === '??'
188        ? '?'
189        : /U/.test(xy) || xy === 'AA' || xy === 'DD'
190          ? 'U'
191          : (['D', 'M', 'R', 'C', 'A', 'T'].find(c => xy.includes(c)) ?? '')
192    if (!letter) continue
193    git[path] = stronger(git[path], letter)
194    let dir = dirname(path)
195    while (dir.length >= root.length && dir !== '/') {
196      git[dir] = stronger(git[dir], letter)
197      if (dir === root) break
198      dir = dirname(dir)
199    }
200  }
201  return { git, ignored, untrackedDirs, branch, files }
202}
203
204export function parseNumstat(stdout: string, root: string, prefix: string, into: Record<string, [number, number]>): void {
205  const map = mapper(root, prefix)
206  const parts = stdout.split('\0')
207  for (let i = 0; i < parts.length; i++) {
208    const rec = parts[i] ?? ''
209    if (!rec) continue
210    const m = rec.match(/^(-|\d+)\t(-|\d+)\t(.*)$/s)
211    if (!m) continue
212    let path = m[3] ?? ''
213    if (path === '') {
214      i += 2
215      path = parts[i] ?? ''
216    }
217    if (!path) continue
218    const add = m[1] === '-' ? 0 : Number(m[1])
219    const del = m[2] === '-' ? 0 : Number(m[2])
220    const abs = map(path)
221    if (!abs) continue
222    const prev = into[abs] ?? [0, 0]
223    into[abs] = [prev[0] + add, prev[1] + del]
224  }
225}
226
227export function rollUp(diff: Record<string, [number, number]>, root: string): Record<string, [number, number]> {
228  const out: Record<string, [number, number]> = { ...diff }
229  for (const [path, [add, del]] of Object.entries(diff)) {
230    let dir = dirname(path)
231    while (dir.length >= root.length && dir !== '/') {
232      const prev = out[dir] ?? [0, 0]
233      out[dir] = [prev[0] + add, prev[1] + del]
234      if (dir === root) break
235      dir = dirname(dir)
236    }
237  }
238  return out
239}
240
241export function rollCounts(files: Record<string, Change>, root: string): Record<string, [number, number, number]> {
242  const out: Record<string, [number, number, number]> = {}
243  const slot = { new: 0, mod: 1, del: 2 } as const
244  for (const [path, change] of Object.entries(files)) {
245    let dir = dirname(path)
246    while (dir.length >= root.length && dir !== '/') {
247      const cur = out[dir] ?? [0, 0, 0]
248      cur[slot[change]] += 1
249      out[dir] = cur
250      if (dir === root) break
251      dir = dirname(dir)
252    }
253  }
254  return out
255}
256
257export function underAny(id: string, set: Set<string>, root: string): boolean {
258  if (set.size === 0) return false
259  let cur = id
260  while (cur.length >= root.length) {
261    if (set.has(cur)) return true
262    const up = dirname(cur)
263    if (up === cur) break
264    cur = up
265  }
266  return false
267}
268
269export function ancestorsOf(id: string, root: string): string[] {
270  const out: string[] = []
271  if (!root) return out
272  for (let dir = dirname(id), prev = id; dir !== prev && dir !== root && inside(root, dir); prev = dir, dir = dirname(dir)) out.push(dir)
273  return out
274}
275
276export function relative(root: string, path: string): string {
277  return path === root ? '' : path.slice(root.endsWith('/') ? root.length : root.length + 1)
278}
279
280export function visibleRows(t: FileTree): Row[] {
281  const kids = new Map<string, FileNode[]>()
282  for (const n of t.nodes) {
283    if (!t.showHidden && n.hidden) continue
284    const list = kids.get(n.parent)
285    if (list) list.push(n)
286    else kids.set(n.parent, [n])
287  }
288  const q = t.query.trim().toLowerCase()
289  let keep: Set<string> | null = null
290  if (q) {
291    keep = new Set()
292    for (const n of t.nodes) {
293      if (!relative(t.root, n.id).toLowerCase().includes(q)) continue
294      keep.add(n.id)
295      for (const a of ancestorsOf(n.id, t.root)) keep.add(a)
296    }
297  }
298  const open = new Set(t.expanded)
299  const rows: Row[] = []
300  const walk = (dir: string, depth: number) => {
301    for (const n of kids.get(dir) ?? []) {
302      if (keep && !keep.has(n.id)) continue
303      const isOpen = n.kind === 'dir' && (keep ? kids.has(n.id) : open.has(n.id))
304      rows.push({ node: n, depth, open: isOpen })
305      if (isOpen) walk(n.id, depth + 1)
306    }
307  }
308  walk(t.root, 0)
309  return rows
310}
311
312export function stamp(ms: number): string {
313  if (!ms) return ''
314  const d = new Date(ms)
315  const p = (v: number) => String(v).padStart(2, '0')
316  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
317}
318
319export function formatSize(bytes: number): string {
320  if (bytes < 0) return '?'
321  if (bytes < 1024) return `${bytes} B`
322  const units = ['K', 'M', 'G', 'T']
323  let v = bytes / 1024
324  let i = 0
325  while (v >= 1024 && i < units.length - 1) {
326    v /= 1024
327    i++
328  }
329  return `${v < 100 ? v.toFixed(1) : Math.round(v)} ${units[i]}`
330}
331
hooks/goal-fmt.mjs 70 lines
1// Shared source. Copied into each mod's hooks/ folder by _dev/sync-shared.mjs.
2
3export function tokens(n) {
4  const v = Number(n) || 0
5  if (v >= 1e6) return (v / 1e6).toFixed(v >= 1e7 ? 0 : 1) + 'M'
6  if (v >= 1e3) return (v / 1e3).toFixed(v >= 1e5 ? 0 : 1) + 'k'
7  return String(Math.round(v))
8}
9
10export function usd(n) {
11  const v = Number(n) || 0
12  if (v === 0) return '$0'
13  if (v < 0.01) return '<$0.01'
14  if (v < 10) return '$' + v.toFixed(2)
15  return '$' + v.toFixed(0)
16}
17
18export function duration(ms) {
19  const s = Math.max(0, Math.round((Number(ms) || 0) / 1000))
20  if (s < 60) return s + 's'
21  const m = Math.floor(s / 60)
22  if (m < 60) return m + 'm' + String(s % 60).padStart(2, '0') + 's'
23  const h = Math.floor(m / 60)
24  return h + 'h' + String(m % 60).padStart(2, '0') + 'm'
25}
26
27export function minutes(ms) {
28  const m = Math.round((Number(ms) || 0) / 60000)
29  if (m <= 60) return m + 'm'
30  return Math.floor(m / 60) + 'h' + String(m % 60).padStart(2, '0') + 'm'
31}
32
33export function clock(ts) {
34  const d = new Date(ts)
35  let h = d.getHours()
36  const ampm = h >= 12 ? 'pm' : 'am'
37  h = h % 12 || 12
38  return h + ':' + String(d.getMinutes()).padStart(2, '0') + ampm
39}
40
41const SPARKS = '▁▂▃▄▅▆▇█'
42
43export function sparkline(values, width = 12) {
44  const vals = values.slice(-width)
45  if (vals.length === 0) return ''
46  const max = Math.max(...vals, 1)
47  return vals.map((v) => SPARKS[Math.min(7, Math.floor((v / max) * 7.999))]).join('')
48}
49
50export function bar(fraction, width = 20, full = '█', empty = '░') {
51  const f = Math.max(0, Math.min(1, Number(fraction) || 0))
52  const n = Math.round(f * width)
53  return full.repeat(n) + empty.repeat(width - n)
54}
55
56export function clip(text, max) {
57  const s = String(text ?? '').replace(/\s+/g, ' ').trim()
58  return s.length > max ? s.slice(0, Math.max(0, max - 1)) + '…' : s
59}
60
61export function pad(text, width) {
62  const s = String(text ?? '')
63  return s.length >= width ? s.slice(0, width) : s + ' '.repeat(width - s.length)
64}
65
66export function basename(path) {
67  const parts = String(path || '').split(/[\\/]+/).filter(Boolean)
68  return parts[parts.length - 1] || String(path || '')
69}
70
hooks/goal-privacy.mjs 337 lines
1// Masks secrets, personal details, and business figures for screen recording.
2// Pure functions: no mods API calls here. The same file ships in each mod that
3// draws text, since a mod may import only its own files.
4
5const DOTS = '••••••••'
6const HIDE = '•••'
7const HIDDEN_OUTPUT = '••• hidden while recording'
8
9const SECRET_PATTERNS = [
10  /sk-ant-[A-Za-z0-9_-]{16,}/g,
11  /\bsk-(?:proj-|live-|test-|svcacct-)?[A-Za-z0-9_-]{20,}/g,
12  /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}/g,
13  /\bgithub_pat_[A-Za-z0-9_]{20,}/g,
14  /\bxox[abprs]-[A-Za-z0-9-]{10,}/g,
15  /\bAIza[0-9A-Za-z_-]{30,}/g,
16  /\bAKIA[0-9A-Z]{16}\b/g,
17  /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
18  /\b(?:hf|r8|pk_live|sk_live|rk_live|whsec|pat|key)_[A-Za-z0-9]{20,}/g,
19  /\bBearer\s+[A-Za-z0-9._~+/-]{20,}=*/gi,
20]
21
22// NAME=value or "name": "value" where the name looks like a credential
23const ASSIGNMENT =
24  /\b([A-Za-z0-9_.-]*(?:API[_-]?KEY|SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE[_-]?KEY|ACCESS[_-]?KEY|CLIENT[_-]?SECRET|AUTH)[A-Za-z0-9_.-]*)(["']?\s*[=:]\s*["']?)([^\s"'`,;}{]{6,})/gi
25
26const EMAIL_SRC = '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}'
27const EMAIL = new RegExp(`\\b${EMAIL_SRC}\\b`, 'g')
28
29// People: "Jane Doe <jane@x.com>", "displayName": "Jane Doe", and From:/Attendees: lines
30const NAME_WORD = "[A-Z][\\p{L}'’.-]*"
31const DISPLAY_NAME = new RegExp(`(["']?)(${NAME_WORD}(?:[ \\t]+${NAME_WORD}){0,3})\\1([ \\t]*<[ \\t]*${EMAIL_SRC}[ \\t]*>)`, 'gu')
32const PERSON_KEY =
33  /("(?:displayName|display_name|fullName|full_name|firstName|first_name|lastName|last_name|givenName|given_name|familyName|family_name|real_name|realName|senderName|sender_name|authorName|author_name|username|user_name)"\s*:\s*")([^"]{1,80})(")/g
34const PERSON_LINE = /^([ \t>*-]*(?:From|To|Cc|Bcc|Reply-To|Attendees?|Organizer|Invitees?|Guests?|Assignees?|Sender|Owner)[ \t]*:[ \t]*)(\S.*)$/gm
35
36// Personal details
37const PHONE = /(?<![\w.])(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}(?![\w.])/g
38const PHONE_INTL = /(?<![\w.])\+\d{1,3}[\s.-]?\(?\d{1,4}\)?(?:[\s.-]?\d{2,4}){2,4}(?![\w.])/g
39const SSN = /(?<![\w-])\d{3}-\d{2}-\d{4}(?![\w-])/g
40const EIN = /(?<![\w-])\d{2}-\d{7}(?![\w-])/g
41const CARD = /(?<![\w-])(?:4\d{3}|5[1-5]\d{2}|2[2-7]\d{2}|3[47]\d{2}|6(?:011|5\d{2}))(?:[ -]?\d{2,4}){3,4}(?![\w-])/g
42const IBAN = /\b[A-Z]{2}\d{2}(?: ?[A-Z0-9]{4}){3,7}(?: ?[A-Z0-9]{1,3})?\b/g
43const ACCOUNT = /\b((?:routing|account|acct|a\/c|aba|swift|bic|iban|passport|driver'?s license)(?:\s*(?:number|no\.?|num|#))?\s*[:#]?\s*)(\d[\d -]{3,}\d)/gi
44const CARD_ENDING = /\b((?:ending(?:\s+in)?|last\s+(?:4|four)(?:\s+digits)?)\s*[:#]?\s*)(\d{4})\b/gi
45const STREET = /\b\d{1,6}\s+(?:[NSEW]\.?\s+)?(?:[A-Z][\p{L}'.-]*\s+){1,3}(?:Street|St|Avenue|Ave|Road|Rd|Boulevard|Blvd|Drive|Dr|Lane|Ln|Way|Court|Ct|Place|Pl|Parkway|Pkwy|Highway|Hwy|Circle|Cir|Terrace|Ter|Trail|Trl|Square|Sq)\b\.?(?:,?\s+(?:Apt|Apartment|Suite|Ste|Unit|#)\.?\s*[\w-]+)?/gu
46const PO_BOX = /\bP\.?\s?O\.?\s+Box\s+\d+/gi
47const STATE_ZIP = /(,\s*(?:A[KLRZ]|C[AOT]|D[CE]|FL|GA|HI|I[ADLN]|K[SY]|LA|M[ADEINOST]|N[CDEHJMVY]|O[HKR]|PA|RI|S[CD]|T[NX]|UT|V[AT]|W[AIVY]))\s+\d{5}(?:-\d{4})?\b/g
48const BIRTH = /\b((?:DOB|D\.O\.B\.|date of birth|birth\s?date|birthday|born(?: on)?)\s*[:-]?\s*)([A-Za-z0-9 ,/.-]{4,20}\d)/gi
49const IPV4 = /(?<![\w.])((?:25[0-5]|2[0-4]\d|1?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3})(?![\w.])/g
50
51// Money in any common currency
52const MONEY = /(?:US|CA|AU)?[$€£¥]\s?\d[\d,]*(?:\.\d+)?(?:\s?(?:[kKmMbB]|bn|million|thousand|billion)\b)?/g
53const MONEY_WORD = /(?<![\w.])(?:(?:USD|EUR|GBP|CAD|AUD)\s?\d[\d,]*(?:\.\d+)?(?:\s?(?:[kKmMbB]|bn|million|thousand|billion)\b)?|\d[\d,]*(?:\.\d+)?(?:\s?(?:[kKmMbB]|bn|million|thousand|billion))?\s?(?:USD|EUR|GBP|CAD|AUD|dollars|bucks)\b)/gi
54
55// Figures near business words: "MRR is 84k", "40% margin", "profit share 20%"
56const FIN_WORD =
57  /\b(?:revenues?|mrr|arr|gmv|profits?|profit share|margins?|ebitda|income|earnings|payroll|salar(?:y|ies)|wages?|compensation|comp|bonus(?:es)?|equity|stakes?|ownership|valuation|runway|burn(?: rate)?|cash(?:flow)?|balances?|budgets?|spend(?:ing)?|pric(?:e|es|ing)|fees?|invoices?|payouts?|distributions?|dividends?|tax(?:es)?|sales|ltv|cac|aov|sponsor(?:s|ships?)?|cpm|rpm|retainers?|commissions?|royalt(?:y|ies)|refunds?|expenses?|debts?|loans?|funding|investments?|deals?|net|gross|paid|pays?|owed?|costs?)\b/gi
58const FIGURE = /(?<![\w.])\d[\d,]*(?:\.\d+)?(?:\s?(?:%|percent\b|[kKmMbB]\b|bn\b|million\b|thousand\b|billion\b|x\b))?/g
59const BIG_FIGURE = /(?<![\w.])(?:\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?\s?(?:%|percent\b|[kKmMbB]\b|bn\b|million\b|thousand\b|billion\b))/g
60
61// Single names that are also ordinary words, and names that stay visible (the channel's own)
62const COMMON_WORDS = new Set(
63  'Will Mark Grant Bill Rich Art Hope Faith Joy May June April August Summer Rose Page Chase Hunter Max Ray Dawn Sky Brook Lane Dean Gene Guy Frank Sterling Major Price Young King Long Little Wood Hill Stone Field Ford Hall Bell Rice Banks Case Cook Fox Gray Green Brown White Black Day Lee West North South Love Church Park Street Bishop Mason Miller Baker Carter Cole Wells Hart Moon Star Van Von De Del La Le Da Di St Mr Mrs Ms Dr Jr Sr Claude Code Team Support Admin Info Hello The And Ai Slack Gmail Google Calendar Meeting Sync Notes Update Review Weekly Daily Monday Tuesday Wednesday Thursday Friday Saturday Sunday'.split(' '),
64)
65const ROLE_LOCAL_PARTS = /^(?:info|hello|hi|team|support|help|admin|noreply|no-reply|donotreply|contact|sales|billing|accounts?|notifications?|news|newsletter|marketing|office|mail|security|privacy|legal|press|jobs|careers|hr|ops|dev|bot|alerts?|updates?|calendar|invites?)$/i
66
67// Files that stay closed while recording. Add your own folders and file names
68// in ~/.claude/mods-data/recording-mode/config.json ("privatePaths"); /rec config makes one.
69const PRIVATE_PATHS = [
70  /(^|[\\/\s"'`])\.env(\.[A-Za-z0-9_-]+)?(?=$|[\s"'`;|&)])/i,
71  /\.credentials\.json/i,
72  /[\\/]\.claude\.json\b/i,
73  /\.claude[\\/]projects[\\/][^\\/\s"'`]+[\\/]memory/i,
74  /(^|[\\/\s"'`])\.(?:ssh|aws|gnupg)(?=$|[\\/])/i,
75  /\bid_(?:rsa|ed25519|ecdsa)\b/i,
76  /(^|[\\/\s"'`])\.(?:netrc|npmrc|pypirc)\b/i,
77  /\.(?:pem|p12|pfx)\b/i,
78  /taxes?[-_ ]?20\d\d/i,
79  /[\\/_-](?:payroll|invoices?|contracts?|financials?|finances?|budgets?|forecasts?|cap[-_ ]?table|term[-_ ]?sheets?|offer[-_ ]?letters?|business[-_ ]?plans?)(?=$|[\\/._\s"'`;|&)-])/i,
80  /(^|[\s"'`])(?:payroll|invoices?|contracts?|financials?|finances?|budgets?|forecasts?|business[-_ ]?plans?)(?=[\\/.])/i,
81]
82
83// Tools whose results are business data: drawn as hidden while recording
84const BUSINESS_TOOL =
85  /(?:^|__|_)(?:clickup|slack|gmail|outlook|notion|asana|linear|jira|clay|qbo|quickbooks|xero|fireflies|hubspot|salesforce|stripe|calendar|gcal|google_drive|drive|sheets)|search_threads|get_thread|get_message|list_drafts|get_draft|read_file_content|download_file_content|search_files|list_recent_files|get_file_metadata|profit_loss|cash_flow|balance_sheet|payroll|session_transcripts|export_transcript/i
86const BUSINESS_COMMAND = /\bgws(?:\.cmd|\.exe)?\b|fireflies|quickbooks/i
87// Finance tools stay closed while recording
88const FINANCE_TOOL = /__(?:qbo_|quickbooks|profit_loss|cash_flow|balance_sheet|benchmarking_quickbooks|money_onboarding|company_info)/i
89
90function escapeRegExp(s) {
91  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
92}
93
94// Values from .env files: long enough to be secret, and not plain words or numbers.
95export function secretValuesFromEnv(text) {
96  const values = []
97  for (const line of String(text || '').split(/\r?\n/)) {
98    const m = line.match(/^\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=\s*(.*)\s*$/)
99    if (!m) continue
100    let v = m[1].trim()
101    if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1)
102    if (v.length < 8) continue
103    if (/^(true|false|null|none|yes|no)$/i.test(v)) continue
104    if (/^\d+(\.\d+)?$/.test(v)) continue
105    if (/your[-_ ]?(key|token|secret)|here$|^<.*>$|^xxx/i.test(v)) continue
106    values.push(v)
107  }
108  return [...new Set(values)].sort((a, b) => b.length - a.length)
109}
110
111// The forms of one person's name that get masked: the full name, its slug, and each part that isn't an ordinary word
112function nameForms(full, keep) {
113  const clean = String(full || '').replace(/\s+/g, ' ').trim()
114  if (!clean || clean.length > 60 || keep.has(clean)) return []
115  const forms = new Set()
116  const parts = clean.split(/[ -]/).filter((p) => /^[A-Z][\p{L}'’.]*$/u.test(p) && p.length >= 2)
117  if (parts.length >= 2) {
118    forms.add(clean)
119    forms.add(clean.toLowerCase())
120    forms.add(clean.toLowerCase().replace(/ /g, '-'))
121    forms.add(clean.toLowerCase().replace(/ /g, '_'))
122  }
123  for (const p of parts) if (!COMMON_WORDS.has(p) && !keep.has(p)) forms.add(p)
124  return [...forms]
125}
126
127function namesFromEmail(email) {
128  const local = email.split('@')[0]
129  if (ROLE_LOCAL_PARTS.test(local)) return null
130  const parts = local.split(/[._+-]/).filter((p) => /^[a-z]{2,}$/i.test(p))
131  if (parts.length < 2 || parts.length > 4) return null
132  return parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase()).join(' ')
133}
134
135// Masks numbers in the same clause as a business word
136function maskFigures(text, strict) {
137  if (strict) text = text.replace(BIG_FIGURE, HIDE)
138  const ranges = []
139  for (const m of text.matchAll(FIN_WORD)) {
140    const s = m.index
141    const e = s + m[0].length
142    let a = Math.max(0, s - 30)
143    let b = Math.min(text.length, e + 45)
144    const before = text.slice(a, s)
145    const stop = Math.max(before.lastIndexOf('\n'), before.lastIndexOf(';'), before.search(/[.!?]\s[^.!?]*$/))
146    if (stop >= 0) a += stop + 1
147    const after = text.slice(e, b)
148    const end = after.search(/\n|;|[.!?](?:\s|$)/)
149    if (end >= 0) b = e + end
150    ranges.push([a, b])
151  }
152  if (!ranges.length) return text
153  return text.replace(FIGURE, (fig, offset) => {
154    if (/^(?:19|20)\d\d$/.test(fig)) return fig // a year
155    return ranges.some(([a, b]) => offset >= a && offset < b) ? HIDE : fig
156  })
157}
158
159function luhn(digits) {
160  let sum = 0
161  for (let i = 0; i < digits.length; i++) {
162    let d = Number(digits[digits.length - 1 - i])
163    if (i % 2 === 1) {
164      d *= 2
165      if (d > 9) d -= 9
166    }
167    sum += d
168  }
169  return sum % 10 === 0
170}
171
172function publicIp(ip) {
173  const [a, b] = ip.split('.').map(Number)
174  if (a === 10 || a === 127 || a === 0 || ip === '255.255.255.255') return false
175  if (a === 192 && b === 168) return false
176  if (a === 172 && b >= 16 && b <= 31) return false
177  if (a === 169 && b === 254) return false
178  return true
179}
180
181// Your own name and its parts stay visible
182function keepSet(keepNames) {
183  const keep = new Set()
184  for (const n of keepNames || []) {
185    const clean = String(n || '').replace(/\s+/g, ' ').trim()
186    if (!clean) continue
187    keep.add(clean)
188    keep.add(clean.toLowerCase())
189    keep.add(clean.toLowerCase().replace(/ /g, '-'))
190    for (const part of clean.split(/[ -]/)) if (part) keep.add(part)
191  }
192  return keep
193}
194
195// strict: also every large figure (1,250 / 18% / 40k) wherever it appears
196// names: people to mask; keepNames: names that stay visible (yours)
197export function makeMasker({ strict = false, values = [], names = [], keepNames = [] } = {}) {
198  const KEEP_NAMES = keepSet(keepNames)
199  const valueRe = values.length
200    ? new RegExp(values.map(escapeRegExp).join('|'), 'g')
201    : null
202  // Names: the roster passed in plus any learned from emails and contact fields. Memory only.
203  const people = new Set()
204  let nameRe = null
205  let dirty = false
206  const learn = (full) => {
207    if (people.size > 2000) return
208    for (const f of nameForms(full, KEEP_NAMES)) {
209      if (!people.has(f)) {
210        people.add(f)
211        dirty = true
212      }
213    }
214  }
215  for (const n of names) learn(n)
216  const nameRegex = () => {
217    if (dirty) {
218      const list = [...people].sort((a, b) => b.length - a.length).map(escapeRegExp)
219      nameRe = list.length ? new RegExp(`(?<![\\p{L}\\p{N}_])(?:${list.join('|')})(?:['’]s)?(?![\\p{L}\\p{N}_])`, 'gu') : null
220      dirty = false
221    }
222    return nameRe
223  }
224
225  return function mask(text) {
226    if (typeof text !== 'string' || text.length === 0) return text
227    let out = text
228    if (valueRe) out = out.replace(valueRe, DOTS)
229    for (const re of SECRET_PATTERNS) out = out.replace(re, DOTS)
230    out = out.replace(ASSIGNMENT, (_, name, sep) => name + sep + DOTS)
231
232    // people, learned before the emails that name them are masked
233    for (const m of out.matchAll(EMAIL)) {
234      const n = namesFromEmail(m[0])
235      if (n) learn(n)
236    }
237    out = out.replace(DISPLAY_NAME, (_, q, name, addr) => {
238      learn(name)
239      return KEEP_NAMES.has(name) ? q + name + q + addr : HIDE + addr
240    })
241    out = out.replace(PERSON_KEY, (_, head, value, tail) => {
242      learn(value)
243      return head + (KEEP_NAMES.has(value) ? value : HIDE) + tail
244    })
245    out = out.replace(PERSON_LINE, (line, label, value) => (KEEP_NAMES.has(value.trim()) ? line : label + HIDE))
246    out = out.replace(EMAIL, '•••@•••')
247    const re = nameRegex()
248    if (re) out = out.replace(re, HIDE)
249
250    // personal details
251    out = out.replace(CARD, (m) => {
252      const digits = m.replace(/\D/g, '')
253      return digits.length >= 13 && digits.length <= 19 && luhn(digits) ? '•••• •••• •••• ••••' : m
254    })
255    out = out.replace(SSN, '•••-••-••••')
256    out = out.replace(EIN, '••-•••••••')
257    out = out.replace(IBAN, HIDE)
258    out = out.replace(ACCOUNT, (_, head) => head + HIDE)
259    out = out.replace(CARD_ENDING, (_, head) => head + '••••')
260    out = out.replace(BIRTH, (_, head) => head + HIDE)
261    out = out.replace(STREET, HIDE)
262    out = out.replace(PO_BOX, HIDE)
263    out = out.replace(STATE_ZIP, (_, head) => head + ' •••••')
264    out = out.replace(PHONE, '•••-•••-••••')
265    out = out.replace(PHONE_INTL, '+•• •••')
266    out = out.replace(IPV4, (ip) => (publicIp(ip) ? '•••.•••.•••.•••' : ip))
267
268    // business figures
269    out = out.replace(MONEY, '$•••')
270    out = out.replace(MONEY_WORD, HIDE)
271    out = maskFigures(out, strict)
272    return out
273  }
274}
275
276// Masks every string inside plain data, keeping the shape.
277export function deepMask(value, mask, depth = 0) {
278  if (depth > 12) return value
279  if (typeof value === 'string') return mask(value)
280  if (Array.isArray(value)) return value.map((v) => deepMask(v, mask, depth + 1))
281  if (value && typeof value === 'object') {
282    const proto = Object.getPrototypeOf(value)
283    if (proto !== Object.prototype && proto !== null) return value
284    const out = {}
285    for (const [k, v] of Object.entries(value)) out[k] = deepMask(v, mask, depth + 1)
286    return out
287  }
288  return value
289}
290
291// Hides the readable text inside a business tool's result, keeping short tags (type: 'text') so the row still draws.
292export function hideText(s) {
293  if (typeof s !== 'string' || s.length === 0) return s
294  return s.length > 24 || /\s/.test(s) ? HIDDEN_OUTPUT : s
295}
296
297// extra: your own folder or file names from the config file, matched anywhere in a path, any case
298export function privatePathHit(text, extra = []) {
299  const s = String(text || '')
300  for (const re of PRIVATE_PATHS) {
301    const m = s.match(re)
302    if (m) return m[0].trim().replace(/^["'`\\/_-]/, '')
303  }
304  const flat = s.replace(/\\/g, '/').toLowerCase()
305  for (const item of extra) {
306    const needle = String(item || '').replace(/\\/g, '/').toLowerCase().trim()
307    if (needle && flat.includes(needle)) return String(item)
308  }
309  return null
310}
311
312// The strings in a tool call's arguments that could name a file or carry a command.
313export function toolCallTargets(e) {
314  const keys = ['file_path', 'path', 'pattern', 'glob', 'command', 'notebook_path', 'url']
315  const out = []
316  for (const k of keys) if (typeof e?.[k] === 'string') out.push(e[k])
317  return out
318}
319
320// Whether a tool's result is business data (mail, chat, tasks, files, calendar, finance)
321// extra: more tool or command names (any part of the name) whose results draw as hidden
322export function businessSource(tool, input, extra = []) {
323  const name = String(tool || '')
324  if (BUSINESS_TOOL.test(name)) return true
325  const cmd = input && typeof input.command === 'string' ? input.command : ''
326  const hay = (name + ' ' + cmd).toLowerCase()
327  if (extra.some((x) => x && hay.includes(String(x).toLowerCase()))) return true
328  const command = input && typeof input.command === 'string' ? input.command : ''
329  return command ? BUSINESS_COMMAND.test(command) : false
330}
331
332// extra: more tool names (any part of the name) that stay closed while recording
333export function financeTool(tool, extra = []) {
334  const name = String(tool || '')
335  return FINANCE_TOOL.test(name) || extra.some((x) => x && name.toLowerCase().includes(String(x).toLowerCase()))
336}
337
hooks/goal-plan.mjs 243 lines
1// Goal Meter's plan: the task list Claude keeps through the mod's own tool, and
2// what the bar, the ETA, and the goal check read from it. Pure functions: no mods
3// API calls here, so the harness tests can drive them directly.
4
5export const SIZES = { S: 1, M: 2, L: 3 }
6
7const STOP = /^(clear|stop|off|cancel|end|none|reset)$/i
8
9export function isStopWord(text) {
10  return STOP.test(String(text || '').trim())
11}
12
13export function titleOf(condition) {
14  const line = String(condition || '').split(/\r?\n/).map((l) => l.trim()).find(Boolean) || 'Goal'
15  return line.length > 160 ? line.slice(0, 159) + '…' : line
16}
17
18export function newGoal({ sessionId, condition, now, cwd, kind = 'goal' }) {
19  return {
20    sessionId,
21    kind,
22    title: titleOf(condition),
23    condition: String(condition || '').slice(0, 2000),
24    cwd: cwd || '',
25    startedAt: now,
26    planAt: 0,
27    endedAt: 0,
28    status: 'running',
29    active: true,
30    lastTurnEnd: 0,
31    interrupted: false,
32    tasks: [],
33    nextId: 1,
34    firstPlan: 0,
35    check: null,
36    checks: 0,
37    updatedAt: now,
38  }
39}
40
41function sizeOf(v) {
42  const s = String(v || '').trim().toUpperCase().charAt(0)
43  return SIZES[s] ? s : 'M'
44}
45
46function clean(text, max) {
47  const s = String(text ?? '').replace(/\s+/g, ' ').trim()
48  return s.length > max ? s.slice(0, max - 1) + '…' : s
49}
50
51export function weight(t) {
52  return SIZES[t.size] || 2
53}
54
55// Tasks still part of the plan: dropped and replaced ones don't count
56export function live(goal) {
57  return goal.tasks.filter((t) => t.status !== 'dropped' && !t.replaced)
58}
59
60export function progress(goal) {
61  const tasks = live(goal)
62  const total = tasks.reduce((a, t) => a + weight(t), 0)
63  const done = tasks.filter((t) => t.status === 'done')
64  const doneW = done.reduce((a, t) => a + weight(t), 0)
65  return {
66    total,
67    doneW,
68    doneN: done.length,
69    n: tasks.length,
70    active: tasks.filter((t) => t.status === 'active'),
71    pct: total ? Math.round((100 * doneW) / total) : 0,
72    fraction: total ? doneW / total : 0,
73  }
74}
75
76// The ETA at this goal's own pace: time since the plan per unit of finished
77// work, times the work left. Nothing until two tasks are done.
78export function eta(goal, now) {
79  const p = progress(goal)
80  if (goal.status !== 'running' || p.doneN < 2 || p.doneW <= 0 || p.doneW >= p.total) return null
81  const since = goal.planAt || goal.startedAt
82  const per = Math.max(0, now - since) / p.doneW
83  const ms = Math.round(per * (p.total - p.doneW))
84  return { ms, at: now + ms }
85}
86
87// Accepts [{ title, size }], plain strings, or the same as a JSON string
88export function normalizeTasks(raw) {
89  let list = raw
90  if (typeof list === 'string') {
91    try {
92      list = JSON.parse(list)
93    } catch {
94      list = list.split(/\r?\n/)
95    }
96  }
97  if (!Array.isArray(list)) return []
98  const out = []
99  for (const item of list) {
100    if (typeof item === 'string') {
101      const title = clean(item.replace(/^\s*[-*\d.)\]]+\s*/, ''), 140)
102      if (title) out.push({ title, size: 'M' })
103      continue
104    }
105    if (!item || typeof item !== 'object') continue
106    const title = clean(item.title ?? item.subject ?? item.name ?? item.task ?? item.description, 140)
107    if (title) out.push({ title, size: sizeOf(item.size) })
108  }
109  return out.slice(0, 60)
110}
111
112function idsOf(input) {
113  const raw = Array.isArray(input.ids) ? input.ids : input.id !== undefined ? [input.id] : []
114  return raw.map((v) => Number(String(v).replace(/^#/, ''))).filter((n) => Number.isInteger(n) && n > 0)
115}
116
117function addTasks(goal, list, now, origin) {
118  for (const t of list) {
119    goal.tasks.push({ id: goal.nextId++, title: t.title, size: t.size, status: 'pending', by: '', addedAt: now, startedAt: 0, doneAt: 0, origin, note: '' })
120  }
121}
122
123const WORD = { pending: 'todo', active: 'running', done: 'done', dropped: 'dropped' }
124
125export function listText(goal) {
126  const p = progress(goal)
127  const lines = [`Plan: ${p.doneN} of ${p.n} tasks done, ${p.pct}% of the work.`]
128  for (const t of goal.tasks) {
129    if (t.replaced) continue
130    const who = t.status === 'active' && t.by ? ` by ${t.by}` : ''
131    lines.push(`#${t.id} ${WORD[t.status] || t.status}  ${t.title} (${t.size})${who}`)
132  }
133  return lines.join('\n')
134}
135
136// One call of the task tool. Returns { ok, text } and changes the goal in place.
137export function applyAction(goal, input, { now, by = '' } = {}) {
138  const action = String(input.action || 'show').toLowerCase()
139  const fail = (msg) => ({ ok: false, text: `Goal meter: ${msg}\n${listText(goal)}` })
140  if (action === 'plan' || action === 'add') {
141    const list = normalizeTasks(input.tasks)
142    if (!list.length) return fail(`"${action}" needs tasks: [{ "title": "...", "size": "S" | "M" | "L" }].`)
143    if (action === 'plan' && goal.planAt) {
144      // a new plan replaces the work not started yet; finished and running tasks stay
145      for (const t of goal.tasks) if (t.status === 'pending') t.replaced = true
146    }
147    const first = !goal.planAt
148    addTasks(goal, list, now, first ? 'plan' : 'later')
149    if (first) {
150      goal.planAt = now
151      goal.firstPlan = list.length
152    }
153  } else if (action === 'start' || action === 'done' || action === 'drop') {
154    const ids = idsOf(input)
155    if (!ids.length) return fail(`"${action}" needs the task's id.`)
156    const tasks = ids.map((id) => goal.tasks.find((t) => t.id === id && !t.replaced))
157    const missing = ids.filter((id, i) => !tasks[i])
158    if (missing.length) return fail(`no task #${missing.join(', #')}.`)
159    for (const t of tasks) {
160      if (action === 'start') {
161        t.status = 'active'
162        t.startedAt = t.startedAt || now
163        if (by) t.by = clean(by, 32)
164      } else if (action === 'done') {
165        t.status = 'done'
166        t.startedAt = t.startedAt || now
167        t.doneAt = now
168        if (input.note) t.note = clean(input.note, 140)
169      } else {
170        t.status = 'dropped'
171        t.doneAt = now
172        t.note = clean(input.note || 'no longer needed', 140)
173      }
174    }
175  } else if (action !== 'show') {
176    return fail(`unknown action "${action}". Use plan, add, start, done, drop, or show.`)
177  }
178  goal.updatedAt = now
179  return { ok: true, text: listText(goal) }
180}
181
182// The goal check's verdict, from the text the engine records for it. The shape
183// is not documented, so this reads both a JSON-ish record and plain words.
184export function parseCheck(text) {
185  const t = String(text || '')
186  const notMet = /"met"\s*:\s*false|\bmet\s*[=:]\s*false|\bnot\s+(?:yet\s+)?(?:been\s+)?(?:met|achieved|satisfied|complete)|\bunmet\b|\bnot\s+satisfied\b/i.test(t)
187  const met = !notMet && /"met"\s*:\s*true|\bmet\s*[=:]\s*true|\b(?:condition|goal)\b[^.\n]{0,60}\b(?:is|was|has been)\s+(?:met|achieved|satisfied)\b/i.test(t)
188  const m = t.match(/"reason"\s*:\s*"((?:[^"\\]|\\.)*)"/) || t.match(/\breason\b[^:\n]{0,20}:\s*([\s\S]+)/i)
189  const reason = clean(m ? m[1].replace(/\\"/g, '"').replace(/\\n/g, ' ') : '', 300)
190  return { met, notMet, reason }
191}
192
193export const TOOL_SPEC = {
194  name: 'tasks',
195  description:
196    'Goal meter: the task plan the user watches as a progress bar while a /goal runs. ' +
197    'Call action "plan" first with every task needed to meet the goal, in order, each sized S, M, or L. ' +
198    'Call "start" with a task id when you begin it (set "by" to a subagent\'s short name when one does it) and "done" when it is finished. ' +
199    'Call "add" for work you discover or when the goal check says the goal is not met yet, and "drop" for a task no longer needed. ' +
200    'Keep it accurate: the bar and the ETA come only from this list.',
201  inputSchema: {
202    type: 'object',
203    properties: {
204      action: { type: 'string', enum: ['plan', 'add', 'start', 'done', 'drop', 'show'] },
205      tasks: {
206        type: 'array',
207        description: 'For plan and add: the tasks in order.',
208        items: {
209          type: 'object',
210          properties: {
211            title: { type: 'string', description: 'What the task delivers, in a few words' },
212            size: { type: 'string', enum: ['S', 'M', 'L'], description: 'S: a few minutes. M: a solid chunk. L: the big piece.' },
213          },
214          required: ['title'],
215        },
216      },
217      id: { type: 'number', description: 'For start, done, and drop: the task number' },
218      ids: { type: 'array', items: { type: 'number' }, description: 'Several task numbers at once' },
219      by: { type: 'string', description: 'For start: who does it, "main" or the subagent\'s short name' },
220      note: { type: 'string', description: 'For done or drop: one short line' },
221    },
222    required: ['action'],
223  },
224}
225
226export function instruction(tool) {
227  return (
228    `Goal meter is on for this goal: the user watches your progress as a bar built from your task plan. ` +
229    `Before you start the work, call ${tool} (load it with ToolSearch if it is deferred) with action "plan" and every task needed to meet the goal, in order, each sized S, M, or L. ` +
230    `Call "start" with a task's id when you begin it and "done" when it is finished. ` +
231    `If you find more work, or the goal check says the goal is not met yet, call "add" with the new tasks. ` +
232    `When a subagent does a task, call "start" with "by" set to its short name, and "done" when it reports back.`
233  )
234}
235
236export function nudge(tool) {
237  return `Reminder from the goal meter: this /goal has no task plan yet, so the user's progress bar is empty. Call ${tool} with action "plan" now (every task to meet the goal, in order, sized S, M, or L), then mark tasks as you go.`
238}
239
240export function strictDeny(tool) {
241  return `Goal meter (strict): plan the goal before changing files. Call ${tool} with action "plan" first, then retry this.`
242}
243
types/index.d.ts 129 lines
1// The cockpit's state contract: savvy-progress's values and the file tree's, under one plugin.
2
3export type Phase = 'plan' | 'design' | 'delegate' | 'review' | 'close'
4
5export type PlannedTask = {
6  title: string
7  tier: string
8  after: number[]
9}
10
11export type Flow = {
12  title: string
13  total: number
14  done: number
15  running: number
16  phase: Phase
17  isFinished: boolean
18  tasks: PlannedTask[]
19}
20
21export type AgentStatus = 'running' | 'done' | 'failed'
22
23export type AgentRun = {
24  id: string
25  agentId?: string
26  type: string
27  description: string
28  model: string
29  effort?: string
30  status: AgentStatus
31  startedAt: number
32  endedAt?: number
33  contextTokens: number
34  contextMax: number
35  tokens: number
36  costUsd: number
37  steps: number
38  round: number
39  /** Self-reported by the worker through the `step` tool. */
40  stepDone?: number
41  stepTotal?: number
42  stepNote?: string
43}
44
45export type Panel = {
46  isCompact: boolean
47  isDoneCollapsed: boolean
48  autoOpenedFor: string
49}
50
51export type FileNode = {
52  id: string
53  parent: string
54  name: string
55  kind: 'dir' | 'file' | 'link'
56  hidden: boolean
57  mtime: number
58  size: number
59  loaded: boolean
60}
61
62export type Branch = {
63  head: string
64  upstream: string
65  ahead: number
66  behind: number
67}
68
69export type Activity = {
70  id: number
71  kind: string
72  label: string
73  state: 'running' | 'done' | 'failed'
74  detail: string
75  at: number
76  tone: string
77  nerd: string
78  plain: string
79}
80
81export type FileTree = {
82  root: string
83  nodes: FileNode[]
84  expanded: string[]
85  cursor: string
86  selected: string
87  query: string
88  showHidden: boolean
89  showSize: boolean
90  dirSizes: Record<string, number>
91  git: Record<string, string>
92  diff: Record<string, [number, number]>
93  ignored: string[]
94  untrackedDirs: string[]
95  top: string
96  prefix: string
97  branch: Branch | null
98  counts: Record<string, [number, number, number]>
99  flash: string[]
100  flashDim: string[]
101  flashOn: boolean
102  flashTones: Record<string, string>
103  scroll: number | null
104}
105
106export type Theme = {
107  fg: string
108  accent: string
109  muted: string
110  urgent: string
111  selection: string
112  bg: string
113}
114
115declare module 'claude-code' {
116  interface PluginState {
117    cockpit: {
118      flow: Flow | null
119      agents: AgentRun[]
120      panel: Panel
121      now: number
122      chatTokens: number
123      tree: FileTree
124      theme: Theme
125      activity: Activity[]
126    }
127  }
128}
129