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…

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.
In Claude Code:
/plugin marketplace add servaes/cockpit
/plugin install cockpit@my-mods
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():
resets Fri 1:30 AM (in 2h40m)), then one short "so what" line (40% of the session gone, 5% used · room to spare).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):
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.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):/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.~/.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)./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.
/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./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./agents-info brings up the board; there is no separate agents panel.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:
+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./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.
/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./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.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).minAnswerChars, suggestSkills. The separate next-steps@claude-community plugin is disabled on purpose (it would draw twice).◆ 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.codex) only turns on where a codex command is installed; otherwise it says so.✎ 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./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.
~/.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.$ 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.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.claude plugin disable cockpit@my-mods.claude plugin validate ~/.claude/my-mods/cockpit; tests: claude plugin test ~/.claude/my-mods/cockpit.hooks/register.tsx 7009 lines1// 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 lines1export 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}
41hooks/board-svg.ts 312 lines1// 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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}
312hooks/git.ts 262 lines1import { 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}
262hooks/rows.tsx 143 lines1import 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
143hooks/icons.ts 132 lines1import 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}
132hooks/tree.ts 331 lines1import 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}
331hooks/goal-fmt.mjs 70 lines1// 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}
70hooks/goal-privacy.mjs 337 lines1// 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}
337hooks/goal-plan.mjs 243 lines1// 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}
243types/index.d.ts 129 lines1// 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