Subscription usage, API-equivalent cost and context bar above the prompt

A one-row bar above the prompt showing how close your Claude subscription is to its limits, and what the work would cost at Anthropic API list prices.
AI Tools 5h 42%, 1h12m wk 18%, Thu ctx 31% 62k/200k tok 1.3M (+48k) cache 42m thread $3.12 (+$0.41) month $184.20 limit ~3:40pm [🛠] [⁝]
| Segment | Meaning |
|---|---|
| Clawd | The mascot, at the start of the bar in the desktop Code tab only (see below) |
AI Tools | A dim title naming the bar |
5h 42%, 1h12m | 5-hour usage window: percent used, time until it resets |
wk 18%, Thu | Weekly window: percent used, the day it resets (hours when under a day) |
ctx 31% 62k/200k | Context window fill: percent and tokens used / window size |
tok 1.3M (+48k) | Tokens: this thread's total this month (its helpers' included) and the last turn's, counting input, output and prompt-cache reads and writes |
cache 42m | How long the prompt cache should stay warm: Claude Code writes the main conversation's cache with a 1-hour TTL, restarted by every request. Amber under 10 minutes, cache cold once expired. An estimate from request times; the API does not report cache state, and Anthropic may evict early |
thread $3.12 | This session's cost at API prices |
(+$0.41) | What the last turn cost (last $0.41 when thread cost is hidden) |
~2.1% wk (+0.03%) | Thread cost %: the thread's and the last turn's estimated share of your weekly allowance (see below) |
month $184.20 | This calendar month's Claude Code usage at API prices, across all sessions on this machine |
limit ~3:40pm | At your pace over the last hour, when the 5-hour window would run out; shown only if before it resets |
Labels are dim and values a lighter grey; usage and context values turn yellow at 70% and red at 90%. A value not known yet shows —; until the first response of a session the bar shows the figures from your previous session.
/aitools toggles the whole bar (buttons included); /aitools on and /aitools off set it. The choice holds across sessions. /handoff works either way.
Press ⁝ to open the options above the bar, five to a row, and toggle any of: (in the desktop Code tab only, leading the first row) the mascot, the title, 5-hour usage, weekly usage, reset countdowns, context %, context tokens, thread tokens, last-turn tokens, cache warmth, thread cost, last-turn cost, thread cost %, monthly cost, burn-rate projection, threshold colors. The 🛠 button always shows. Changes show in the bar right away and are saved across sessions. An option that is off does no work in the background: the transcript scan runs only for monthly cost, thread tokens or thread cost %; the cost lookups only for the cost options; the cache timer only for cache warmth; the mascot follows nothing while hidden. Turned back on, an option catches up at once, except the burn-rate projection, which needs fresh samples (a few responses) to project from. Hiding the bar with /aitools off stops all of it. Press ⁝ again to close it; opening it closes the 🛠 menu, and the other way round.
In the desktop Code tab, Clawd, Claude Code's pixel mascot, stands at the start of the bar (48 × 20 px) and acts out what Claude is doing. Every pose is animated, and a change of pose plays a short transition first: he walks over to his laptop or paper, sits down to sleep, jolts awake.
| Pose | When |
|---|---|
| Waving | A new session starts (a few seconds) |
| Standing | Nothing is running: breathing, blinking, glancing around |
| Typing | A turn is running: tapping at a laptop beside him, code typing out on its screen |
| Reading | A read-only tool runs (Read, Grep, Glob, a web fetch or search): reading a sheet of paper |
| Puzzled | A question waits on you: an open AskUserQuestion dialog, or a reply that ends with a question mark. Your next prompt settles it |
| Celebrating | A turn finishes with an answer (a few seconds, then Standing) |
| Oops | A tool call fails or is refused, or a turn is interrupted or fails (a few seconds) |
| Sleeping | Standing idle for 5 minutes; your next prompt wakes him |
He is SVG, so he never shows in the terminal, and Mascot in the ⁝ menu (on by default) is offered on the desktop only.
/handoff asks the current conversation for a brief (goal, current state, decisions, open tasks, key files, next step), prints it in the chat as formatted text, and copies the raw markdown to the clipboard. Paste it into a new session to continue there. The brief is a command output row, so the current session's model reads it too.
The 🛠 button opens a row above the bar with Handoff, Notes, Subagents, Task View and Clean View, right-aligned; press it again to close the row without picking. Handoff writes the same brief into a Handoff brief pane, drawn as formatted text with a Copy button; nothing is copied until you press it, and the brief stays out of the chat (so the current session's model does not read it).
Notes in the 🛠 menu opens a Notes pane with one text box for a single scratch note. It is kept in .claude/notes.md under the project root, a plain Markdown file you can open anywhere else too, and saved as you type. Copy, at the end of the line above the box, copies the note to the clipboard. Add .claude/notes.md to the project's .gitignore if it should stay out of git.
Click in the box to type. Enter starts a new line, the arrows, Home and End move the cursor (Ctrl+A and Ctrl+E too), Page Up and Page Down move ten rows, and Backspace and Delete remove. Escape hands the keys back to the prompt. There is no selection or undo. Pasting works in the terminal but not in the desktop app, where a mod cannot read the clipboard; paste into .claude/notes.md in another editor and reopen the pane instead. The pane stays open until you close it, and comes back after the mod reloads. Where a surface cannot draw the box (VS Code, the mobile app), the pane says where the note is kept instead.
Two views from the 🛠 menu that turn Claude's work into a checklist under the bar (an open menu still shows above it): done steps marked with a green ✓, the step in progress with a blue ●, steps not started with a dim ○. In the menu a ● marks the view that is on and a ○ the one that is off; picking the one that is on turns it off, and picking the other switches. The choice holds across sessions.
While one is on, its name shows in the status line under the prompt (with Subagents after a comma while the Subagents pane is open with a team size picked). /taskview and /cleanview do the same as the menu from the prompt: no argument toggles that view (turning it on switches off the other), on turns it on and off turns it off.
Adding dark mode 3 of 5
✓ Read the theme code 100% ▰▰▰▰▰▰
✓ Add the color tokens 100% ▰▰▰▰▰▰
● Wire up the toggle 40% ▰▰▱▱▱▱
○ Update tests 0% ▱▱▱▱▱▱
○ Run checks 0% ▱▱▱▱▱▱
Each step has a bar: full once done, empty until it starts, and for the step in progress Claude's own estimate of how far along it is, which moves each time Claude updates the checklist (the note asks for about every quarter of a step).
While a view is on, each prompt you send carries a hidden note asking Claude to plan the work as plain-language steps and report them through the mod's checklist tool (mcp__aitools__checklist), updating it as each step starts and finishes. The mod answers that tool itself, before Claude Code's permission check, so it should not ask for permission. Each new prompt starts a fresh checklist; once every step is done and Claude stops, it folds to one line, ✓ Done: <title> (<n> steps), with a × at its right end that closes it. A quick question that needs no work gets no checklist.
Clean View recognizes a final reply as the last thing Claude wrote before your next prompt. It reads them from the conversation when the mod loads (so replies from before a reload or restart still show) and adds each new one as a request finishes.
Subagents in the 🛠 menu opens the Subagents pane; the pane's own close mark closes it. /subagents opens it, or closes it if open. It stays open until you close it, and comes back after the mod reloads.
On the desktop, Clawd stands left of the pane's option rows with a mini Clawd stacked on his head for each subagent running in the list (the stack stops growing at four), and sleeps in quiet grey when none is. The status line under the options sits beside him too. The terminal has no picture.
While it is open, the pane lists every subagent Claude starts, one per line: Agent tool subagents (foreground or background), and the agents of a workflow it starts, named after the workflow (review-changes · agent 3, since a workflow's agents have no names of their own). The ones running come first (oldest first), then the finished ones, the most recently finished first; pieces waiting for a free helper get no line, only a count in the header. Marks are Task View's: a blue ● running, a ✓ done, and a red × failed. A running subagent's name is a lighter grey, followed in dim grey by what it is doing from its latest tool call (Reading bar.tsx, Running npm test) and its running time. A line's name and what it is doing take at most 80 characters together, cut with …. A finished line is all dim (but a failed one's ×): no bar, just how long it ran and how long ago it finished, 0:09 (3m ago).
Showing: [All] Tool agents Current task
Hide completed: [Never] 15m 5m 1m Immediate
Set team size: Default 3 [5] 10 20 30
Set model: [Same as chat] Fast & cheap
5 agents · 3 working · 2 idle · 1 queued · 1 done
● Research business licenses · Reading the city site 40% ▰▰▱▱▱▱ 1:20
✓ Draft the floor plan 0:56 (3m ago)
In each option row the choice in effect is drawn at full strength (shown as […] above); the others are dim, and pressing one picks it. They are the same compact buttons as the bar's.
Set team size starts on Default: subagents run as Claude Code normally runs them, and the pane lists them (no team note, no model change, nothing in the status line); each is only asked to report its progress. Pick a size and the team applies too, with Subagents in the status line. While the pane is closed nothing is tracked; while it is open but not on screen (the status line then reads Subagents (not shown)) the team does not apply.
agent_progress tool (mcp__aitools__agent_progress); once it does, its line shows its percent (its own estimate) and a bar. A helper's line shows what it says it is doing; other subagents' lines follow their latest tool call. Workflow agents, and subagents started before the pane opened, are not asked, so they show no bar.Tool subagents will appear here when created.The usage figures only report the weekly window as a percent, so the mod estimates what 1% of the week costs: this machine's spend at API prices since the window opened (its reset time less 7 days) divided by the weekly percent used. The thread's cost and the last turn's are then shown as shares of that, marked ~. Usage on other machines or in claude.ai's chat counts toward your weekly percent but not toward this machine's spend, which makes the shares read low; the estimate also assumes the allowance is charged roughly in line with API prices. Hourly costs for the last 8 days are kept alongside the monthly scan for this.
The mod reads Claude Code's own transcripts (~/.claude/projects/**/*.jsonl, or under CLAUDE_CONFIG_DIR), counts each assistant response once (by message id) when its timestamp falls in the current month, and prices its token usage (input, output, cache writes at the 5-minute or 1-hour rate, cache reads) with the table in src/pricing.ts (API list prices as of 2026-09-25). Only bytes added since the last scan are read; the cache lives in the mod's store and resets each month. A ~ before a figure means part of it used an estimated rate (an unknown model priced by its family) or a file could not be read.
hooks/register.tsx 1388 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Events, Register, Timer } from 'claude-code'
3
4import {
5 FAST_EFFORT,
6 FAST_MODEL,
7 HELPER_LABELS,
8 HELPER_NOTE,
9 PROGRESS_SPEC,
10 PROGRESS_TOOL_ID,
11 SUBAGENT_NOTE,
12 DEFAULT_HIDE_AFTER,
13 DOCK_PANE,
14 activityText,
15 addCard,
16 dockNote,
17 finishCard,
18 isRunning,
19 isHideAfter,
20 isShowing,
21 isTeamPick,
22 newRun,
23 noteActivity,
24 queuePiece,
25 renderDock,
26 reportProgress,
27 workflowName,
28} from '../src/agentdock'
29import { renderBar } from '../src/bar'
30import {
31 CHECKLIST_SPEC,
32 CHECKLIST_TOOL_ID,
33 VIEW_NAMES,
34 FAINT_TEXT,
35 addFinal,
36 finalReplies,
37 isFinalReply,
38 nextView,
39 parseChecklist,
40 renderChecklist,
41 viewNote,
42} from '../src/checklist'
43import { EMPTY_BURN, addSample, project } from '../src/burnrate'
44import type { BurnState } from '../src/burnrate'
45import { HANDOFF_PROMPT, handoffOutput, paneMarkdown } from '../src/handoff'
46import {
47 MASCOT_START,
48 ONE_SHOT_MS,
49 READ_TOOLS,
50 SLEEP_AFTER_MS,
51 endsWithQuestion,
52 introMs,
53 isQuestionOpenIn,
54 mascotDrawing,
55} from '../src/mascot'
56import { DEFAULT_SETTINGS, loadSettings, nextHidden, normalizeSettings, saveSettings } from '../src/settings'
57import type { KeyStore } from '../src/settings'
58import { scanMonth, sessionTokens, sessionUsd, usageTokens } from '../src/transcripts'
59import type { ScanCache, ScanIO } from '../src/transcripts'
60import type { AgentKind, AgentShowing, Brief, HelperMode, HideAfter, LimitWindow, MascotPose, MascotState, Snapshot, TeamPick, TeamSize, ToggleKey, ViewMode } from '../types'
61
62// The engine follows `$` only into functions declared in this file, so every
63// function that touches `$` lives here; src/ holds the pure units.
64
65const snapshot = atom({ plugin: 'aitools', key: 'snapshot' } as const, null)
66const month = atom({ plugin: 'aitools', key: 'month' } as const, { usd: 0, isEstimate: false, status: 'loading' })
67const projection = atom({ plugin: 'aitools', key: 'projection' } as const, null)
68const settings = atom({ plugin: 'aitools', key: 'settings' } as const, DEFAULT_SETTINGS)
69const isHidden = atom({ plugin: 'aitools', key: 'isHidden' } as const, false)
70const isSettingsOpen = atom({ plugin: 'aitools', key: 'isSettingsOpen' } as const, false)
71const cacheAt = atom({ plugin: 'aitools', key: 'cacheAt' } as const, null)
72const turnBaseline = atom({ plugin: 'aitools', key: 'turnBaseline' } as const, null)
73const tick = atom({ plugin: 'aitools', key: 'tick' } as const, 0)
74const isHandingOff = atom({ plugin: 'aitools', key: 'isHandingOff' } as const, false)
75const isToolsOpen = atom({ plugin: 'aitools', key: 'isToolsOpen' } as const, false)
76const brief = atom({ plugin: 'aitools', key: 'brief' } as const, null)
77const viewMode = atom({ plugin: 'aitools', key: 'viewMode' } as const, 'off')
78const checklist = atom({ plugin: 'aitools', key: 'checklist' } as const, null)
79const finals = atom({ plugin: 'aitools', key: 'finals' } as const, [])
80const isDockOpen = atom({ plugin: 'aitools', key: 'isDockOpen' } as const, false)
81const team = atom({ plugin: 'aitools', key: 'team' } as const, 'default' as TeamPick)
82const helpers = atom({ plugin: 'aitools', key: 'helpers' } as const, 'same')
83const agentShowing = atom({ plugin: 'aitools', key: 'agentShowing' } as const, 'all')
84const hideAfter = atom({ plugin: 'aitools', key: 'hideAfter' } as const, DEFAULT_HIDE_AFTER)
85const requestAt = atom({ plugin: 'aitools', key: 'requestAt' } as const, null)
86const agentRun = atom({ plugin: 'aitools', key: 'agentRun' } as const, null)
87const mainEffort = atom({ plugin: 'aitools', key: 'mainEffort' } as const, null)
88const dockTick = atom({ plugin: 'aitools', key: 'dockTick' } as const, 0)
89const mascot = atom({ plugin: 'aitools', key: 'mascot' } as const, MASCOT_START)
90const asking = atom({ plugin: 'aitools', key: 'asking' } as const, 0)
91const isQuestionOpen = atom({ plugin: 'aitools', key: 'isQuestionOpen' } as const, false)
92const isNotesOpen = atom({ plugin: 'aitools', key: 'isNotesOpen' } as const, false)
93const notes = atom({ plugin: 'aitools', key: 'notes' } as const, null)
94
95const HANDOFF_PANE = 'handoff'
96const NOTES_PANE = 'notes'
97/** The note's file, under the session's project root. */
98const NOTES_FILE = '.claude/notes.md'
99/** The editor's key in the Notes pane. */
100const NOTES_EDITOR = 'notes-editor'
101
102/** Prompt origins that are the person's own words; anything else (a helper's report, a notice) is not a new request. */
103const PERSON_ORIGINS: ReadonlySet<string> = new Set(['composer', 'bridge', 'sdk'])
104/** How many Subagents trace lines the store keeps. */
105const TRACE_KEEP = 60
106/** The mod's own tools: reporting progress is not work the mascot reacts to. */
107const QUIET_TOOLS: ReadonlySet<string> = new Set([CHECKLIST_TOOL_ID, PROGRESS_TOOL_ID])
108/** How long a helper's first model request waits for its own start to be recorded, at most. */
109const SPAWN_WAIT_MS = 2000
110
111/**
112 * Subagents bookkeeping that must change without an `await` between check and write, so helpers started together
113 * in one message see each other: the team places taken by starts still in flight, the helpers with a running card,
114 * and the starts in flight, which a helper's first model request may wait on before its card exists.
115 */
116let reservedPlaces = 0
117const liveHelpers = new Set<string>()
118const startsInFlight = new Set<Promise<void>>()
119/** Subagent loops already looked at: each gets a line once, or is passed over once (a fork, the pane closed). */
120const seenAgents = new Set<string>()
121/** Subagents with a line that shows their latest tool call (helpers report their own progress instead). */
122const trackedAgents = new Set<string>()
123/** The last workflow the main chat started, and how many of its agents have a line: what workflow agents are called. */
124let lastWorkflow: string | null = null
125let workflowAgents = 0
126/** Read-only tool calls of the main chat in flight: the mascot reads until the last one returns. */
127let readsInFlight = 0
128/** Whether a main-chat turn is running: where the mascot stands when he is turned on mid-session. */
129let isTurnRunning = false
130/** The once-a-second timer that redraws helper times: running only while the Subagents pane is open. */
131let dockTimer: Timer | null = null
132/** The model tools registered so far: each is offered to the model only once its feature is first turned on. */
133const registeredTools = new Set<string>()
134/** The note's writes, one after another, so the last text typed is the one left on disk. */
135let notesSaving: Promise<void> = Promise.resolve()
136/** The note as it stands: as loaded, then as the editor last posted it. What Copy copies. */
137let latestNotes = ''
138/** Trace writes run one after another, so lines written at once are all kept. */
139let traceQueue: Promise<void> = Promise.resolve()
140
141const encoder = new TextEncoder()
142const TICK_MS = 30_000
143/** Cache times older than this are dropped from the per-session store. */
144const CACHE_KEEP_MS = 2 * 60 * 60_000
145/** Blank rows between the chat and the bar on the terminal, so the last of a reply does not run into it. */
146const TERMINAL_TOP_MARGIN = 1
147
148/** The latest scan, kept here too so a store write that fails costs nothing but persistence. */
149let memoryCache: ScanCache | null = null
150let isScanning = false
151let isScanPending = false
152let hasLoggedScanError = false
153
154function errorText(err: unknown): string {
155 return err instanceof Error ? err.message : String(err)
156}
157
158/** Whether a bar option is on and the bar shown: work that only feeds the bar is skipped otherwise. */
159async function isShown($: EngineInterface, keys: ToggleKey | readonly ToggleKey[]): Promise<boolean> {
160 if (await read($, isHidden)) {
161 return false
162 }
163 const current = normalizeSettings(await read($, settings))
164
165 return (typeof keys === 'string' ? [keys] : keys).some(key => current[key])
166}
167
168/** The options whose figures come from the transcript scan: the month's cost, the thread's tokens, the week's share. */
169const SCAN_KEYS: readonly ToggleKey[] = ['monthlyCost', 'threadTokens', 'threadPercent']
170/** The options that show the session's cost. */
171const COST_KEYS: readonly ToggleKey[] = ['threadCost', 'lastTurnCost', 'threadPercent']
172
173/** Whether the mascot is drawn anywhere: its option on, the bar shown, and a surface that draws SVG attached. */
174async function isMascotOn($: EngineInterface): Promise<boolean> {
175 if (!(await isShown($, 'mascot'))) {
176 return false
177 }
178
179 return (await $.session.surfaces().catch(() => [])).some(surface => surface !== 'terminal')
180}
181
182/** Offers one of the mod's tools to the model, the first time its feature is turned on in this load. */
183async function registerTool($: EngineInterface, spec: Parameters<EngineInterface['tool']['register']>[0]): Promise<void> {
184 if (registeredTools.has(spec.name)) {
185 return
186 }
187 registeredTools.add(spec.name)
188 await $.tool.register(spec).catch(err => {
189 registeredTools.delete(spec.name)
190 $.ui.log(`aitools: ${spec.name} tool not registered: ${errorText(err)}`)
191 })
192}
193
194function toWindow(limit: { percentUsed: number; resetsAt?: string } | undefined): LimitWindow | undefined {
195 return limit && { percentUsed: limit.percentUsed, resetsAt: limit.resetsAt }
196}
197
198function storeOf($: EngineInterface): KeyStore {
199 return { get: key => $.store.get(key), set: (key, value) => $.store.set(key, value) }
200}
201
202function scanIO($: EngineInterface): ScanIO {
203 return {
204 list: dir => $.fs.list(dir),
205 readBytes: async path => encoder.encode(await $.fs.read(path)),
206 async tail(path, offset) {
207 const run = await $.process.run(['tail', '-c', `+${offset + 1}`, path])
208 if (run.exitCode !== 0) {
209 throw new Error(`tail exited ${run.exitCode}`)
210 }
211
212 return { bytes: encoder.encode(run.stdout), isTruncated: run.isStdoutTruncated }
213 },
214 }
215}
216
217async function projectsRoot($: EngineInterface): Promise<string> {
218 const configDir = await $.env.get('CLAUDE_CONFIG_DIR')
219 const home = await $.env.get('HOME')
220
221 return `${configDir ?? `${home}/.claude`}/projects`
222}
223
224async function readCache($: EngineInterface): Promise<ScanCache | null> {
225 return memoryCache ?? ((await $.store.get('scanCache')) as ScanCache | undefined) ?? null
226}
227
228async function scan($: EngineInterface): Promise<void> {
229 if (isScanning) {
230 isScanPending = true
231 return
232 }
233 isScanning = true
234 try {
235 do {
236 isScanPending = false
237 const next = await scanMonth(scanIO($), await projectsRoot($), await readCache($), await $.clock.now())
238 memoryCache = next
239 await $.store.set('scanCache', next).catch((err: unknown) => logScanError($, err))
240 await update($, month, () => ({ usd: next.usd, isEstimate: next.isEstimate, status: 'ready' as const, hours: next.hours }))
241 const threadTokens = sessionTokens(next, await $.session.id())
242 if (threadTokens !== undefined) {
243 await update($, snapshot, snap => ({ ...(snap ?? {}), threadTokens }))
244 }
245 } while (isScanPending)
246 } catch (err) {
247 await update($, month, m => ({ ...m, usd: m?.usd ?? 0, isEstimate: m?.isEstimate ?? false, status: 'error' as const }))
248 logScanError($, err)
249 } finally {
250 isScanning = false
251 }
252}
253
254function logScanError($: EngineInterface, err: unknown): void {
255 if (!hasLoggedScanError) {
256 hasLoggedScanError = true
257 $.ui.log(`aitools: monthly cost scan failed: ${errorText(err)}`)
258 }
259}
260
261type CacheTimes = Record<string, number>
262
263async function rememberCacheAt($: EngineInterface, at: number): Promise<void> {
264 const sessionId = await $.session.id()
265 const stored = ((await $.store.get('cacheAt')) as CacheTimes | undefined) ?? {}
266 const kept = Object.fromEntries(Object.entries(stored).filter(([, t]) => at - t < CACHE_KEEP_MS))
267 await $.store.set('cacheAt', { ...kept, [sessionId]: at })
268}
269
270async function restoreCacheAt($: EngineInterface): Promise<void> {
271 const stored = ((await $.store.get('cacheAt')) as CacheTimes | undefined) ?? {}
272 const at = stored[await $.session.id()]
273 if (at !== undefined) {
274 await update($, cacheAt, () => at)
275 }
276}
277
278/** Scans the transcripts soon, if any option showing their figures is on. */
279function startScan($: EngineInterface): void {
280 $.clock.after(0, () => void scanIfShown($))
281}
282
283async function scanIfShown($: EngineInterface): Promise<void> {
284 if (await isShown($, SCAN_KEYS)) {
285 await scan($)
286 }
287}
288
289/** Asks a fork of this conversation for the brief: the brief, or the failure's text. Null while one is already running. */
290async function writeBrief($: EngineInterface): Promise<Brief | null> {
291 if (await read($, isHandingOff)) {
292 return null
293 }
294 await update($, isHandingOff, () => true)
295 try {
296 const reply = await $.model.fork({ prompt: HANDOFF_PROMPT })
297 if (!reply.isAnswered) {
298 const reason = reply.reason === 'nothing-to-fork' ? 'nothing to hand off yet' : reply.reason
299 return { status: 'error', text: `Handoff failed: ${reason}` }
300 }
301
302 return { status: 'ready', text: reply.text.trim() }
303 } catch (err) {
304 return { status: 'error', text: `Handoff failed: ${errorText(err)}` }
305 } finally {
306 await update($, isHandingOff, () => false)
307 }
308}
309
310/** /handoff: copies the brief and toasts the outcome. Returns the chat row's text. */
311async function handoff($: EngineInterface): Promise<string> {
312 const written = await writeBrief($)
313 if (written === null) {
314 return 'Handoff already in progress.'
315 }
316 if (written.status === 'error') {
317 $.ui.toast(written.text)
318 return written.text
319 }
320 const copied = await $.ui.copy({ text: written.text })
321 $.ui.toast(copied.isCopied ? 'Handoff brief copied to the clipboard' : 'Handoff brief ready (copy failed)')
322
323 return handoffOutput(written.text)
324}
325
326/** Where the note is kept: `.claude/notes.md` under the session's project root. */
327async function notesPath($: EngineInterface): Promise<string> {
328 return `${(await $.session.root()).replace(/[\\/]$/, '')}/${NOTES_FILE}`
329}
330
331/**
332 * Reads the note for the pane, once any write still going has landed: none yet is an empty note; a file that is there
333 * but cannot be read is an error, so the editor never writes over it.
334 */
335async function loadNotes($: EngineInterface): Promise<void> {
336 await update($, notes, () => null)
337 await notesSaving
338 const path = await notesPath($)
339 let loaded: { text: string } | { error: string }
340 if (!(await $.fs.exists(path).catch(() => false))) {
341 loaded = { text: '' }
342 } else {
343 loaded = await $.fs.read(path).then(
344 text => (typeof text === 'string' ? { text } : { error: `${NOTES_FILE} is not text.` }),
345 (err: unknown) => ({ error: `Could not read ${NOTES_FILE}: ${errorText(err)}` }),
346 )
347 }
348 latestNotes = 'text' in loaded ? loaded.text : ''
349 await update($, notes, () => loaded)
350}
351
352/** The 🛠 menu's Notes: opens the Notes pane on the note as saved, its keys handed to the editor. */
353async function openNotes($: EngineInterface): Promise<void> {
354 await update($, isToolsOpen, () => false)
355 await update($, isNotesOpen, () => true)
356 await $.ui.open({ id: NOTES_PANE, title: 'Notes', focus: true })
357 await loadNotes($)
358 await $.ui.focus({ requestId: NOTES_PANE, key: NOTES_EDITOR }).catch(() => undefined)
359}
360
361/** Saves the note as the editor posted it, after any write before it. */
362function saveNotes($: EngineInterface, text: string): void {
363 latestNotes = text
364 notesSaving = notesSaving
365 .then(async () => $.fs.write(await notesPath($), text))
366 .catch((err: unknown) => $.ui.log(`aitools: could not save ${NOTES_FILE}: ${errorText(err)}`))
367}
368
369/** The Notes pane's Copy: the note as it stands, to the clipboard of the surface pressed on. */
370async function copyNotes($: EngineInterface, surface: Parameters<EngineInterface['ui']['copy']>[0]['surface']): Promise<void> {
371 const copied = await $.ui.copy({ text: latestNotes, surface })
372 $.ui.toast(copied.isCopied ? 'Note copied to the clipboard' : 'Copy failed')
373}
374
375/** The 🛠 menu's Handoff: writes the brief into the handoff pane, which has its own Copy button. */
376async function showHandoff($: EngineInterface): Promise<void> {
377 await update($, isToolsOpen, () => false)
378 if (await read($, isHandingOff)) {
379 $.ui.toast('Handoff already in progress.')
380 return
381 }
382 await update($, brief, () => ({ status: 'writing', text: '' }))
383 await $.ui.open({ id: HANDOFF_PANE, title: 'Handoff brief' })
384 const written = await writeBrief($)
385 if (written !== null) {
386 await update($, brief, () => written)
387 }
388}
389
390/** Copies the handoff pane's brief on the surface the press came from, and says whether it took. */
391async function copyBrief($: EngineInterface, surface: Parameters<EngineInterface['ui']['copy']>[0]['surface']): Promise<void> {
392 const shown = await read($, brief)
393 if (shown?.status !== 'ready') {
394 return
395 }
396 const copied = await $.ui.copy({ text: shown.text, surface })
397 $.ui.toast(copied.isCopied ? 'Handoff brief copied to the clipboard' : 'Copy failed')
398}
399
400async function toggleSetting($: EngineInterface, key: keyof typeof DEFAULT_SETTINGS): Promise<void> {
401 const changed = await update($, settings, s => {
402 const was = normalizeSettings(s)
403
404 return { ...was, [key]: !was[key] }
405 })
406 await saveSettings(storeOf($), normalizeSettings(changed))
407 if (normalizeSettings(changed)[key]) {
408 await catchUp($, key)
409 }
410}
411
412/**
413 * Brings a feature up to date once it is turned on, or the whole bar once it is shown again (`key` absent): the work
414 * skipped while it was off.
415 */
416async function catchUp($: EngineInterface, key?: ToggleKey): Promise<void> {
417 startScan($)
418 if ((key === undefined || COST_KEYS.includes(key)) && (await isShown($, COST_KEYS))) {
419 const cost = (await $.session.usage()).cost?.usd
420 if (cost !== undefined) {
421 await update($, snapshot, snap => ({ ...(snap ?? {}), threadUsd: cost }))
422 }
423 }
424 if (key === 'cacheWarmth') {
425 // Its time was not kept while it was off: unknown until the next request.
426 await update($, cacheAt, () => null)
427 }
428 if ((key === undefined || key === 'mascot') && (await isMascotOn($))) {
429 await syncMascot($)
430 }
431}
432/** The 🛠 menu's Task View and Clean View: sets the view, saves it for later sessions and closes the menu. */
433async function setView($: EngineInterface, mode: ViewMode): Promise<void> {
434 await update($, isToolsOpen, () => false)
435 await update($, viewMode, () => mode)
436 await $.store.set('viewMode', mode)
437 await showStatus($)
438 await startView($, mode)
439}
440
441/**
442 * What a view needs once it is on: the checklist tool offered to the model, and for Clean View the final replies so
443 * far (only kept while it is on).
444 */
445async function startView($: EngineInterface, mode: ViewMode): Promise<void> {
446 if (mode === 'off') {
447 return
448 }
449 await registerTool($, CHECKLIST_SPEC)
450 if (mode === 'clean') {
451 const earlier = finalReplies(await $.session.messages().catch(() => []))
452 await update($, finals, list => earlier.reduce((acc, text) => addFinal(acc, text), list))
453 }
454}
455
456/**
457 * The status line under the prompt: the view that is on and Subagents while a team is picked, comma separated; cleared
458 * when none. A Subagents pane open but not on screen (waiting for room) applies nothing, and says so; on Default the
459 * pane changes nothing, so the line leaves it out.
460 */
461async function showStatus($: EngineInterface): Promise<void> {
462 const mode = await read($, viewMode)
463 const parts: string[] = mode === 'off' ? [] : [VIEW_NAMES[mode]]
464 if (await isTeamPicked($)) {
465 const pane = (await $.ui.panes()).find(p => p.id === DOCK_PANE)
466 parts.push(pane?.isPlaced === false ? 'Subagents (not shown)' : 'Subagents')
467 }
468 $.ui.status(parts.length > 0 ? parts.join(', ') : undefined)
469}
470
471/** /taskview and /cleanview: toggles the view, or sets it with on/off, and says what is on now. */
472async function viewCommand($: EngineInterface, view: 'task' | 'clean', args: string): Promise<{ text: string }> {
473 const name = VIEW_NAMES[view]
474 const mode = nextView(await read($, viewMode), view, args)
475 if (mode === null) {
476 return { text: `Usage: /${view}view [on|off] (no argument toggles ${name})` }
477 }
478 await setView($, mode)
479
480 return { text: mode === 'off' ? `${name} off.` : `${VIEW_NAMES[mode]} on.` }
481}
482
483/** Redraws the dock's running times each second; with nothing running any more, the timer stops. */
484async function bumpDockTick($: EngineInterface): Promise<void> {
485 if (isRunning(await read($, agentRun))) {
486 await update($, dockTick, n => n + 1)
487 } else {
488 dockTimer?.cancel()
489 dockTimer = null
490 }
491}
492
493/** Starts the once-a-second redraw while the pane is open and a subagent runs; it stops itself once none does. */
494async function startDockTimer($: EngineInterface): Promise<void> {
495 if (dockTimer === null && (await read($, isDockOpen)) && isRunning(await read($, agentRun))) {
496 dockTimer = $.clock.every(1000, () => void bumpDockTick($))
497 }
498}
499
500/** Opens or closes the Subagents pane; closed, none of its settings apply. */
501async function setDockOpen($: EngineInterface, isOpen: boolean): Promise<void> {
502 await update($, isDockOpen, () => isOpen)
503 if (isOpen) {
504 await $.ui.open({ id: DOCK_PANE, title: 'Subagents' })
505 } else {
506 await $.ui.close({ id: DOCK_PANE })
507 }
508 await syncDock($)
509 await showStatus($)
510}
511
512/** Whether the Subagents pane is open with a team size picked: on Default, or closed, it changes nothing. */
513async function isTeamPicked($: EngineInterface): Promise<boolean> {
514 return (await read($, isDockOpen)) && (await read($, team)) !== 'default'
515}
516
517/**
518 * Runs the pane's work only while it is open: its once-a-second redraw timer while a subagent runs, and the progress
519 * tool its subagents report through.
520 */
521async function syncDock($: EngineInterface): Promise<void> {
522 if (!(await read($, isDockOpen))) {
523 dockTimer?.cancel()
524 dockTimer = null
525 return
526 }
527 await startDockTimer($)
528 await registerTool($, PROGRESS_SPEC)
529}
530
531/**
532 * The team size in effect: the pane is open with a size picked and on screen; null otherwise. A pane that went away
533 * turns it off; one open but not on screen applies nothing, which the status line says.
534 */
535async function activeTeam($: EngineInterface): Promise<TeamSize | null> {
536 if (!(await read($, isDockOpen))) {
537 return null
538 }
539 const pane = (await $.ui.panes()).find(p => p.id === DOCK_PANE)
540 if (pane === undefined) {
541 await update($, isDockOpen, () => false)
542 await syncDock($)
543 }
544 await showStatus($)
545 const pick = await read($, team)
546
547 return pick === 'default' || pane?.isPlaced !== true ? null : pick
548}
549
550/** /subagents: opens the pane if closed, closes it if open. */
551async function toggleDock($: EngineInterface): Promise<boolean> {
552 const isOpen = !(await read($, isDockOpen))
553 await setDockOpen($, isOpen)
554
555 return isOpen
556}
557
558/** The 🛠 menu's Subagents: opens the pane (its own close mark closes it), and closes the menu. */
559async function openDock($: EngineInterface): Promise<void> {
560 await update($, isToolsOpen, () => false)
561 await setDockOpen($, true)
562}
563
564/** The pane's team pick, saved for later sessions; Default stops its work, a size starts it. */
565async function setTeam($: EngineInterface, pick: TeamPick): Promise<void> {
566 await update($, team, () => pick)
567 await $.store.set('agentTeam', pick)
568 await syncDock($)
569 await showStatus($)
570}
571
572/** The dock's helper model, saved for later sessions. */
573async function setHelpers($: EngineInterface, mode: HelperMode): Promise<void> {
574 await update($, helpers, () => mode)
575 await $.store.set('agentHelpers', mode)
576}
577
578/** The pane's Showing pick, saved for later sessions. */
579async function setShowing($: EngineInterface, showing: AgentShowing): Promise<void> {
580 await update($, agentShowing, () => showing)
581 await $.store.set('agentShowing', showing)
582}
583
584/** The pane's Hide completed pick, saved for later sessions. */
585async function setHideAfter($: EngineInterface, minutes: HideAfter): Promise<void> {
586 await update($, hideAfter, () => minutes)
587 await $.store.set('agentHideAfter', minutes)
588}
589
590/** Appends one line to the Subagents pane trace in the store, the newest TRACE_KEEP kept, one write after another. */
591function traceDock($: EngineInterface, line: string): Promise<void> {
592 traceQueue = traceQueue.then(() => appendTrace($, line)).catch(() => undefined)
593
594 return traceQueue
595}
596
597/** Reads the trace, adds one timed line and writes it back; only ever run through traceDock's queue. */
598async function appendTrace($: EngineInterface, line: string): Promise<void> {
599 const at = new Date(await $.clock.now()).toISOString().slice(11, 19)
600 const trace = ((await $.store.get('dockTrace')) as string[] | undefined) ?? []
601 await $.store.set('dockTrace', [...trace, `${at} ${line}`].slice(-TRACE_KEEP))
602}
603
604/** Waits for `work`, or `ms` at most, whichever comes first. */
605function waitAtMost($: EngineInterface, work: Promise<unknown>, ms: number): Promise<void> {
606 return new Promise(resolve => {
607 $.clock.after(ms, () => resolve())
608 void work.then(() => resolve(), () => resolve())
609 })
610}
611
612/**
613 * Marks a subagent finished: a helper's place on the team frees up, and its line turns done or failed and moves down
614 * to the top of the finished ones.
615 */
616async function endHelper($: EngineInterface, id: string, isAnswered: boolean): Promise<void> {
617 liveHelpers.delete(id)
618 trackedAgents.delete(id)
619 const now = await $.clock.now()
620 await update($, agentRun, run => (run === null ? run : finishCard(run, id, isAnswered, now)))
621}
622
623/** Gives a subagent a line in the pane, once, while it is open; `kind` says how its line follows it. */
624async function trackAgent($: EngineInterface, id: string, task: string, kind: AgentKind): Promise<void> {
625 seenAgents.add(id)
626 if (kind !== 'helper') {
627 trackedAgents.add(id)
628 }
629 const now = await $.clock.now()
630 await update($, agentRun, r => addCard(r ?? newRun(now), id, task, now, kind))
631 await startDockTimer($)
632}
633
634/**
635 * A subagent loop the pane has no line for yet, at its first model request: one the engine lists (started before the
636 * pane opened, or by another plugin) gets its own description; one it does not list is a workflow's agent while a
637 * workflow has been started, else the engine's own fork, which is passed over.
638 */
639async function noticeAgent($: EngineInterface, id: string): Promise<void> {
640 seenAgents.add(id)
641 if (!(await read($, isDockOpen)) || (await read($, isHandingOff))) {
642 return
643 }
644 const listed = (await $.agent.list().catch(() => [])).find(a => a.id === id)
645 if (listed !== undefined) {
646 await trackAgent($, id, listed.description, 'subagent')
647 } else if (lastWorkflow !== null) {
648 workflowAgents += 1
649 await trackAgent($, id, `${lastWorkflow} · agent ${workflowAgents}`, 'workflow')
650 }
651}
652
653/** Marks done or failed any card whose helper the engine no longer has running, in case its finish was never heard. */
654async function settleCards($: EngineInterface): Promise<void> {
655 const run = await read($, agentRun)
656 if (!isRunning(run)) {
657 return
658 }
659 const agents = await $.agent.list().catch(() => null)
660 if (agents === null) {
661 return
662 }
663 // A helper the engine no longer lists has finished too; only one listed as running or pending is still at work. A
664 // workflow's agents are never listed: their own finish is the only word on them.
665 for (const card of run?.cards.filter(c => c.status === 'running' && c.kind !== 'workflow') ?? []) {
666 const status = agents.find(a => a.id === card.id)?.status ?? 'gone'
667 if (status !== 'running' && status !== 'pending') {
668 await traceDock($, `settled ${card.task}: ${status}`)
669 await endHelper($, card.id, status !== 'failed' && status !== 'killed')
670 }
671 }
672}
673
674
675/**
676 * Starts a subagent the team does not run: as Claude Code would, given a line in the pane while it is open and asked
677 * to report its progress there. Its start counts as in flight, so its first request finds the line rather than racing
678 * it.
679 */
680async function startSubagent(
681 $: EngineInterface,
682 e: Parameters<Events['agent.spawn']>[1],
683 next: Parameters<Events['agent.spawn']>[2],
684): Promise<Awaited<ReturnType<Events['agent.spawn']>>> {
685 if (!(await read($, isDockOpen))) {
686 return next(e)
687 }
688 if (await isTeamPicked($)) await traceDock($, `ignored ${e.description}: not a team helper`)
689 let settle = () => {}
690 const inFlight = new Promise<void>(resolve => (settle = resolve))
691 startsInFlight.add(inFlight)
692 try {
693 const started = await next({ ...e, prompt: e.prompt + SUBAGENT_NOTE })
694 if (started.agentId !== undefined) {
695 await trackAgent($, started.agentId, e.description, 'subagent')
696 }
697
698 return started
699 } finally {
700 startsInFlight.delete(inFlight)
701 settle()
702 }
703}
704
705/**
706 * Shows `pose`, playing its transition from the pose before first; `rest` is the pose a moment pose (ONE_SHOT_MS)
707 * gives way to. Showing the pose already up only changes what it gives way to.
708 */
709async function showMascot($: EngineInterface, pose: MascotPose, rest: MascotPose = pose): Promise<void> {
710 if (!(await isMascotOn($))) {
711 return
712 }
713 const now = await $.clock.now()
714 // Checked inside the update, so two calls at once (parallel tool calls) change the pose once.
715 let isChanged = false
716 const shown = await update($, mascot, m => {
717 isChanged = m.pose !== pose
718 return isChanged ? { pose, rest, from: m.pose, change: m.change + 1, seq: m.seq + 1, since: now } : { ...m, rest }
719 })
720 if (!isChanged) {
721 return
722 }
723 const { change } = shown
724 // Once the intro has played the source drops it, so a redraw can only ever restart the loop.
725 $.clock.after(introMs(pose, shown.from), () => void settleMascot($, change))
726 const hold = ONE_SHOT_MS[pose]
727 if (hold !== undefined) {
728 $.clock.after(hold, () => void endMoment($, change))
729 }
730}
731
732/** Drops the played intro from the mascot's source, unless the pose changed since. */
733async function settleMascot($: EngineInterface, change: number): Promise<void> {
734 await update($, mascot, m => (m.change === change && m.from !== null ? { ...m, from: null, seq: m.seq + 1 } : m))
735}
736
737/** A moment pose that is still up gives way to the pose underneath it. */
738async function endMoment($: EngineInterface, change: number): Promise<void> {
739 const shown = await read($, mascot)
740 if (shown.change === change) {
741 await showMascot($, shown.rest)
742 }
743}
744
745/** The pose underneath when no turn runs: puzzled while a question waits on the person, standing otherwise. */
746async function restingPose($: EngineInterface): Promise<MascotPose> {
747 return (await read($, asking)) > 0 || (await read($, isQuestionOpen)) ? 'puzzled' : 'idle'
748}
749
750/**
751 * Where the mascot should be when he is turned on: his poses were not followed while he was off, so the question
752 * left open is read back from the conversation.
753 */
754async function syncMascot($: EngineInterface): Promise<void> {
755 const isAsked = isQuestionOpenIn(await $.session.messages().catch(() => []))
756 await update($, isQuestionOpen, () => isAsked)
757 await showMascot($, isTurnRunning ? 'working' : await restingPose($))
758}
759
760/** Standing idle long enough, the mascot sits down and falls asleep; the person's next prompt wakes him. */
761async function checkSleep($: EngineInterface): Promise<void> {
762 const shown = await read($, mascot)
763 if (shown.pose === 'idle' && (await $.clock.now()) - shown.since >= SLEEP_AFTER_MS && (await isMascotOn($))) {
764 await showMascot($, 'sleeping')
765 }
766}
767
768/** Draws nothing in a transcript row's place: how Clean View hides tool calls and in-progress replies. */
769function drawNothing($: EngineInterface, e: Parameters<EngineInterface['ui']['resolve']>[0]) {
770 const { Box } = $.ui.resolve(e)
771
772 return <Box key="aitools-hidden" display="none" />
773}
774
775/** Opens or closes one menu above the bar; opening it closes the other, so only one row shows. */
776async function toggleMenu($: EngineInterface, menu: 'tools' | 'settings'): Promise<void> {
777 if (menu === 'tools') {
778 if (await update($, isToolsOpen, open => !open)) {
779 await update($, isSettingsOpen, () => false)
780 }
781 } else if (await update($, isSettingsOpen, open => !open)) {
782 await update($, isToolsOpen, () => false)
783 }
784}
785
786/** Every 30 s: countdowns redraw while the bar shows; the mascot may fall asleep. */
787async function onTick($: EngineInterface): Promise<void> {
788 if (!(await read($, isHidden))) {
789 await update($, tick, n => (n ?? 0) + 1)
790 }
791 await checkSleep($)
792}
793
794/** The mascot drawing last built: it only changes with the pose, so redraws of the bar reuse it. */
795let lastMascot: { state: MascotState; drawing: ReturnType<typeof mascotDrawing> } | null = null
796
797function drawMascot(state: MascotState): ReturnType<typeof mascotDrawing> {
798 if (lastMascot?.state.pose !== state.pose || lastMascot.state.from !== state.from || lastMascot.state.seq !== state.seq) {
799 lastMascot = { state, drawing: mascotDrawing(state) }
800 }
801
802 return lastMascot.drawing
803}
804
805/** The bar, with the 🛠 or ⁝ menu above it while one is open, for the AbovePrompt band. */
806async function drawBar($: EngineInterface, el: Parameters<typeof renderBar>[0], isWorking: boolean) {
807 await read($, tick)
808 const current = normalizeSettings(await read($, settings))
809
810 const view = {
811 snapshot: await read($, snapshot),
812 month: await read($, month),
813 projection: await read($, projection),
814 cacheAt: await read($, cacheAt),
815 settings: current,
816 now: await $.clock.now(),
817 }
818 const flags = {
819 isWorking,
820 isHandingOff: await read($, isHandingOff),
821 isToolsOpen: await read($, isToolsOpen),
822 isSettingsOpen: await read($, isSettingsOpen),
823 viewMode: await read($, viewMode),
824 // Read only where it is drawn and turned on, so no other bar redraws for the mascot.
825 mascot: el.Svg && current.mascot ? drawMascot(await read($, mascot)) : null,
826 }
827 const list =
828 flags.viewMode === 'off'
829 ? null
830 : renderChecklist(el, await read($, checklist), isWorking, () => void update($, checklist, () => null))
831
832 return renderBar(el, view, flags, {
833 toggleTools: () => void toggleMenu($, 'tools'),
834 handoff: () => void showHandoff($),
835 openNotes: () => void openNotes($),
836 toggleSettings: () => void toggleMenu($, 'settings'),
837 setView: mode => void setView($, mode),
838 openDock: () => void openDock($),
839 toggle: key => void toggleSetting($, key),
840 }, list)
841}
842
843async function measure($: EngineInterface, e: { rateLimits: readonly { kind: string; percentUsed: number; resetsAt?: string }[]; context: Snapshot['context'] & {}; cost?: { usd: number } }) {
844 const previous = await read($, snapshot)
845 // Off the subscription's ledger, the session's cost comes from the last scan, if a cost is shown at all.
846 const cache = e.cost || !(await isShown($, COST_KEYS)) ? null : await readCache($)
847 const fallback = cache ? sessionUsd(cache, await $.session.id()) : undefined
848 const snap: Snapshot = {
849 fiveHour: toWindow(e.rateLimits.find(r => r.kind === 'five_hour')) ?? previous?.fiveHour,
850 weekly: toWindow(e.rateLimits.find(r => r.kind === 'seven_day')) ?? previous?.weekly,
851 context: e.context.tokens === undefined ? (previous?.context ?? e.context) : e.context,
852 threadUsd: e.cost?.usd ?? fallback,
853 lastTurnUsd: previous?.lastTurnUsd,
854 threadTokens: previous?.threadTokens,
855 lastTurnTokens: previous?.lastTurnTokens,
856 }
857 await update($, snapshot, () => snap)
858 // Kept for the next session's first figures; only the usage limits are read back.
859 if (await isShown($, ['fiveHour', 'weekly'])) {
860 await $.store.set('lastSnapshot', snap)
861 }
862 // The projection samples the 5-hour usage only while it is shown: turned on, it has a projection again once two
863 // responses an hour apart or so have been sampled.
864 if (!(await isShown($, 'burnRate'))) {
865 await update($, projection, () => null)
866 return
867 }
868 const now = await $.clock.now()
869 let burn = ((await $.store.get('burn')) as BurnState | undefined) ?? EMPTY_BURN
870 if (snap.fiveHour) {
871 burn = addSample(burn, now, snap.fiveHour.percentUsed, snap.fiveHour.resetsAt)
872 await $.store.set('burn', burn)
873 }
874 await update($, projection, () => project(burn, now))
875}
876
877export const register: Register = on => {
878 on('session.start', async ($, e, next) => {
879 const loaded = await loadSettings(storeOf($))
880 await update($, settings, () => loaded)
881 // $.state outlives a hot reload: a snapshot already there is this session's, so keep it.
882 // A new session carries over only the limits; thread cost and context are its own.
883 if ((await read($, snapshot)) === null) {
884 const last = (await $.store.get('lastSnapshot')) as Snapshot | undefined
885 const usage = await $.session.usage()
886 const fresh: Snapshot = {
887 fiveHour: toWindow(usage.rateLimits.find(r => r.kind === 'five_hour')) ?? last?.fiveHour,
888 weekly: toWindow(usage.rateLimits.find(r => r.kind === 'seven_day')) ?? last?.weekly,
889 context: usage.context,
890 threadUsd: usage.cost?.usd,
891 }
892 await update($, snapshot, () => fresh)
893 }
894 const hidden = (await $.store.get('isHidden')) === true
895 await update($, isHidden, () => hidden)
896 if (await isShown($, 'cacheWarmth')) {
897 await restoreCacheAt($)
898 }
899 // The tick redraws countdowns while the bar shows, and checks whether the mascot has stood idle long enough to
900 // fall asleep.
901 $.clock.every(TICK_MS, () => void onTick($))
902 await $.command.register({ name: 'aitools', description: 'Show or hide the aitools bar', argumentHint: '[on|off]' })
903 const savedView = await $.store.get('viewMode')
904 const view: ViewMode = savedView === 'task' || savedView === 'clean' ? savedView : 'off'
905 await update($, viewMode, () => view)
906 await showStatus($)
907 // A load (a reload at a turn's end, a restart) sees no turn.complete for what came before: read it back, for
908 // what is on.
909 await startView($, view)
910 if (await isMascotOn($)) {
911 const isAsked = isQuestionOpenIn(await $.session.messages().catch(() => []))
912 await update($, isQuestionOpen, () => isAsked)
913 // A new session starts the mascot waving. A reload keeps his pose, but its timers went with the old module: a
914 // moment pose gives way now and a transition still playing is dropped.
915 const shown = await read($, mascot)
916 if (shown.change === 0) {
917 await showMascot($, 'wave', await restingPose($))
918 } else if (ONE_SHOT_MS[shown.pose] !== undefined) {
919 await showMascot($, shown.rest)
920 } else {
921 await settleMascot($, shown.change)
922 }
923 }
924 await $.command.register({ name: 'taskview', description: 'Turn Task View on or off', argumentHint: '[on|off]' })
925 await $.command.register({ name: 'cleanview', description: 'Turn Clean View on or off', argumentHint: '[on|off]' })
926 const savedTeam = await $.store.get('agentTeam')
927 const savedHelpers = await $.store.get('agentHelpers')
928 await update($, team, () => (isTeamPick(savedTeam) ? savedTeam : 'default'))
929 await update($, helpers, () => (savedHelpers === 'fast' ? 'fast' : 'same'))
930 const savedShowing = await $.store.get('agentShowing')
931 const savedHideAfter = await $.store.get('agentHideAfter')
932 await update($, agentShowing, () => (isShowing(savedShowing) ? savedShowing : 'all'))
933 await update($, hideAfter, () => (isHideAfter(savedHideAfter) ? savedHideAfter : DEFAULT_HIDE_AFTER))
934 await $.command.register({ name: 'subagents', description: 'Open or close the Subagents pane' })
935 // A reload starts the module's bookkeeping over: the running lines are the subagents still at work.
936 liveHelpers.clear()
937 for (const card of (await read($, agentRun))?.cards ?? []) {
938 seenAgents.add(card.id)
939 if (card.status === 'running' && (card.kind ?? 'helper') === 'helper') liveHelpers.add(card.id)
940 if (card.status === 'running' && card.kind !== undefined && card.kind !== 'helper') trackedAgents.add(card.id)
941 }
942 // A reload takes the dock pane down with the old module; the dock stays open until the person closes it.
943 if ((await read($, isDockOpen)) && !(await $.ui.panes()).some(p => p.id === DOCK_PANE)) {
944 await $.ui.open({ id: DOCK_PANE, title: 'Subagents' })
945 }
946 await syncDock($)
947 // The Notes pane went with the old module too: it opens again on the note as saved.
948 if ((await read($, isNotesOpen)) && !(await $.ui.panes()).some(p => p.id === NOTES_PANE)) {
949 await $.ui.open({ id: NOTES_PANE, title: 'Notes' })
950 await loadNotes($)
951 }
952 await $.command.register({ name: 'handoff', description: 'Print a handoff brief for a fresh session and copy it' })
953 startScan($)
954
955 return next(e)
956 })
957
958 on('turn.start', async ($, e, next) => {
959 isTurnRunning = true
960 if ((await read($, asking)) === 0) {
961 await showMascot($, 'working')
962 }
963 // The cost when the turn started: the last-turn cost's baseline, only while it is shown.
964 if (await isShown($, 'lastTurnCost')) {
965 const cost = (await $.session.usage()).cost?.usd
966 if (cost !== undefined) {
967 await update($, turnBaseline, () => ({ turnId: e.turnId, usd: cost }))
968 }
969 }
970
971 return next(e)
972 })
973
974 on('session.measure', async ($, e, next) => {
975 await measure($, e)
976
977 return next(e)
978 })
979
980 on('turn.step', async function* ($, e, next) {
981 if (e.agentId === undefined) {
982 if (await isShown($, 'cacheWarmth')) {
983 const at = await $.clock.now()
984 await update($, cacheAt, () => at)
985 // Persisting is a nicety (survives reloads); it must never hold up the model request.
986 await rememberCacheAt($, at).catch(() => undefined)
987 }
988 // The chat's effort is what Same as chat helpers run at: kept while a team is picked.
989 if (await isTeamPicked($)) {
990 await update($, mainEffort, () => e.effort ?? null)
991 }
992
993 return yield* next(e)
994 }
995 // A subagent: its first request can come before its start is recorded, so an unknown one waits briefly for the
996 // starts still in flight, then gets a line if it still has none.
997 if (!liveHelpers.has(e.agentId) && startsInFlight.size > 0) {
998 await waitAtMost($, Promise.all(startsInFlight), SPAWN_WAIT_MS)
999 }
1000 if (!seenAgents.has(e.agentId)) {
1001 await noticeAgent($, e.agentId)
1002 }
1003 // A team helper: every request it makes runs on the team's model and effort.
1004 if (!liveHelpers.has(e.agentId)) {
1005 return yield* next(e)
1006 }
1007 if ((await read($, helpers)) === 'fast') {
1008 return yield* next({ ...e, model: FAST_MODEL, effort: FAST_EFFORT })
1009 }
1010 const effort = await read($, mainEffort)
1011
1012 return yield* next(effort === null ? e : { ...e, effort })
1013 })
1014
1015 on('turn.complete', async ($, e, next) => {
1016 if (e.agentId !== undefined) {
1017 const id = e.agentId
1018 // Only a subagent with a line is the pane's business.
1019 if (liveHelpers.has(id) || ((await read($, agentRun))?.cards.some(c => c.id === id) ?? false)) {
1020 await traceDock($, `finished ${id} (${e.reason})`)
1021 await endHelper($, id, e.reason === 'answer')
1022 }
1023
1024 return next(e)
1025 }
1026 isTurnRunning = false
1027 if ((await read($, viewMode)) === 'clean') {
1028 await update($, finals, list => addFinal(list, e.answer))
1029 }
1030 if (await isMascotOn($)) {
1031 // A finished answer is celebrated, unless it asks something; a turn that failed or was interrupted startles him.
1032 const isAsked = e.reason === 'answer' && endsWithQuestion(e.answer)
1033 await update($, isQuestionOpen, () => isAsked)
1034 const rest = await restingPose($)
1035 await showMascot($, e.reason !== 'answer' ? 'error' : isAsked ? 'puzzled' : 'celebrate', rest)
1036 }
1037 const baseline = await read($, turnBaseline)
1038 const base = baseline?.turnId === e.turnId ? baseline.usd : undefined
1039 await update($, turnBaseline, () => null)
1040 if (e.usage !== undefined) {
1041 const lastTurnTokens = usageTokens(e.usage)
1042 await update($, snapshot, snap => ({ ...(snap ?? {}), lastTurnTokens }))
1043 }
1044 if (await isShown($, COST_KEYS)) {
1045 const cost = (await $.session.usage()).cost?.usd
1046 if (cost !== undefined) {
1047 const lastTurnUsd = base === undefined ? undefined : cost - base
1048 await update($, snapshot, snap => ({ ...(snap ?? {}), threadUsd: cost, lastTurnUsd }))
1049 }
1050 }
1051 startScan($)
1052 // Any subagent whose finish went unheard is settled once Claude replies.
1053 await settleCards($)
1054
1055 return next(e)
1056 })
1057
1058 on('command.run', { command: 'aitools' }, async ($, e) => {
1059 const hidden = nextHidden(e.args, await read($, isHidden))
1060 if (hidden === null) {
1061 return { text: 'Usage: /aitools [on|off] (no argument toggles the bar)' }
1062 }
1063 await update($, isHidden, () => hidden)
1064 await $.store.set('isHidden', hidden)
1065 if (!hidden) {
1066 await catchUp($)
1067 }
1068
1069 return { text: hidden ? 'aitools bar hidden. Run /aitools to show it.' : 'aitools bar shown.' }
1070 })
1071
1072 on('command.run', { command: 'taskview' }, ($, e) => viewCommand($, 'task', e.args))
1073 on('command.run', { command: 'cleanview' }, ($, e) => viewCommand($, 'clean', e.args))
1074
1075 // The brief is the command's output row: the chat renders it as markdown.
1076 on('command.run', { command: 'handoff' }, async $ => ({ text: await handoff($) }))
1077
1078 on('ui.render', { component: 'Pane', requestId: HANDOFF_PANE }, async ($, e) => {
1079 const { Box, Text, Button, Markdown } = $.ui.resolve(e)
1080 const shown = await read($, brief)
1081 if (shown === null || shown.status === 'writing') {
1082 return <Text dimColor>Writing the handoff brief…</Text>
1083 }
1084 if (shown.status === 'error') {
1085 return <Text color="red">{shown.text}</Text>
1086 }
1087
1088 return (
1089 <Box flexDirection="column" gap={1}>
1090 <Box flexDirection="row" justifyContent="flex-end">
1091 <Button key="copy" label="Copy" plain dimColor onPress={press => void copyBrief($, press.surface)} />
1092 </Box>
1093 <Markdown text={paneMarkdown(shown.text)} />
1094 </Box>
1095 )
1096 })
1097
1098 // The person's own prompt starts a new request, whatever is on: a fresh checklist (the Subagents pane keeps its
1099 // finished lines for the session). A helper's report or a task notice arrives as a prompt too, and starts nothing.
1100 // A view on, or the pane with a team picked, adds its note.
1101 on('prompt.submit', async ($, e, next) => {
1102 if (PERSON_ORIGINS.has(e.origin.kind)) {
1103 await update($, checklist, () => null)
1104 await update($, isQuestionOpen, () => false)
1105 // Where the pane's Current task agents start.
1106 if (await read($, isDockOpen)) {
1107 const at = await $.clock.now()
1108 await update($, requestAt, () => at)
1109 }
1110 }
1111 const notes: string[] = []
1112 const view = viewNote(await read($, viewMode))
1113 if (view !== null) notes.push(view)
1114 const size = await activeTeam($)
1115 if (size !== null) notes.push(dockNote(size))
1116
1117 return notes.length === 0 ? next(e) : next({ ...e, context: [...(e.context ?? []), ...notes] })
1118 })
1119
1120 // With a team picked, a helper the main chat starts runs on the team's model, reports progress, and waits its turn
1121 // when the team is full (refused with a note: a hook may not hold the start for long). Any other subagent started
1122 // while the pane is open gets a line too, following its tool calls and the progress it reports.
1123 on('agent.spawn', async ($, e, next) => {
1124 if (e.fork) {
1125 return next(e)
1126 }
1127 const size = e.parentAgentId === undefined ? await activeTeam($) : null
1128 if (size === null) {
1129 return startSubagent($, e, next)
1130 }
1131 const now = await $.clock.now()
1132 const model = (await read($, helpers)) === 'fast' ? FAST_MODEL : e.parentModel
1133 // Check and take a place with no `await` in between, so starts in the same message count each other.
1134 const working = liveHelpers.size + reservedPlaces
1135 if (working >= size) {
1136 await traceDock($, `queued ${e.description} (${working} of ${size} working)`)
1137 await update($, agentRun, r => queuePiece(r ?? newRun(now), e.description))
1138 return { deny: `Subagents: all ${size} helpers are busy. Start "${e.description}" again once one finishes.` }
1139 }
1140 reservedPlaces += 1
1141 let settle = () => {}
1142 const inFlight = new Promise<void>(resolve => (settle = resolve))
1143 startsInFlight.add(inFlight)
1144 try {
1145 const started = await next({ ...e, model, prompt: e.prompt + HELPER_NOTE })
1146 await traceDock($, `started ${e.description}: ${started.agentId ?? started.deny ?? 'no id'}`)
1147 if (started.agentId !== undefined) {
1148 const id = started.agentId
1149 liveHelpers.add(id)
1150 seenAgents.add(id)
1151 await update($, agentRun, r => addCard(r ?? newRun(now), id, e.description, now, 'helper'))
1152 await startDockTimer($)
1153 }
1154
1155 return started
1156 } finally {
1157 reservedPlaces -= 1
1158 startsInFlight.delete(inFlight)
1159 settle()
1160 }
1161 })
1162
1163 // A workflow the main chat starts: its agents' lines take its name.
1164 on('tool.call', { tool: 'Workflow' }, async ($, e, next) => {
1165 if (e.agentId === undefined) {
1166 lastWorkflow = workflowName(e as unknown as Record<string, unknown>)
1167 workflowAgents = 0
1168 }
1169
1170 return next(e)
1171 })
1172
1173 // A subagent's own report: answered here, so it never asks for permission.
1174 on('tool.call', { tool: PROGRESS_TOOL_ID }, async ($, e) => {
1175 const id = e.agentId
1176 if (id === undefined) {
1177 return { deny: 'Only subagents report progress.' }
1178 }
1179 const doing = typeof e.doing === 'string' ? e.doing : ''
1180 const percent = typeof e.percent === 'number' ? e.percent : 0
1181 await update($, agentRun, run => (run === null ? run : reportProgress(run, id, doing, percent)))
1182
1183 return { result: 'Progress noted.' }
1184 })
1185
1186 on('command.run', { command: 'subagents' }, async $ => {
1187 if (!(await toggleDock($))) {
1188 return { text: 'Subagents closed.' }
1189 }
1190 const size = await read($, team)
1191 if (size === 'default') {
1192 return { text: 'Subagents open: Default, subagents run as usual.' }
1193 }
1194
1195 return { text: `Subagents open: ${size} ${HELPER_LABELS[await read($, helpers)]} helpers.` }
1196 })
1197
1198 on('ui.render', { component: 'Pane', requestId: DOCK_PANE }, async ($, e) => {
1199 await read($, dockTick)
1200 // The 30-second tick keeps finished lines' `3m ago` current.src/agentdock.tsx 459 lines1import type { Elements } from 'claude-code'
2
3import type { AgentCard, AgentKind, AgentRun, AgentShowing, HelperMode, HideAfter, TeamPick, TeamSize } from '../types'
4import { BLUE, DOING_TEXT, FAINT_TEXT, progressBar } from './checklist'
5import { ago, elapsed } from './format'
6import { TOTEM_HEIGHT, TOTEM_WIDTH, totemAlt, totemSvg } from './totem'
7
8export const TEAM_SIZES = [3, 5, 10, 20, 30] as const
9/** The team picks the pane offers: Default first, which changes nothing about subagents, then the sizes. */
10export const TEAM_PICKS = ['default', ...TEAM_SIZES] as const
11/** How many finished subagents the pane keeps listing, newest first out of view last. */
12export const FINISHED_KEEP = 50
13/** Teams larger than this get a warning: they use Claude usage much faster. */
14export const LARGE_TEAM = 10
15export const HELPER_LABELS = { same: 'Same as chat', fast: 'Fast & cheap' } as const
16export const SHOWING_LABELS = { all: 'All', tool: 'Tool agents', current: 'Current task' } as const
17/** What the pane says while the Showing pick leaves it no lines. */
18export const EMPTY_LABELS = {
19 all: 'Workflow and tool subagents will appear here when created.',
20 tool: 'Tool subagents will appear here when created.',
21 current: 'Subagents for the current task will appear here when created.',
22} as const
23/** The Hide completed picks: never, then the longest wait to the shortest, in minutes. */
24export const HIDE_AFTER = ['never', 15, 5, 1, 0] as const
25export const DEFAULT_HIDE_AFTER: HideAfter = 'never'
26
27export function hideAfterLabel(minutes: HideAfter): string {
28 return minutes === 'never' ? 'Never' : minutes === 0 ? 'Immediate' : `${minutes}m`
29}
30
31export function isShowing(value: unknown): value is AgentShowing {
32 return value === 'all' || value === 'tool' || value === 'current'
33}
34
35export function isHideAfter(value: unknown): value is HideAfter {
36 return HIDE_AFTER.some(m => m === value)
37}
38
39/**
40 * The lines the pane's picks leave: workflow agents only under All, only those started since the last prompt under
41 * Current task (all of them before any prompt), and finished ones until Hide completed's time is up (if
42 * ever).
43 */
44export function visibleCards(
45 cards: readonly AgentCard[],
46 view: { showing: AgentShowing; hideAfter: HideAfter; requestAt: number | null; now: number },
47): AgentCard[] {
48 return cards.filter(
49 c =>
50 (view.showing !== 'tool' || c.kind !== 'workflow') &&
51 (view.showing !== 'current' || view.requestAt === null || c.startedAt >= view.requestAt) &&
52 (c.status === 'running' || view.hideAfter === 'never' || view.now - (c.endedAt ?? view.now) < view.hideAfter * 60_000),
53 )
54}
55/** What Fast & cheap helpers run on, and how hard they think. */
56export const FAST_MODEL = 'claude-sonnet-5-5'
57export const FAST_EFFORT = 'low'
58
59export const PROGRESS_TOOL = 'agent_progress'
60export const PROGRESS_TOOL_ID = 'mcp__aitools__agent_progress'
61export const DOCK_PANE = 'subagents'
62
63export const PROGRESS_SPEC = {
64 name: PROGRESS_TOOL,
65 description:
66 'For a subagent listed in the Subagents pane: reports what you are doing and how far along your task is, shown on ' +
67 'your line in the pane. Call it when you start, about every quarter of your work, and at 100 when done.',
68 inputSchema: {
69 type: 'object',
70 properties: {
71 doing: { type: 'string', description: 'What you are doing now, in a few plain words' },
72 percent: { type: 'number', minimum: 0, maximum: 100, description: 'Your estimate of how far along your task is' },
73 },
74 required: ['doing', 'percent'],
75 },
76}
77
78/** The hidden note the main prompt carries while the dock is open. */
79export function dockNote(team: TeamSize): string {
80 return (
81 `The Subagents pane is open: the person picked a team of ${team} helper agents for this request. ` +
82 'If the job has parts that can be done separately, split it into pieces and hand them to ' +
83 `up to ${team} helper agents at once with the Agent tool, starting several in one message so they run in ` +
84 'parallel. Give each a short description (it is the title of its card in the Subagents pane) and a ' +
85 'self-contained prompt. If a start is refused because the team is busy, start that piece again once a helper ' +
86 'finishes. When every helper is done, combine their results into one reply.'
87 )
88}
89
90/** How a subagent is asked to report, so its line can show a percent and bar. */
91const REPORT_ASK =
92 `Report progress with ${PROGRESS_TOOL_ID}: call it when you start with a few words on what you are doing and ` +
93 'percent 0, again about every quarter of your work, and with percent 100 when you finish.)'
94
95/** What each helper's prompt gains, so its card can show progress. */
96export const HELPER_NOTE = `\n\n(You are one helper on a Subagents team. ${REPORT_ASK}`
97
98/** What a tool agent's prompt gains while the pane is open, so its line can show progress too. */
99export const SUBAGENT_NOTE = `\n\n(Your work shows as a line in the person's Subagents pane. ${REPORT_ASK}`
100
101export function isTeamSize(n: unknown): n is TeamSize {
102 return TEAM_SIZES.some(size => size === n)
103}
104
105export function isTeamPick(n: unknown): n is TeamPick {
106 return n === 'default' || isTeamSize(n)
107}
108
109export function newRun(now: number): AgentRun {
110 return { startedAt: now, cards: [], queued: [] }
111}
112
113/** The run with a started helper's card, its piece no longer queued. */
114export function addCard(run: AgentRun, id: string, task: string, now: number, kind: AgentKind = 'helper'): AgentRun {
115 if (run.cards.some(c => c.id === id)) {
116 return run
117 }
118 const card: AgentCard = { id, task, kind, status: 'running', startedAt: now }
119
120 return { ...run, cards: [...run.cards, card], queued: run.queued.filter(q => q !== task) }
121}
122
123/** The run with a subagent's latest activity on its line; a helper's own progress report is left as it is. */
124export function noteActivity(run: AgentRun, id: string, doing: string): AgentRun {
125 return { ...run, cards: run.cards.map(c => (c.id === id && c.kind !== 'helper' && c.kind !== undefined ? { ...c, doing } : c)) }
126}
127
128/** The last part of a path: what a line names a file by. */
129function baseName(path: unknown): string {
130 return typeof path === 'string' ? (path.split('/').filter(Boolean).at(-1) ?? path) : ''
131}
132
133/** A short line on what a tool call does, for a subagent's line: `Reading bar.tsx`, `Running npm test`. */
134export function activityText(tool: string, input: Record<string, unknown>): string {
135 const clip = (text: unknown, n = 40) => {
136 const one = typeof text === 'string' ? text.replace(/\s+/g, ' ').trim() : ''
137 return one.length > n ? `${one.slice(0, n - 1)}…` : one
138 }
139 switch (tool) {
140 case 'Read':
141 return `Reading ${baseName(input.file_path)}`
142 case 'Edit':
143 case 'MultiEdit':
144 case 'NotebookEdit':
145 return `Editing ${baseName(input.file_path ?? input.notebook_path)}`
146 case 'Write':
147 return `Writing ${baseName(input.file_path)}`
148 case 'Bash':
149 return `Running ${clip(input.command)}`
150 case 'Grep':
151 return `Searching for ${clip(input.pattern, 30)}`
152 case 'Glob':
153 return `Finding ${clip(input.pattern, 30)}`
154 case 'WebFetch':
155 return `Fetching ${clip(typeof input.url === 'string' ? input.url.replace(/^https?:\/\//, '') : '', 30)}`
156 case 'WebSearch':
157 return `Searching the web for ${clip(input.query, 30)}`
158 case 'Agent':
159 return `Starting ${clip(input.description, 30) || 'a subagent'}`
160 default:
161 return `Using ${tool.replace(/^mcp__[^_]+__/, '')}`
162 }
163}
164
165/** A workflow's name, for its agents' lines: the tool's `name`, else its script's `meta` name. */
166export function workflowName(input: Record<string, unknown>): string {
167 if (typeof input.name === 'string' && input.name) {
168 return input.name
169 }
170 const script = typeof input.script === 'string' ? input.script : ''
171
172 return /\bname\s*:\s*['"`]([^'"`]+)['"`]/.exec(script)?.[1] ?? 'workflow'
173}
174
175/** The most characters a line's name and activity take together, ` · ` included. */
176export const LINE_MAX = 80
177/** The fewest characters worth showing of an activity; with less room it is left off. */
178const DOING_MIN = 10
179
180/**
181 * A line's name and activity cut to `max` characters together, the cut marked with `…`: the name first, then the
182 * activity in what is left; an activity with under DOING_MIN characters of room is left off.
183 */
184export function clipLine(task: string, doing?: string, max = LINE_MAX): { task: string; doing?: string } {
185 const clip = (text: string, n: number) => {
186 const chars = Array.from(text)
187 return chars.length > n ? `${chars.slice(0, n - 1).join('')}…` : text
188 }
189 const name = clip(task, max)
190 const room = max - Array.from(name).length - 3
191 if (!doing || room < DOING_MIN) {
192 return { task: name }
193 }
194
195 return { task: name, doing: clip(doing, room) }
196}
197
198/** The run with a refused piece waiting for a free helper (once per description). */
199export function queuePiece(run: AgentRun, task: string): AgentRun {
200 return run.queued.includes(task) ? run : { ...run, queued: [...run.queued, task] }
201}
202
203/** The run with one helper's reported progress; a percent is rounded and held to 0 to 100. */
204export function reportProgress(run: AgentRun, id: string, doing: string, percent: number): AgentRun {
205 const pct = Math.min(100, Math.max(0, Math.round(Number.isFinite(percent) ? percent : 0)))
206
207 const report = (c: AgentCard): AgentCard => ({ ...c, doing: doing.trim() || c.doing, percent: pct })
208
209 return { ...run, cards: run.cards.map(c => (c.id === id ? report(c) : c)) }
210}
211
212/** The run with a helper finished: done when it answered, failed otherwise. */
213export function finishCard(run: AgentRun, id: string, isAnswered: boolean, now: number): AgentRun {
214 const status: AgentCard['status'] = isAnswered ? 'done' : 'failed'
215 const cards = run.cards.map((c): AgentCard => (c.id === id ? { ...c, status, endedAt: now } : c))
216 // Finished lines stay for the session, the newest FINISHED_KEEP of them.
217 const finished = cards.filter(c => c.status !== 'running').sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))
218 const dropped = new Set(finished.slice(FINISHED_KEEP).map(c => c.id))
219
220 return { ...run, cards: cards.filter(c => !dropped.has(c.id)) }
221}
222
223export function isRunning(run: AgentRun | null): boolean {
224 return run !== null && run.cards.some(c => c.status === 'running')
225}
226
227/** The pane's header line: the team size when one is picked, then the non-zero counts. */
228export function headerText(run: AgentRun | null, team: TeamPick): string {
229 const cards = run?.cards ?? []
230 const working = cards.filter(c => c.status === 'running').length
231 const helpers = cards.filter(c => c.status === 'running' && (c.kind ?? 'helper') === 'helper').length
232 const done = cards.filter(c => c.status === 'done').length
233 const failed = cards.filter(c => c.status === 'failed').length
234 const parts = team === 'default' ? [] : [`${team} agents`]
235 const counts: [number, string][] = [
236 [working, 'working'],
237 [team === 'default' ? 0 : Math.max(0, team - helpers), 'idle'],
238 [run?.queued.length ?? 0, 'queued'],
239 [done, 'done'],
240 [failed, 'failed'],
241 ]
242 for (const [n, label] of counts) {
243 if (n > 0) parts.push(`${n} ${label}`)
244 }
245
246 return parts.join(' · ')
247}
248
249/** `Svg` only where the surface draws it (the desktop): the totem shows there alone. */
250type El = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'> & { Svg?: Elements['desktop']['Svg'] }
251
252const QUIET = { plain: true, dimColor: true } as const
253/** Task View's marks: ● running in blue, ✓ done (dim, as the rest of a finished line); a red × failed. */
254const CARD_MARKS = { running: '●', done: '✓', failed: '×' } as const
255
256/** The run's lines in order: running ones (oldest first), then finished ones, the most recently finished first. */
257export function orderedCards(run: AgentRun): AgentCard[] {
258 const running = run.cards.filter(c => c.status === 'running').sort((a, b) => a.startedAt - b.startedAt)
259 const finished = run.cards.filter(c => c.status !== 'running').sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))
260
261 return [...running, ...finished]
262}
263
264/**
265 * The dock pane: controls on top, a size warning, then the header (or what will appear when nothing is listed) and a
266 * line per subagent. Where SVG is drawn, the totem stands left of the controls, the header beside it too.
267 */
268export function renderDock(
269 el: El,
270 dock: {
271 team: TeamPick
272 helpers: HelperMode
273 showing: AgentShowing
274 hideAfter: HideAfter
275 requestAt: number | null
276 run: AgentRun | null
277 now: number
278 },
279 on: {
280 setTeam(pick: TeamPick): void
281 setHelpers(mode: HelperMode): void
282 setShowing(showing: AgentShowing): void
283 setHideAfter(minutes: HideAfter): void
284 },
285) {
286 const { Box, Text, Svg } = el
287 const { team, helpers, showing, hideAfter, run, now } = dock
288 const shown = run === null ? null : { ...run, cards: visibleCards(run.cards, { ...dock, now }) }
289 const isEmpty = shown === null || (shown.cards.length === 0 && shown.queued.length === 0)
290 const running = shown?.cards.filter(c => c.status === 'running').length ?? 0
291
292 return (
293 <Box flexDirection="column">
294 <Box flexDirection="row">
295 {Svg && (
296 <Box key="totem" flexShrink={0} marginRight={2}>
297 <Svg source={totemSvg(running)} alt={totemAlt(running)} width={TOTEM_WIDTH} height={TOTEM_HEIGHT} />
298 </Box>
299 )}
300 <Box flexDirection="column" flexShrink={1} gap={1}>
301 {renderControls(el, dock, on)}
302 {team !== 'default' && team > LARGE_TEAM && (
303 <Text color="yellow">{`⚠ A team of ${team} uses your Claude usage much faster.`}</Text>
304 )}
305 {isEmpty ? (
306 <Box flexDirection="column">
307 <Text dimColor>{EMPTY_LABELS[showing]}</Text>
308 {team !== 'default' && (
309 <Text dimColor>
310 {`Your next prompt can hand work to ${team} ${HELPER_LABELS[helpers]} helpers.`}
311 </Text>
312 )}
313 </Box>
314 ) : (
315 <Text dimColor>{headerText(shown, team)}</Text>
316 )}
317 </Box>
318 </Box>
319 {/* A blank line under the header, then the subagents running and the finished ones, the latest first. Queued
320 pieces get no line: the header counts them. */}
321 {shown !== null && shown.cards.length > 0 && (
322 <Box flexDirection="column" marginTop={1}>
323 {orderedCards(shown).map(card => renderCard(el, card, now))}
324 </Box>
325 )}
326 </Box>
327 )
328}
329
330/** The pane's four option rows: Showing, Hide completed, Set team size and Set model. */
331function renderControls(
332 el: El,
333 dock: { team: TeamPick; helpers: HelperMode; showing: AgentShowing; hideAfter: HideAfter },
334 on: {
335 setTeam(pick: TeamPick): void
336 setHelpers(mode: HelperMode): void
337 setShowing(showing: AgentShowing): void
338 setHideAfter(minutes: HideAfter): void
339 },
340) {
341 const { Box, Text } = el
342 const { team, helpers, showing, hideAfter } = dock
343
344 return (
345 <Box flexDirection="column">
346 <Box flexDirection="row" columnGap={1}>
347 <Text dimColor>Showing:</Text>
348 {(['all', 'tool', 'current'] as const).map(pick => (
349 renderPick(el, `showing-${pick}`, SHOWING_LABELS[pick], showing === pick, () => on.setShowing(pick))
350 ))}
351 </Box>
352 <Box flexDirection="row" columnGap={1}>
353 <Text dimColor>Hide completed:</Text>
354 {HIDE_AFTER.map(minutes => (
355 renderPick(el, `hide-${minutes}`, hideAfterLabel(minutes), hideAfter === minutes, () => on.setHideAfter(minutes))
356 ))}
357 </Box>
358 <Box flexDirection="row" columnGap={1}>
359 <Text dimColor>Set team size:</Text>
360 {TEAM_PICKS.map(pick => (
361 renderPick(el, `team-${pick}`, pick === 'default' ? 'Default' : String(pick), team === pick, () => on.setTeam(pick))
362 ))}
363 </Box>
364 {/* Set model only applies with a team: on Default it shows, faint and unpickable. */}
365 {team === 'default' ? (
366 <Box flexDirection="row" columnGap={1}>
367 <Text color={FAINT_TEXT}>Set model:</Text>
368 {(['same', 'fast'] as const).map(mode => (
369 <Text key={`helpers-${mode}`} color={FAINT_TEXT}>
370 {HELPER_LABELS[mode]}
371 </Text>
372 ))}
373 </Box>
374 ) : (
375 <Box flexDirection="row" columnGap={1}>
376 <Text dimColor>Set model:</Text>
377 {(['same', 'fast'] as const).map(mode =>
378 renderPick(el, `helpers-${mode}`, HELPER_LABELS[mode], helpers === mode, () => on.setHelpers(mode)),
379 )}
380 </Box>
381 )}
382 </Box>
383 )
384}
385
386/** Behind the picked choice: a Button takes no background, so a Box behind it carries one the white label reads on. */
387const PICKED_BACKGROUND = '#2e2e2c'
388
389/**
390 * One choice of an option row, as the bar's own buttons (plain, compact): the one picked at full strength on a grey
391 * ground, the rest dim. Pressing the picked one picks it again, changing nothing.
392 */
393function renderPick(el: El, key: string, label: string, isPicked: boolean, onPick: () => void) {
394 const { Box, Button } = el
395
396 return isPicked ? (
397 <Box key={`picked-${key}`} backgroundColor={PICKED_BACKGROUND}>
398 <Button key={key} label={label} plain dimColor={false} onPress={() => onPick()} />
399 </Box>
400 ) : (
401 <Button key={key} label={label} plain dimColor onPress={() => onPick()} />
402 )
403}
404
405/**
406 * One subagent's row. Running: a blue ●, its name in the half-dim grey and what it is doing faint, then (a helper, or
407 * a subagent once it reports progress) its percent and bar, and its time so far. Finished: all faint (a failed one's × red), and its time
408 * with how long ago it finished: `0:09 (3m ago)`.
409 */
410function renderCard(el: El, card: AgentCard, now: number) {
411 const { Box, Text } = el
412 const time = elapsed((card.endedAt ?? now) - card.startedAt)
413 if (card.status !== 'running') {
414 return (
415 <Box key={`agent-${card.id}`} flexDirection="row" justifyContent="space-between">
416 <Box flexDirection="row" flexShrink={1}>
417 <Text color={card.status === 'failed' ? 'red' : FAINT_TEXT}>{`${CARD_MARKS[card.status]} `}</Text>
418 <Text color={FAINT_TEXT} wrap="truncate-end">
419 {clipLine(card.task).task}
420 </Text>
421 </Box>
422 <Text color={FAINT_TEXT}>{` ${time} (${ago(now - (card.endedAt ?? now))})`}</Text>
423 </Box>
424 )
425 }
426 const percent = card.percent ?? 0
427 const bar = progressBar(percent)
428 const isMeasured = (card.kind ?? 'helper') === 'helper' || card.percent !== undefined
429 const line = clipLine(card.task, card.doing)
430
431 return (
432 <Box key={`agent-${card.id}`} flexDirection="row" justifyContent="space-between">
433 <Box flexDirection="row" flexShrink={1}>
434 <Text color={BLUE}>{`${CARD_MARKS.running} `}</Text>
435 <Text color={DOING_TEXT} wrap="truncate-end">
436 {line.task}
437 </Text>
438 {line.doing && (
439 <Text color={FAINT_TEXT} wrap="truncate-end">
440 {` · ${line.doing}`}
441 </Text>
442 )}
443 </Box>
444 <Box flexDirection="row" flexShrink={0}>
445 {isMeasured ? (
446 <Box flexDirection="row">
447 <Text dimColor>{` ${String(percent).padStart(3)}% `}</Text>
448 <Text color={BLUE}>{bar.filled}</Text>
449 <Text dimColor>{bar.empty}</Text>
450 </Box>
451 ) : (
452 <Text>{' '.repeat(6 + bar.filled.length + bar.empty.length)}</Text>
453 )}
454 <Text dimColor>{` ${time.padStart(5)}`}</Text>
455 </Box>
456 </Box>
457 )
458}
459src/bar.tsx 326 lines1import type { Elements } from 'claude-code'
2
3import { clockTime, duration, tokens, usd, weeklyReset } from './format'
4import { usdSince } from './transcripts'
5import { DOING_TEXT, VIEW_NAMES } from './checklist'
6import type { renderChecklist } from './checklist'
7import { MASCOT_HEIGHT, MASCOT_WIDTH } from './mascot'
8import { TOGGLES } from './settings'
9import type { MonthTotal, Settings, Snapshot, ToggleKey, ViewMode } from '../types'
10
11export type View = {
12 snapshot: Snapshot | null
13 month: MonthTotal
14 projection: number | null
15 /** Start of the last main-thread model request, or null before one. */
16 cacheAt: number | null
17 settings: Settings
18 now: number
19}
20
21export type Tone = 'warn' | 'danger' | undefined
22
23/** One metric: `text` is the whole of it, `label` the leading word(s) naming it, drawn dimmer than the value after. */
24export type Segment = { key: string; label: string; text: string; tone?: Tone }
25
26export const SEPARATOR = ' '
27/** Names the mod at the start of the bar; the `label` toggle hides it. */
28export const LABEL = 'AI Tools'
29
30/** Claude Code writes the main conversation's prompt cache with the 1-hour TTL; each request restarts it. */
31export const CACHE_TTL = 60 * 60_000
32
33const DASH = '—'
34const MIN = 60_000
35const CACHE_WARN = 10 * MIN
36const TONE_COLORS = { warn: 'yellow', danger: 'red' } as const
37/** Values in the checklist's half-dim grey: brighter than their dim labels, quieter than full text. */
38const VALUE_COLOR = DOING_TEXT
39
40export function toneFor(pct: number | undefined, settings: Settings): Tone {
41 if (!settings.thresholdColors || pct === undefined) {
42 return undefined
43 }
44
45 return pct >= 90 ? 'danger' : pct >= 70 ? 'warn' : undefined
46}
47
48function cacheSegment(cacheAt: number | null, now: number, settings: Settings): Segment {
49 if (cacheAt === null) {
50 return { key: 'cache', label: 'cache', text: `cache ${DASH}` }
51 }
52 const left = cacheAt + CACHE_TTL - now
53 const tone: Tone = !settings.thresholdColors ? undefined : left <= 0 ? 'danger' : left < CACHE_WARN ? 'warn' : undefined
54 const text = left <= 0 ? 'cache cold' : `cache ${Math.max(1, Math.ceil(left / MIN))}m`
55
56 return { key: 'cache', label: 'cache', text, tone }
57}
58
59function monthText(month: MonthTotal): string {
60 if (month.status === 'loading') {
61 return 'month …'
62 }
63
64 return month.status === 'error' ? 'month ?' : `month ${usd(month.usd, month.isEstimate)}`
65}
66
67const WEEK_MS = 7 * 24 * 60 * 60_000
68
69/**
70 * What 1% of the weekly allowance costs at API prices, estimated: this machine's spend since the weekly window opened
71 * (its reset time less 7 days) over the weekly percent used. Null until both are known and above zero.
72 */
73export function usdPerWeeklyPercent(snapshot: Snapshot | null, month: MonthTotal): number | null {
74 const weekly = snapshot?.weekly
75 if (!weekly?.resetsAt || !(weekly.percentUsed > 0) || month.hours === undefined) {
76 return null
77 }
78 const spent = usdSince(month.hours, Date.parse(weekly.resetsAt) - WEEK_MS)
79
80 return spent > 0 ? spent / weekly.percentUsed : null
81}
82
83/** A share of the weekly allowance, with as many decimals as small shares need: `12%`, `2.1%`, `0.04%`, `<0.01%`. */
84export function percentText(pct: number): string {
85 if (pct < 0.01) {
86 return '<0.01%'
87 }
88
89 return `${pct >= 10 ? Math.round(pct) : pct >= 1 ? pct.toFixed(1) : pct.toFixed(2)}%`
90}
91
92/** The thread segment: its cost and the last turn's, each followed by its estimated share of the week when asked. */
93function threadSegment(view: View): Segment | null {
94 const { snapshot, settings: on } = view
95 const thread = snapshot?.threadUsd
96 const last = snapshot?.lastTurnUsd
97 const perPercent = on.threadPercent ? usdPerWeeklyPercent(snapshot, view.month) : null
98 const share = (spent: number | undefined) =>
99 perPercent === null || spent === undefined ? null : `~${percentText(spent / perPercent)}`
100 const threadShare = share(thread)
101 const lastShare = on.lastTurnCost ? share(last) : null
102
103 if (on.threadCost) {
104 const turn = on.lastTurnCost && last !== undefined ? ` (+${usd(last)})` : ''
105 const pct = threadShare === null ? '' : ` ${threadShare} wk${lastShare === null ? '' : ` (+${lastShare.slice(1)})`}`
106 return { key: 'thread', label: 'thread', text: `thread ${thread === undefined ? DASH : usd(thread)}${turn}${pct}` }
107 }
108 if (on.lastTurnCost) {
109 return { key: 'thread', label: 'last', text: `last ${last === undefined ? DASH : usd(last)}${lastShare === null ? '' : ` ${lastShare} wk`}` }
110 }
111 if (threadShare !== null) {
112 return { key: 'thread', label: 'thread', text: `thread ${threadShare} wk` }
113 }
114
115 return null
116}
117
118/** The token segment: the thread's total and the last turn's in parentheses, as the cost segment shows dollars. */
119function tokenSegment(snapshot: Snapshot | null, on: Settings): Segment {
120 const count = (n: number | undefined) => (n === undefined ? DASH : tokens(n))
121 if (on.threadTokens) {
122 const turn = on.lastTurnTokens && snapshot?.lastTurnTokens !== undefined ? ` (+${tokens(snapshot.lastTurnTokens)})` : ''
123 return { key: 'tokens', label: 'tok', text: `tok ${count(snapshot?.threadTokens)}${turn}` }
124 }
125
126 return { key: 'tokens', label: 'last tok', text: `last tok ${count(snapshot?.lastTurnTokens)}` }
127}
128
129export function segments(view: View): Segment[] {
130 const { snapshot, settings: on, now } = view
131 const segs: Segment[] = []
132 const fiveHour = snapshot?.fiveHour
133 const weekly = snapshot?.weekly
134 const context = snapshot?.context
135 const pct = (n: number | undefined) => (n === undefined ? DASH : `${Math.round(n)}%`)
136
137 if (on.fiveHour) {
138 const reset = on.resets && fiveHour?.resetsAt ? `, ${duration(Date.parse(fiveHour.resetsAt) - now)}` : ''
139 segs.push({ key: 'fiveHour', label: '5h', text: `5h ${pct(fiveHour?.percentUsed)}${reset}`, tone: toneFor(fiveHour?.percentUsed, on) })
140 }
141 if (on.weekly) {
142 const reset = on.resets && weekly?.resetsAt ? `, ${weeklyReset(weekly.resetsAt, now)}` : ''
143 segs.push({ key: 'weekly', label: 'wk', text: `wk ${pct(weekly?.percentUsed)}${reset}`, tone: toneFor(weekly?.percentUsed, on) })
144 }
145 if (on.contextPercent || on.contextTokens) {
146 const parts: string[] = []
147 if (on.contextPercent) {
148 parts.push(pct(context?.percent))
149 }
150 if (on.contextTokens && context?.tokens !== undefined) {
151 parts.push(`${tokens(context.tokens)}/${tokens(context.window)}`)
152 } else if (on.contextTokens && !on.contextPercent) {
153 parts.push(DASH)
154 }
155 segs.push({ key: 'context', label: 'ctx', text: `ctx ${parts.join(' ')}`, tone: toneFor(context?.percent, on) })
156 }
157 if (on.threadTokens || on.lastTurnTokens) {
158 segs.push(tokenSegment(snapshot, on))
159 }
160 if (on.cacheWarmth) {
161 segs.push(cacheSegment(view.cacheAt, now, on))
162 }
163 const threadSeg = threadSegment(view)
164 if (threadSeg !== null) {
165 segs.push(threadSeg)
166 }
167 if (on.monthlyCost) {
168 segs.push({ key: 'month', label: 'month', text: monthText(view.month) })
169 }
170 if (on.burnRate && view.projection !== null) {
171 segs.push({ key: 'burn', label: 'limit', text: `limit ~${clockTime(view.projection)}` })
172 }
173
174 return segs
175}
176
177/** `Svg` only where the surface draws it (the desktop): the mascot and its option show there alone. */
178type El = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'> & { Svg?: Elements['desktop']['Svg'] }
179
180/** The 🛠 menu's view options; picking the one that is on turns it off. */
181const VIEWS = [
182 { mode: 'task', label: VIEW_NAMES.task },
183 { mode: 'clean', label: VIEW_NAMES.clean },
184] as const
185
186/** How many display options the ⁝ menu puts on one row. */
187const SETTINGS_PER_ROW = 5
188
189/** Quiet buttons, matching the other bands: dim text and no outline at rest, full strength under the pointer. */
190const QUIET = { plain: true, dimColor: true } as const
191
192/**
193 * The two menu buttons' labels. The terminal gets words: it lays a bare 🛠 out one column wide but paints it two,
194 * which pushes ⁝ under the icon's right half and both presses onto the icon.
195 */
196const MENU_LABELS = {
197 desktop: { tools: '🛠', settings: '⁝' },
198 terminal: { tools: 'tools', settings: 'opts' },
199} as const
200
201/** Room between neighboring buttons: the desktop's native chrome parts them, the terminal's plain words need spaces. */
202const BUTTON_GAP = { desktop: 0, terminal: 2 } as const
203
204export function renderBar(
205 el: El,
206 view: View,
207 flags: {
208 isWorking: boolean
209 isHandingOff: boolean
210 isToolsOpen: boolean
211 isSettingsOpen: boolean
212 viewMode: ViewMode
213 /** The mascot's drawing as it stands now. */
214 mascot: { source: string; alt: string } | null
215 },
216 on: {
217 toggleTools(): void
218 handoff(): void
219 openNotes(): void
220 setView(mode: ViewMode): void
221 openDock(): void
222 toggleSettings(): void
223 toggle(key: ToggleKey): void
224 },
225 checklist: ReturnType<typeof renderChecklist> = null,
226) {
227 const { Box, Text, Button, Svg } = el
228 const isHandoffIdle = !flags.isWorking && !flags.isHandingOff
229 const segs = segments(view)
230
231 const art = Svg && view.settings.mascot ? flags.mascot : null
232 const labels = Svg ? MENU_LABELS.desktop : MENU_LABELS.terminal
233 const gap = Svg ? BUTTON_GAP.desktop : BUTTON_GAP.terminal
234
235 // Segments wrap onto further rows when the band is narrow; the mascot and the buttons keep their places at the ends.
236 // Beside the mascot the row centers on him; without him the buttons stay on the first row of text.
237 const bar = (
238 <Box flexDirection="row" justifyContent="space-between" {...(art ? { alignItems: 'center' as const } : {})}>
239 {Svg && art && (
240 <Box key="mascot-art" flexShrink={0} marginRight={1}>
241 <Svg source={art.source} alt={art.alt} width={MASCOT_WIDTH} height={MASCOT_HEIGHT} />
242 </Box>
243 )}
244 <Box flexDirection="row" flexWrap="wrap" flexGrow={1} flexShrink={1}>
245 {view.settings.label && (
246 <Text key="label" dimColor>
247 {LABEL}
248 {segs.length > 0 ? SEPARATOR : ''}
249 </Text>
250 )}
251 {segs.map((s, i) => (
252 // Label and value in one row, so a narrow band wraps between metrics, never inside one.
253 <Box key={`segment-${s.key}`} flexDirection="row">
254 <Text dimColor>{`${s.label} `}</Text>
255 <Text color={s.tone ? TONE_COLORS[s.tone] : VALUE_COLOR}>
256 {s.text.slice(s.label.length + 1)}
257 {i < segs.length - 1 ? SEPARATOR : ''}
258 </Text>
259 </Box>
260 ))}
261 </Box>
262 <Box flexDirection="row" gap={gap} flexShrink={0}>
263 <Button key="tools" label={labels.tools} {...QUIET} onPress={() => on.toggleTools()} />
264 <Button key="settings" label={labels.settings} {...QUIET} onPress={() => on.toggleSettings()} />
265 </Box>
266 </Box>
267 )
268
269 let menu = null
270 if (flags.isSettingsOpen) {
271 // At most SETTINGS_PER_ROW options to a row; a narrow band still wraps a row further.
272 // The desktop's own options (Mascot) lead the first row, on top of its usual count.
273 const shared = TOGGLES.filter(t => !t.needsSvg)
274 const rows = Array.from({ length: Math.ceil(shared.length / SETTINGS_PER_ROW) }, (_, i) =>
275 shared.slice(i * SETTINGS_PER_ROW, (i + 1) * SETTINGS_PER_ROW),
276 )
277 if (Svg) {
278 rows[0] = [...TOGGLES.filter(t => t.needsSvg), ...(rows[0] ?? [])]
279 }
280 menu = (
281 <Box flexDirection="column">
282 {rows.map((row, i) => (
283 <Box key={`settings-row-${i}`} flexDirection="row" flexWrap="wrap" justifyContent="flex-end" columnGap={2}>
284 {row.map(t => (
285 <Button key={t.key} label={`${view.settings[t.key] ? '●' : '○'} ${t.label}`} {...QUIET} onPress={() => on.toggle(t.key)} />
286 ))}
287 </Box>
288 ))}
289 </Box>
290 )
291 } else if (flags.isToolsOpen) {
292 menu = (
293 <Box flexDirection="row" justifyContent="flex-end" gap={gap}>
294 <Button
295 key="handoff"
296 label={flags.isHandingOff ? 'Handoff…' : 'Handoff'}
297 {...QUIET}
298 onPress={() => isHandoffIdle && on.handoff()}
299 />
300 <Button key="notes" label="Notes" {...QUIET} onPress={() => on.openNotes()} />
301 <Button key="subagents" label="Subagents" {...QUIET} onPress={() => on.openDock()} />
302 {VIEWS.map(v => (
303 <Button
304 key={v.mode}
305 label={`${flags.viewMode === v.mode ? '●' : '○'} ${v.label}`}
306 {...QUIET}
307 onPress={() => on.setView(flags.viewMode === v.mode ? 'off' : v.mode)}
308 />
309 ))}
310 </Box>
311 )
312 }
313 if (!menu && !checklist) {
314 return bar
315 }
316
317 // An open menu (🛠 or ⁝) sits above the bar and the checklist below it, so the bar itself never shifts.
318 return (
319 <Box flexDirection="column" gap={1}>
320 {menu}
321 {bar}
322 {checklist}
323 </Box>
324 )
325}
326src/checklist.tsx 237 lines1import type { Elements } from 'claude-code'
2
3import type { Checklist, ChecklistItem, ViewMode } from '../types'
4
5/** The checklist tool's short name; the model calls it as `mcp__aitools__checklist`. */
6export const CHECKLIST_TOOL = 'checklist'
7export const CHECKLIST_TOOL_ID = 'mcp__aitools__checklist'
8
9const STATUSES = ['todo', 'doing', 'done'] as const
10
11export const CHECKLIST_SPEC = {
12 name: CHECKLIST_TOOL,
13 description:
14 'Shows the person your plan and progress as a checklist above their prompt. Use it only when a system note says ' +
15 'progress reporting is on. Send the whole list every call: a short title for the task and each step in plain ' +
16 'language (an outcome, never a tool name, file path or code).',
17 inputSchema: {
18 type: 'object',
19 properties: {
20 title: { type: 'string', description: 'What the task is, in a few words' },
21 items: {
22 type: 'array',
23 items: {
24 type: 'object',
25 properties: {
26 text: { type: 'string', description: 'One step, in plain language' },
27 status: { type: 'string', enum: [...STATUSES] },
28 percent: {
29 type: 'number',
30 minimum: 0,
31 maximum: 100,
32 description: 'For the step in progress: your estimate of how far along it is',
33 },
34 },
35 required: ['text', 'status'],
36 },
37 },
38 },
39 required: ['title', 'items'],
40 },
41}
42
43const TASK_NOTE =
44 `Progress reporting is on: the person follows your work through a checklist drawn from ${CHECKLIST_TOOL_ID}. ` +
45 'If the request needs more than a quick answer, call it before you start with a short title and your plan as ' +
46 '3 to 8 plain-language steps (outcomes, not tool names or code). Call it again with the whole list each time a ' +
47 'step starts (status "doing") or finishes ("done"), with one step doing at a time; add, drop or reword steps when ' +
48 'the plan changes. Give the step in progress a percent, your estimate of how far along it is, and send the list ' +
49 'again as it moves (roughly every quarter of the step) so its progress bar keeps up. Mark every step done before ' +
50 'your final reply. Skip the checklist for a quick question that needs no work.'
51
52const CLEAN_NOTE =
53 ' Clean View is on: the person sees only the checklist and your final reply, not your tool calls or anything you ' +
54 'write before it. Do not narrate between steps; put what they need in a brief final reply.'
55
56/** The views' names as the menu, the commands and their replies spell them. */
57export const VIEW_NAMES = { task: 'Task View', clean: 'Clean View' } as const
58
59/**
60 * The view after `/taskview` or `/cleanview` with `args`: no argument toggles that view (turning it on replaces the
61 * other), `on` sets it, `off` turns it off if it is the one on; null for anything else.
62 */
63export function nextView(current: ViewMode, view: 'task' | 'clean', args: string): ViewMode | null {
64 const word = args.trim().toLowerCase()
65 if (word === '') {
66 return current === view ? 'off' : view
67 }
68 if (word === 'on') {
69 return view
70 }
71 if (word === 'off') {
72 return current === view ? 'off' : current
73 }
74
75 return null
76}
77
78/** The hidden note a prompt carries while a view is on; null when off. */
79export function viewNote(mode: ViewMode): string | null {
80 return mode === 'off' ? null : mode === 'clean' ? TASK_NOTE + CLEAN_NOTE : TASK_NOTE
81}
82
83/** The checklist tool's input as a Checklist, or the reason it is not one. */
84export function parseChecklist(input: unknown): Checklist | string {
85 const given = input !== null && typeof input === 'object' ? (input as Record<string, unknown>) : {}
86 const title = typeof given.title === 'string' ? given.title.trim() : ''
87 if (!title) {
88 return 'title must be a non-empty string'
89 }
90 if (!Array.isArray(given.items)) {
91 return 'items must be an array of { text, status }'
92 }
93 const items: ChecklistItem[] = []
94 for (const raw of given.items as unknown[]) {
95 const item = raw !== null && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}
96 const text = typeof item.text === 'string' ? item.text.trim() : ''
97 const status = STATUSES.find(s => s === item.status)
98 if (!text || !status) {
99 return 'each item needs a non-empty text and a status of todo, doing or done'
100 }
101 const isPercent = typeof item.percent === 'number' && Number.isFinite(item.percent)
102 const percent = isPercent ? clampPercent(item.percent as number) : undefined
103 items.push(percent === undefined ? { text, status } : { text, status, percent })
104 }
105
106 return { title, items }
107}
108
109function clampPercent(n: number): number {
110 return Math.min(100, Math.max(0, Math.round(n)))
111}
112
113/** How far along a step's bar is: full once done, empty before it starts, the model's estimate in between. */
114export function stepPercent(item: ChecklistItem): number {
115 return item.status === 'done' ? 100 : item.status === 'todo' ? 0 : (item.percent ?? 0)
116}
117
118/** Cells in a step's progress bar. */
119export const BAR_CELLS = 6
120
121/** A step's progress bar: filled cells for the share done, light cells for the rest. */
122export function progressBar(percent: number, cells = BAR_CELLS): { filled: string; empty: string } {
123 const n = Math.round((clampPercent(percent) / 100) * cells)
124
125 return { filled: '▰'.repeat(n), empty: '▱'.repeat(cells - n) }
126}
127
128/** Whether Clean View shows an assistant text block: only one that is part of a finished turn's final reply. */
129export function isFinalReply(text: string, finals: readonly string[]): boolean {
130 const block = text.trim()
131
132 return block.length > 0 && finals.some(final => final.includes(block))
133}
134
135/** How many final replies Clean View keeps to match against. */
136export const FINALS_KEEP = 200
137
138/** Keeps the newest finals, so the list Clean View matches against stays small. */
139export function addFinal(finals: readonly string[], answer: string, keep = FINALS_KEEP): string[] {
140 const text = answer.trim()
141
142 return text && !finals.includes(text) ? [...finals, text].slice(-keep) : [...finals]
143}
144
145export type Row = { role: 'user' | 'assistant'; text: string; toolResults?: readonly unknown[] }
146
147/**
148 * Each request's final reply in a conversation: the last assistant text before the next prompt the person sent (a
149 * user row carrying text and no tool results), and the conversation's last assistant text. Read when the mod loads,
150 * so replies from turns that ended before it (a reload, a restart) still show in Clean View.
151 */
152export function finalReplies(rows: readonly Row[]): string[] {
153 const finals: string[] = []
154 let last = ''
155 for (const row of rows) {
156 if (row.role === 'assistant') {
157 last = row.text.trim() || last
158 } else if (row.text.trim() && !row.toolResults?.length) {
159 if (last) finals.push(last)
160 last = ''
161 }
162 }
163 if (last) finals.push(last)
164
165 return finals
166}
167
168type El = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
169
170const MARKS = { done: '✓', doing: '●', todo: '○' } as const
171/** Done marks green, the step in progress blue (the mod's own shades); steps not started stay dim. */
172export const GREEN = '#6fbe49'
173export const BLUE = '#478487'
174const MARK_COLORS = { done: GREEN, doing: BLUE, todo: undefined } as const
175/** The step in progress: a grey between dim and full white, so it stands out without glaring. */
176export const DOING_TEXT = '#b0b0b0'
177/**
178 * The bar's dim grey, named: the desktop draws `dimColor` in a pane nearly as light as DOING_TEXT, so pane text that
179 * must read as faint as the bar's labels uses this instead.
180 */
181export const FAINT_TEXT = '#808080'
182
183/** The checklist under the bar: the steps while work goes on, one line once every step is done. */
184/** `onClose` clears the checklist: the × at the end of the folded Done line. */
185export function renderChecklist(el: El, list: Checklist | null, isWorking: boolean, onClose: () => void) {
186 const { Box, Text, Button } = el
187 if (list === null) {
188 return null
189 }
190 const done = list.items.filter(i => i.status === 'done').length
191 if (!isWorking && list.items.length > 0 && done === list.items.length) {
192 const steps = `${list.items.length} step${list.items.length === 1 ? '' : 's'}`
193 return (
194 <Box flexDirection="row" justifyContent="space-between">
195 <Box flexDirection="row" flexShrink={1}>
196 <Text color={MARK_COLORS.done}>{`${MARKS.done} `}</Text>
197 <Text dimColor wrap="truncate-end">{`Done: ${list.title} (${steps})`}</Text>
198 </Box>
199 <Button key="checklist-close" label="×" plain dimColor onPress={() => onClose()} />
200 </Box>
201 )
202 }
203
204 return (
205 <Box flexDirection="column">
206 <Box flexDirection="row" justifyContent="space-between">
207 <Text bold>{list.title}</Text>
208 <Text dimColor>{`${done} of ${list.items.length}`}</Text>
209 </Box>
210 {list.items.map((item, i) => {
211 const percent = stepPercent(item)
212 const bar = progressBar(percent)
213
214 return (
215 <Box key={`item-${i}`} flexDirection="row" justifyContent="space-between">
216 <Box flexDirection="row" flexShrink={1}>
217 <Text color={MARK_COLORS[item.status]} dimColor={item.status === 'todo'}>{`${MARKS[item.status]} `}</Text>
218 <Text
219 color={item.status === 'doing' ? DOING_TEXT : undefined}
220 dimColor={item.status !== 'doing'}
221 wrap="truncate-end"
222 >
223 {item.text}
224 </Text>
225 </Box>
226 <Box flexDirection="row" flexShrink={0}>
227 <Text key="pct" dimColor>{`${String(percent).padStart(3)}% `}</Text>
228 <Text key="filled" color={MARK_COLORS[item.status]} dimColor={item.status !== 'doing'}>{bar.filled}</Text>
229 <Text key="empty" dimColor>{bar.empty}</Text>
230 </Box>
231 </Box>
232 )
233 })}
234 </Box>
235 )
236}
237src/burnrate.ts 46 lines1const MIN = 60_000
2const WINDOW = 60 * MIN
3const MIN_SPAN = 10 * MIN
4
5export type Sample = { t: number; pct: number }
6
7export type BurnState = { resetsAt: string | null; samples: Sample[] }
8
9export const EMPTY_BURN: BurnState = { resetsAt: null, samples: [] }
10
11export function addSample(s: BurnState, t: number, pct: number, resetsAt?: string): BurnState {
12 const window = resetsAt ?? null
13 const samples = window === s.resetsAt ? s.samples : []
14 if (samples.at(-1)?.pct === pct) {
15 return { resetsAt: window, samples }
16 }
17
18 return { resetsAt: window, samples: [...samples, { t, pct }].filter(x => x.t >= t - WINDOW) }
19}
20
21/** When the 5-hour window hits 100% at the recent pace, or null when that is not before the reset. */
22export function project(s: BurnState, now: number): number | null {
23 const samples = s.samples.filter(x => x.t >= now - WINDOW)
24 const first = samples[0]
25 const last = samples.at(-1)
26 if (!first || !last || samples.length < 2 || last.t - first.t < MIN_SPAN) {
27 return null
28 }
29
30 const meanT = samples.reduce((sum, x) => sum + x.t, 0) / samples.length
31 const meanP = samples.reduce((sum, x) => sum + x.pct, 0) / samples.length
32 const covariance = samples.reduce((sum, x) => sum + (x.t - meanT) * (x.pct - meanP), 0)
33 const variance = samples.reduce((sum, x) => sum + (x.t - meanT) ** 2, 0)
34 const slope = covariance / variance
35 if (!(slope > 0)) {
36 return null
37 }
38
39 const eta = Math.round(now + (100 - last.pct) / slope)
40 if (s.resetsAt && eta >= Date.parse(s.resetsAt)) {
41 return null
42 }
43
44 return eta
45}
46src/handoff.ts 24 lines1export const HANDOFF_PROMPT =
2 'Write a handoff brief so a fresh session with no memory of this conversation can continue the work. ' +
3 'Use markdown with exactly these sections: ## Goal, ## Current state, ## Decisions made, ## Open tasks, ' +
4 '## Key files, ## Next step. Be specific: name files, functions, commands and unresolved questions. ' +
5 'Output only the brief.'
6
7/**
8 * The chat row for a brief, which the chat renders as markdown. The title line comes first because the engine
9 * prefixes the row with the plugin's name, which would otherwise swallow the first heading.
10 */
11export function handoffOutput(brief: string): string {
12 return `Handoff brief\n\n${brief}`
13}
14
15/** The most a Markdown element draws; a longer brief is cut in the pane, while Copy still takes all of it. */
16export const MARKDOWN_MAX = 10_000
17
18const CUT_NOTE = '\n\n_(Cut to fit the pane; Copy takes the whole brief.)_'
19
20/** The brief as the handoff pane draws it, cut to fit the Markdown element's limit. */
21export function paneMarkdown(brief: string): string {
22 return brief.length <= MARKDOWN_MAX ? brief : brief.slice(0, MARKDOWN_MAX - CUT_NOTE.length) + CUT_NOTE
23}
24src/mascot.ts 515 lines1/**
2 * Clawd, the Claude Code mascot, drawn at the start of the bar on surfaces that draw SVG (the desktop Code tab).
3 *
4 * A 28 x 12 pixel grid, of which the 24 x 10 his poses use is shown, at 2 screen pixels per grid pixel (48 x 20 px).
5 * Every pose is one SVG animated with SMIL; a change of pose draws the new pose's SVG with a short intro first (walking
6 * over, sitting down, waking up), then its loop. Clawd is drawn on a 24-wide frame shifted right by CX, so he stands
7 * centered; a posture's `x` steps him aside.
8 */
9
10import type { MascotPose, MascotState } from '../types'
11import type { Row } from './checklist'
12
13/**
14 * The part of the grid shown: no pose draws in its first 3 columns or last one, and the top rows held nothing but
15 * effects above his head (moved down beside him).
16 */
17const LEFT = 3
18const TOP = 2
19const W = 24
20const H = 10
21const CX = 2
22
23/** CSS pixels the mascot takes in the bar. */
24export const MASCOT_WIDTH = W * 2
25export const MASCOT_HEIGHT = H * 2
26
27export const O = '#D77757' // Claude orange
28export const D = '#141413' // eyes
29const BEZEL = '#3B3B39'
30const DECK = '#9A9893'
31const DECK_EDGE = '#5C5C58'
32const DISPLAY = '#22384F'
33const PAGE = '#F6F0E4'
34const PAGE_EDGE = '#CFC4AF'
35const INK = '#B3A68E'
36const INK_DARK = '#6E6352'
37export const ZZZ = '#8E96B8'
38const SPARK = '#F2C14E'
39const RED = '#E0524C'
40const SWEAT = '#7CC4E8'
41
42type Eyes = 'open' | 'half' | 'closed' | 'squint' | 'happy' | 'wide'
43type Arms = 'side' | 'typeA' | 'typeB' | 'scratchA' | 'scratchB' | 'up' | 'rest' | 'waveA' | 'waveB' | 'hold'
44
45/**
46 * One frame of Clawd: body offset (y up is negative), step aside (x), legs (false while sitting, A or B mid-stride),
47 * eyes (with a look offset) and arms.
48 */
49type Posture = { y?: number; x?: number; legs?: boolean | 'A' | 'B'; eyes: Eyes; look?: number; lookY?: number; arms: Arms }
50
51export const rect = (x: number, y: number, w: number, h: number, c: string, extra = '') =>
52 `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${c}"${extra}/>`
53const dots = (pts: [number, number][], c: string) => pts.map(([x, y]) => rect(x, y, 1, 1, c)).join('')
54
55function arms(a: Arms, ay: number): string {
56 switch (a) {
57 case 'side':
58 return rect(5, ay, 2, 1, O) + rect(17, ay, 2, 1, O)
59 // Typing: the right arm reaches down to the keyboard beside him, tapping.
60 case 'typeA':
61 return rect(5, ay, 2, 1, O) + dots([[17, ay], [18, ay + 1], [19, ay + 2]], O)
62 case 'typeB':
63 return rect(5, ay, 2, 1, O) + dots([[17, ay], [18, ay + 1], [19, ay + 1]], O)
64 case 'scratchA':
65 return rect(5, ay, 2, 1, O) + dots([[17, ay], [18, ay - 1], [18, ay - 2], [17, ay - 3]], O)
66 case 'scratchB':
67 return rect(5, ay, 2, 1, O) + dots([[17, ay], [18, ay - 1], [18, ay - 2], [17, ay - 3], [16, ay - 3]], O)
68 case 'up':
69 return dots([[6, ay - 1], [5, ay - 2], [5, ay - 3], [17, ay - 1], [18, ay - 2], [18, ay - 3]], O)
70 case 'rest':
71 return rect(6, ay + 2, 1, 1, O) + rect(17, ay + 2, 1, 1, O)
72 case 'waveA':
73 return rect(5, ay, 2, 1, O) + dots([[17, ay - 1], [18, ay - 2], [19, ay - 3]], O)
74 case 'waveB':
75 return rect(5, ay, 2, 1, O) + dots([[17, ay - 1], [18, ay - 2], [18, ay - 3], [17, ay - 3]], O)
76 // Holding a sheet up beside him with the right arm.
77 case 'hold':
78 return rect(5, ay, 2, 1, O) + dots([[17, ay], [18, ay - 1]], O)
79 }
80}
81
82function eyes(p: Posture, ey: number): string {
83 const dx = p.look ?? 0
84 const dy = p.lookY ?? 0
85 switch (p.eyes) {
86 case 'open':
87 return rect(9 + dx, ey + dy, 1, 2, D) + rect(14 + dx, ey + dy, 1, 2, D)
88 case 'half':
89 return rect(9 + dx, ey + 1 + dy, 1, 1, D) + rect(14 + dx, ey + 1 + dy, 1, 1, D)
90 case 'closed':
91 return rect(8, ey + 1, 2, 1, D) + rect(14, ey + 1, 2, 1, D)
92 case 'squint':
93 return rect(10, ey, 1, 2, D) + rect(14, ey + 1, 2, 1, D)
94 case 'happy':
95 return dots([[8, ey + 1], [9, ey], [10, ey + 1], [13, ey + 1], [14, ey], [15, ey + 1]], D)
96 case 'wide':
97 return rect(9, ey, 2, 2, D) + rect(13, ey, 2, 2, D)
98 }
99}
100
101/** Clawd in one posture: body 10 x 5, arms on the middle row, eyes 1 x 2, four legs 2 tall. */
102export function figure(p: Posture): string {
103 const y = p.y ?? 0
104 let s = rect(7, 5 + y, 10, 5, O)
105 if (p.legs !== false) {
106 // Mid-stride, one pair of legs is lifted a pixel.
107 s += [8, 10, 13, 15].map((x, i) => rect(x, 10 + y, 1, p.legs === (i % 2 === 0 ? 'A' : 'B') ? 1 : 2, O)).join('')
108 }
109 s += arms(p.arms, 7 + y) + eyes(p, 6 + y)
110
111 return `<g transform="translate(${CX + (p.x ?? 0)} 0)">${s}</g>`
112}
113
114// ---- props: things around Clawd, each with its own loop starting at `t` ms ----
115
116const ms = (n: number) => `${Math.round(n)}ms`
117
118/**
119 * Lines that type themselves out on a display, one after another, then clear: each row grows a pixel at a time.
120 * `rows` are [x, y, width, color]; one cycle lasts `cycle` ms from `t`.
121 */
122function typedLines(rows: [number, number, number, string][], t: number, cycle: number): string {
123 return rows
124 .map(([x, y, w, c], i) => {
125 const start = i * 0.17
126 const values: string[] = []
127 const times: string[] = []
128 if (start > 0) {
129 values.push('0')
130 times.push('0')
131 }
132 for (let n = 1; n <= w; n++) {
133 values.push(String(n))
134 times.push((start + (n - 1) * 0.05).toFixed(3))
135 }
136 values.push('0')
137 times.push('0.92')
138 return `<rect x="${x}" y="${y}" width="0" height="1" fill="${c}"><animate attributeName="width" values="${values.join(';')}" keyTimes="${times.join(';')}" calcMode="discrete" dur="${ms(cycle)}" begin="${ms(t)}" repeatCount="indefinite"/></rect>`
139 })
140 .join('')
141}
142
143/** An open laptop beside him, seen from the side: keyboard deck on the ground, screen leaning back, code typing out. */
144function laptop(t: number): string {
145 const deck = rect(16, 10, 9, 1, DECK) + rect(16, 11, 9, 1, DECK_EDGE)
146 const bezel = rect(22, 3, 5, 3, BEZEL) + rect(21, 6, 5, 4, BEZEL)
147 const display = rect(23, 4, 3, 2, DISPLAY) + rect(22, 6, 3, 3, DISPLAY)
148 const code = typedLines(
149 [
150 [23, 4, 3, '#7FB685'],
151 [23, 5, 2, '#6CA0DC'],
152 [22, 6, 3, '#E8B04B'],
153 [22, 7, 2, '#D77757'],
154 [22, 8, 3, '#7FB685'],
155 ],
156 t,
157 2400,
158 )
159
160 return deck + bezel + display + code
161}
162
163/** A sheet of paper held up beside him, a folded corner, the line he is reading darkening as his eyes move down. */
164function paper(t: number): string {
165 // A 5 x 9 sheet, its top-right corner folded over, a darker edge for its thickness, three lines of text.
166 const sheet =
167 rect(18, 2, 4, 1, PAGE) + rect(18, 3, 5, 8, PAGE) + rect(22, 3, 1, 1, PAGE_EDGE) + rect(23, 3, 1, 8, PAGE_EDGE) + rect(19, 11, 5, 0, PAGE_EDGE)
168 const lines = [4, 6, 8].map((y, i) => rect(19, y, i === 2 ? 2 : 3, 1, INK)).join('')
169 const reading = `<rect x="19" y="4" width="3" height="1" fill="${INK_DARK}"><animate attributeName="y" values="4;6;8" calcMode="discrete" dur="2.4s" begin="${ms(t)}" repeatCount="indefinite"/><animate attributeName="width" values="3;3;2" calcMode="discrete" dur="2.4s" begin="${ms(t)}" repeatCount="indefinite"/></rect>`
170 // Every other read-through the sheet lifts a pixel, as if he turned to the next page.
171 return `<g><animateTransform attributeName="transform" type="translate" values="0 0;0 -1;0 0" keyTimes="0;0.94;0.98" calcMode="discrete" dur="4.8s" begin="${ms(t)}" repeatCount="indefinite"/>${sheet}${lines}${reading}</g>`
172}
173
174/** Props drawn on Clawd's own 24-wide frame, shifted to stand where he stands. */
175const centered = (svg: string) => `<g transform="translate(${CX} 0)">${svg}</g>`
176
177const QUESTION: [number, number][] = [[0, 0], [1, 0], [2, 0], [2, 1], [1, 2], [1, 4]]
178
179function question(t: number): string {
180 return centered(`<g><animateTransform attributeName="transform" type="translate" values="0 0;0 1" calcMode="discrete" dur="1s" begin="${ms(t)}" repeatCount="indefinite"/>${dots(
181 QUESTION.map(([x, y]) => [20 + x, 2 + y]),
182 O,
183 )}</g>`)
184}
185
186const Z: [number, number][] = [[0, 0], [1, 0], [2, 0], [3, 0], [2, 1], [1, 2], [0, 3], [1, 3], [2, 3], [3, 3]]
187
188export function zzz(t: number): string {
189 return centered([0, 1, 2]
190 .map(i => {
191 const begin = ms(t + i * 900)
192 return `<g opacity="0"><animateTransform attributeName="transform" type="translate" values="17 6;17 5;18 5;18 4;19 3;19 3;20 2" calcMode="discrete" dur="2.7s" begin="${begin}" repeatCount="indefinite"/><animate attributeName="opacity" values="0;0.9;0.9;0.8;0.6;0.4;0" calcMode="discrete" dur="2.7s" begin="${begin}" repeatCount="indefinite"/>${dots(Z, ZZZ)}</g>`
193 })
194 .join(''))
195}
196
197function sparkles(t: number): string {
198 const plus = (x: number, y: number) => dots([[x, y - 1], [x - 1, y], [x, y], [x + 1, y], [x, y + 1]], SPARK)
199 const spots: [number, number, number][] = [
200 [3, 4, 0],
201 [21, 4, 250],
202 [20, 8, 500],
203 [2, 8, 750],
204 ]
205 return centered(spots
206 .map(
207 ([x, y, d]) =>
208 `<g opacity="0"><animate attributeName="opacity" values="0;1;0" calcMode="discrete" keyTimes="0;0.15;0.5" dur="1s" begin="${ms(t + d)}" repeatCount="indefinite"/>${plus(x, y)}</g>`,
209 )
210 .join(''))
211}
212
213function alarm(t: number): string {
214 const bang = `<g><animate attributeName="opacity" values="1;0.3" calcMode="discrete" dur="0.5s" begin="${ms(t)}" repeatCount="indefinite"/>${rect(20, 2, 1, 3, RED)}${rect(20, 6, 1, 1, RED)}</g>`
215 const drop = `<rect x="15" y="3" width="1" height="2" fill="${SWEAT}" opacity="0"><animate attributeName="y" values="3;3;4;4" calcMode="discrete" dur="1.2s" begin="${ms(t)}" repeatCount="indefinite"/><animate attributeName="opacity" values="1;1;1;0" calcMode="discrete" dur="1.2s" begin="${ms(t)}" repeatCount="indefinite"/></rect>`
216
217 return centered(bang + drop)
218}
219
220// ---- poses ----
221
222type Frames = [Posture, number][]
223
224type Pose = {
225 id: MascotPose
226 /** What the drawing says, for a reader that cannot see it. */
227 alt: string
228 rest: Posture
229 loop: Frames
230 breathe?: { k: number; dur: number }
231 props?: (t: number) => string
232 /** How the props come and go in a transition: the laptop slides up, the rest fade. */
233 propMove?: 'slide' | 'fade'
234}
235
236export const standing = (p: Partial<Posture> = {}): Posture => ({ eyes: 'open', arms: 'side', ...p })
237export const sitting = (p: Partial<Posture> = {}): Posture => ({ y: 2, legs: false, eyes: 'closed', arms: 'rest', ...p })
238
239const repeat = (frames: Frames, n: number): Frames => Array.from({ length: n }, () => frames).flat()
240
241const POSES: Pose[] = [
242 {
243 id: 'idle',
244 alt: 'Clawd standing',
245 rest: standing(),
246 loop: [
247 [standing(), 2600],
248 [standing({ eyes: 'half' }), 110],
249 [standing(), 1500],
250 [standing({ look: 1 }), 900],
251 [standing(), 700],
252 [standing({ look: -1 }), 900],
253 [standing(), 1200],
254 [standing({ eyes: 'half' }), 110],
255 ],
256 breathe: { k: 0.94, dur: 3400 },
257 },
258 {
259 id: 'working',
260 alt: 'Clawd typing on a laptop',
261 rest: standing({ x: -4, arms: 'typeA', look: 1 }),
262 loop: [
263 ...repeat([[standing({ x: -4, arms: 'typeA', look: 1 }), 150], [standing({ x: -4, arms: 'typeB', look: 1 }), 150]], 5),
264 [standing({ x: -4, arms: 'side', look: 1 }), 450],
265 [standing({ x: -4, arms: 'side', look: 1, eyes: 'half' }), 100],
266 ...repeat([[standing({ x: -4, arms: 'typeA', look: 1 }), 150], [standing({ x: -4, arms: 'typeB', look: 1 }), 150]], 4),
267 ],
268 breathe: { k: 0.97, dur: 1600 },
269 props: laptop,
270 propMove: 'slide',
271 },
272 {
273 id: 'puzzled',
274 alt: 'Clawd looking puzzled',
275 rest: standing({ eyes: 'squint', arms: 'scratchA' }),
276 loop: [
277 ...repeat([[standing({ eyes: 'squint', arms: 'scratchA' }), 200], [standing({ eyes: 'squint', arms: 'scratchB' }), 200]], 3),
278 [standing({ eyes: 'squint', arms: 'side' }), 1400],
279 [standing({ eyes: 'open', look: 1, arms: 'side' }), 900],
280 [standing({ eyes: 'squint', arms: 'side' }), 600],
281 ],
282 breathe: { k: 0.95, dur: 3000 },
283 props: question,
284 propMove: 'fade',
285 },
286 {
287 id: 'sleeping',
288 alt: 'Clawd asleep',
289 rest: sitting(),
290 loop: [[sitting(), 1000]],
291 breathe: { k: 0.9, dur: 4200 },
292 props: zzz,
293 propMove: 'fade',
294 },
295 {
296 id: 'celebrate',
297 alt: 'Clawd celebrating',
298 rest: standing({ eyes: 'happy', arms: 'up' }),
299 loop: [
300 [standing({ eyes: 'happy', arms: 'side' }), 220],
301 [standing({ eyes: 'happy', arms: 'up', y: -1 }), 100],
302 [standing({ eyes: 'happy', arms: 'up', y: -2 }), 220],
303 [standing({ eyes: 'happy', arms: 'up', y: -1 }), 100],
304 [standing({ eyes: 'happy', arms: 'side' }), 360],
305 ],
306 props: sparkles,
307 propMove: 'fade',
308 },
309 {
310 id: 'reading',
311 alt: 'Clawd reading a sheet of paper',
312 rest: standing({ x: -3, arms: 'hold', look: 1 }),
313 loop: [
314 [standing({ x: -3, arms: 'hold', look: 1 }), 1500],
315 [standing({ x: -3, arms: 'hold', look: 1, eyes: 'half' }), 100],
316 [standing({ x: -3, arms: 'hold', look: 1, lookY: 1 }), 800],
317 ],
318 breathe: { k: 0.96, dur: 2600 },
319 props: paper,
320 propMove: 'fade',
321 },
322 {
323 id: 'error',
324 alt: 'Clawd startled',
325 rest: standing({ eyes: 'wide', arms: 'up' }),
326 loop: [
327 ...repeat([[standing({ eyes: 'wide', arms: 'up', x: -1 }), 70], [standing({ eyes: 'wide', arms: 'up', x: 1 }), 70]], 3),
328 [standing({ eyes: 'wide', arms: 'side' }), 1500],
329 ],
330 props: alarm,
331 propMove: 'fade',
332 },
333 {
334 id: 'wave',
335 alt: 'Clawd waving',
336 rest: standing({ eyes: 'happy', arms: 'waveA' }),
337 loop: [
338 ...repeat([[standing({ eyes: 'happy', arms: 'waveA' }), 220], [standing({ eyes: 'happy', arms: 'waveB' }), 220]], 3),
339 [standing({ eyes: 'happy', arms: 'side' }), 700],
340 ],
341 breathe: { k: 0.95, dur: 3000 },
342 },
343]
344
345const POSE = Object.fromEntries(POSES.map(p => [p.id, p])) as Record<MascotPose, Pose>
346
347// ---- assembling a pose, with or without a transition into it ----
348
349/** The frames played between two poses before the target's own loop starts. */
350function introFrames(from: Pose, to: Pose): Frames {
351 const frames: Frames = []
352 if (from.id === 'sleeping') {
353 // Jolts awake and hops up.
354 frames.push([sitting(), 150], [sitting({ eyes: 'wide' }), 260], [standing({ eyes: 'wide', arms: 'up', y: -1 }), 180], [standing(), 160])
355 } else {
356 frames.push([{ ...from.rest }, 140])
357 }
358 // Walks over to the laptop or paper, or back to the middle, one pixel a step.
359 const fromX = from.id === 'sleeping' ? 0 : (from.rest.x ?? 0)
360 const toX = to.rest.x ?? 0
361 const dir = Math.sign(toX - fromX)
362 for (let x = fromX + dir, i = 0; dir !== 0 && x !== toX + dir; x += dir, i++) {
363 frames.push([standing({ x, look: dir, legs: i % 2 === 0 ? 'A' : 'B' }), 110])
364 }
365 if (to.id === 'sleeping') {
366 // Eyes droop and close, then he sits down.
367 frames.push([standing({ eyes: 'half' }), 450], [standing({ eyes: 'closed' }), 450], [sitting({ y: 1 }), 250])
368 } else {
369 frames.push([{ ...to.rest, eyes: 'half', y: 0 }, 90])
370 }
371 // The laptop needs time to slide in or out.
372 const needs = from.propMove === 'slide' || to.propMove === 'slide' ? 450 : 0
373 const total = frames.reduce((n, [, d]) => n + d, 0)
374 if (total < needs) frames.push([{ ...to.rest }, needs - total])
375
376 return frames
377}
378
379/** A frame shown once, from `start` for `dur` ms. */
380const once = (p: Posture, start: number, dur: number) =>
381 `<g visibility="hidden"><set attributeName="visibility" to="visible" begin="${ms(start)}" dur="${ms(dur)}"/>${figure(p)}</g>`
382
383/** The loop's frames, each visible for its share of the cycle, starting at `t`. */
384function loopFrames(frames: Frames, t: number): string {
385 if (frames.length === 1) {
386 return `<g visibility="hidden"><set attributeName="visibility" to="visible" begin="${ms(t)}"/>${figure(frames[0]![0])}</g>`
387 }
388 const cycle = frames.reduce((n, [, d]) => n + d, 0)
389 let at = 0
390
391 return frames
392 .map(([p, d]) => {
393 const a = at / cycle
394 const b = (at + d) / cycle
395 at += d
396 const values: string[] = []
397 const times: string[] = []
398 if (a > 0) {
399 values.push('hidden')
400 times.push('0')
401 }
402 values.push('visible')
403 times.push(a.toFixed(4))
404 if (b < 0.99999) {
405 values.push('hidden')
406 times.push(b.toFixed(4))
407 }
408 return `<g visibility="hidden"><animate attributeName="visibility" values="${values.join(';')}" keyTimes="${times.join(';')}" calcMode="discrete" dur="${ms(cycle)}" begin="${ms(t)}" repeatCount="indefinite"/>${figure(p)}</g>`
409 })
410 .join('')
411}
412
413/** Breathing: a smooth squash toward the feet, starting at `t`. */
414function breathing(content: string, pose: Pose, t: number): string {
415 if (!pose.breathe) return content
416 const base = 10 + (pose.rest.y ?? 0)
417 const k = pose.breathe.k
418 const spline = '0.45 0 0.55 1;0.45 0 0.55 1'
419
420 return `<g transform="translate(0 ${base})"><g><animateTransform attributeName="transform" type="scale" values="1 1;1 ${k};1 1" keyTimes="0;0.5;1" calcMode="spline" keySplines="${spline}" dur="${ms(pose.breathe.dur)}" begin="${ms(t)}" repeatCount="indefinite"/><g transform="translate(0 -${base})">${content}</g></g></g>`
421}
422
423/** Props coming in (over the intro) or going out, by slide or fade. */
424function moveProps(content: string, move: 'slide' | 'fade', dir: 'in' | 'out', dur: number): string {
425 const d = ms(dur)
426 if (move === 'slide') {
427 const steps = dir === 'in' ? '0 5;0 4;0 3;0 2;0 1;0 0' : '0 0;0 1;0 2;0 3;0 4;0 6'
428 const vis = dir === 'out' ? `<set attributeName="visibility" to="hidden" begin="${d}"/>` : ''
429 return `<g transform="translate(0 ${dir === 'in' ? 5 : 0})"><animateTransform attributeName="transform" type="translate" values="${steps}" calcMode="discrete" dur="${d}" fill="freeze"/>${vis}${content}</g>`
430 }
431
432 return `<g opacity="${dir === 'in' ? 0 : 1}"><animate attributeName="opacity" values="${dir === 'in' ? '0;0.5;1' : '1;0.5;0'}" calcMode="discrete" dur="${d}" fill="freeze"/>${content}</g>`
433}
434
435function svg(content: string, size = 1): string {
436 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${LEFT} ${TOP} ${W} ${H}" width="${W * size}" height="${H * size}" shape-rendering="crispEdges">${content}</svg>`
437}
438
439/** How long the intro into `to` from `from` plays, in ms; 0 with no pose before. */
440export function introMs(to: MascotPose, from: MascotPose | null): number {
441 return from === null || from === to ? 0 : introFrames(POSE[from], POSE[to]).reduce((n, [, d]) => n + d, 0)
442}
443
444export function mascotAlt(pose: MascotPose): string {
445 return POSE[pose].alt
446}
447
448/**
449 * The pose's SVG, playing the transition from `from` first. `seq` goes in a comment so each change of pose is a new
450 * source: the surface then starts the animation over rather than reusing a drawing it already has.
451 */
452export function mascotSvg(to: MascotPose, from: MascotPose | null, seq: number): string {
453 return render(POSE[to], from === null ? undefined : POSE[from]).replace('</svg>', `<!--${seq}--></svg>`)
454}
455
456/** A pose's SVG; given `from`, it first plays the transition out of that pose. */
457function render(to: Pose, from?: Pose): string {
458 if (!from || from.id === to.id) {
459 return svg(breathing(loopFrames(to.loop, 0), to, 0) + (to.props?.(0) ?? ''))
460 }
461 const intro = introFrames(from, to)
462 const t = intro.reduce((n, [, d]) => n + d, 0)
463 let at = 0
464 const introSvg = intro
465 .map(([p, d]) => {
466 const s = once(p, at, d)
467 at += d
468 return s
469 })
470 .join('')
471 const out = from.props ? moveProps(from.props(0), from.propMove ?? 'fade', 'out', t) : ''
472 const into = to.props ? moveProps(to.props(t), to.propMove ?? 'fade', 'in', t) : ''
473
474 return svg(introSvg + breathing(loopFrames(to.loop, t), to, t) + out + into)
475}
476
477/** Before anything happens in a session: standing, no pose changed yet. */
478export const MASCOT_START: MascotState = { pose: 'idle', rest: 'idle', from: null, change: 0, seq: 0, since: 0 }
479
480/** The drawing for the bar: the pose's SVG (with its transition while one plays) and what it says. */
481export function mascotDrawing(m: MascotState): { source: string; alt: string } {
482 return { source: mascotSvg(m.pose, m.from, m.seq), alt: mascotAlt(m.pose) }
483}
484
485/** Poses that play for a moment, then give way to the pose underneath (MascotState.rest), in ms. */
486export const ONE_SHOT_MS: Partial<Record<MascotPose, number>> = { celebrate: 2600, error: 2400, wave: 2600 }
487
488/** How long Clawd stands idle before he sits down and falls asleep. */
489export const SLEEP_AFTER_MS = 5 * 60_000
490
491/** Tools that read rather than change things: Clawd reads a sheet of paper while one runs. */
492export const READ_TOOLS: ReadonlySet<string> = new Set(['Read', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'NotebookRead'])
493
494/** Whether a reply ends by asking the person something: its last words, past closing marks, end in a question mark. */
495export function endsWithQuestion(text: string): boolean {
496 return /\?[\s*_`"')\]]*$/.test(text)
497}
498
499/**
500 * Whether the conversation, as the mod finds it on load, ends with a reply that asks something: the last assistant
501 * text with no prompt of the person's after it.
502 */
503export function isQuestionOpenIn(rows: readonly Row[]): boolean {
504 for (const row of [...rows].reverse()) {
505 if (row.role === 'assistant' && row.text.trim()) {
506 return endsWithQuestion(row.text)
507 }
508 if (row.role === 'user' && row.text.trim() && !row.toolResults?.length) {
509 return false
510 }
511 }
512
513 return false
514}
515src/settings.ts 64 lines1import type { Settings, ToggleKey } from '../types'
2
3/** The slice of `$.store` the mod needs; the hooks module passes `$.store` itself. */
4export type KeyStore = { get(key: string): Promise<unknown>; set(key: string, value: unknown): Promise<void> }
5
6/** `needsSvg`: an option only surfaces that draw SVG offer (the desktop), since the terminal cannot show it. */
7export const TOGGLES: readonly { key: ToggleKey; label: string; needsSvg?: true }[] = [
8 { key: 'mascot', label: 'Mascot', needsSvg: true },
9 { key: 'label', label: 'Title' },
10 { key: 'fiveHour', label: '5-hour usage' },
11 { key: 'weekly', label: 'Weekly usage' },
12 { key: 'resets', label: 'Reset countdowns' },
13 { key: 'contextPercent', label: 'Context %' },
14 { key: 'contextTokens', label: 'Context tokens' },
15 { key: 'threadTokens', label: 'Thread tokens' },
16 { key: 'lastTurnTokens', label: 'Last-turn tokens' },
17 { key: 'cacheWarmth', label: 'Cache warmth' },
18 { key: 'threadCost', label: 'Thread cost' },
19 { key: 'lastTurnCost', label: 'Last-turn cost' },
20 { key: 'threadPercent', label: 'Thread cost %' },
21 { key: 'monthlyCost', label: 'Monthly cost' },
22 { key: 'burnRate', label: 'Burn-rate projection' },
23 { key: 'thresholdColors', label: 'Threshold colors' },
24]
25
26export const DEFAULT_SETTINGS: Settings = Object.fromEntries(TOGGLES.map(t => [t.key, true])) as Settings
27
28export function normalizeSettings(raw: unknown): Settings {
29 const given = raw !== null && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}
30 const settings = { ...DEFAULT_SETTINGS }
31 for (const { key } of TOGGLES) {
32 const value = given[key]
33 if (typeof value === 'boolean') {
34 settings[key] = value
35 }
36 }
37
38 return settings
39}
40
41export async function loadSettings(store: KeyStore): Promise<Settings> {
42 return normalizeSettings(await store.get('settings'))
43}
44
45export async function saveSettings(store: KeyStore, settings: Settings): Promise<void> {
46 await store.set('settings', settings)
47}
48
49/** Whether `/aitools <args>` hides the bar: no argument toggles, on/show and off/hide set it; null for anything else. */
50export function nextHidden(args: string, isHidden: boolean): boolean | null {
51 const word = args.trim().toLowerCase()
52 if (word === '') {
53 return !isHidden
54 }
55 if (word === 'on' || word === 'show') {
56 return false
57 }
58 if (word === 'off' || word === 'hide') {
59 return true
60 }
61
62 return null
63}
64src/transcripts.ts 229 lines1import { price } from './pricing'
2import type { TokenUsage } from './pricing'
3
4export type ScanEntry = { name: string; kind: 'file' | 'dir' | 'other'; size: number; mtimeMs: number }
5
6export type ScanIO = {
7 list(dir: string): Promise<ScanEntry[]>
8 /** The whole file; only called for files of at most MAX_READ bytes. */
9 readBytes(path: string): Promise<Uint8Array>
10 /** The file's bytes from `offset` on, possibly cut short (`isTruncated`); rejects when no tail is available. */
11 tail(path: string, offset: number): Promise<{ bytes: Uint8Array; isTruncated: boolean }>
12}
13
14/**
15 * `isEstimate`: a priced row used an estimated rate. `isFailed`: the last read failed, so it is retried. `tokens`: every
16 * token the month's responses in the file used (input, output, cache reads and writes).
17 */
18export type FileCache = {
19 offset: number
20 size: number
21 usd: number
22 tokens?: number
23 isEstimate: boolean
24 isFailed?: boolean
25}
26
27export type ScanCache = {
28 /** The cache's layout; an older one is read again from scratch. */
29 version?: number
30 month: string
31 files: Record<string, FileCache>
32 seenIds: string[]
33 usd: number
34 isEstimate: boolean
35 /** Cost per hour (key: hours since the epoch) over the last HOURS_KEPT_MS, for the weekly window's total. */
36 hours?: Record<string, number>
37}
38
39export const MAX_READ = 4 * 1024 * 1024
40/** Bumped when the cache gains a field older scans lack (2: hourly costs and per-file tokens). */
41export const CACHE_VERSION = 2
42const HOUR_MS = 60 * 60_000
43/** Hourly costs are kept this far back: the 7-day weekly window, plus a day of slack. */
44export const HOURS_KEPT_MS = 8 * 24 * HOUR_MS
45
46const NEWLINE = 0x0a
47const ID_KEY_LENGTH = 16
48const decoder = new TextDecoder()
49
50/** Message ids share a long constant prefix; their tail is unique enough and keeps the stored set small. */
51function idKey(id: string): string {
52 return id.slice(-ID_KEY_LENGTH)
53}
54
55export function monthKey(ms: number): string {
56 const d = new Date(ms)
57
58 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
59}
60
61export function monthStart(ms: number): number {
62 const d = new Date(ms)
63
64 return new Date(d.getFullYear(), d.getMonth(), 1).getTime()
65}
66
67type Row = { type?: string; timestamp?: string; message?: { id?: string; model?: string; usage?: TokenUsage } }
68
69/**
70 * Prices a chunk's assistant rows once each (by message id): the month's into `usd`, and, given `hours`, any since
71 * `since` into its hourly buckets, so the weekly window can reach back into last month.
72 */
73export function parseChunk(
74 bytes: Uint8Array,
75 month: string,
76 seen: Set<string>,
77 hours?: { since: number; usd: Record<string, number> },
78): { usd: number; tokens: number; isEstimate: boolean; consumed: number } {
79 const consumed = bytes.lastIndexOf(NEWLINE) + 1
80 let usd = 0
81 let tokens = 0
82 let isEstimate = false
83 for (const line of decoder.decode(bytes.subarray(0, consumed)).split('\n')) {
84 if (!line) {
85 continue
86 }
87 let row: Row
88 try {
89 row = JSON.parse(line) as Row
90 } catch {
91 continue
92 }
93 const message = row.message
94 if (row.type !== 'assistant' || !message?.usage || !message.model || !message.id || seen.has(idKey(message.id))) {
95 continue
96 }
97 const at = row.timestamp ? Date.parse(row.timestamp) : NaN
98 const isMonth = !Number.isNaN(at) && monthKey(at) === month
99 const isRecent = hours !== undefined && at >= hours.since
100 if (!isMonth && !isRecent) {
101 continue
102 }
103 seen.add(idKey(message.id))
104 const priced = price(message.model, message.usage)
105 if (isMonth) {
106 usd += priced.usd
107 tokens += usageTokens(message.usage)
108 isEstimate ||= priced.isEstimate
109 }
110 if (isRecent) {
111 const key = String(Math.floor(at / HOUR_MS))
112 hours.usd[key] = (hours.usd[key] ?? 0) + priced.usd
113 }
114 }
115
116 return { usd, tokens, isEstimate, consumed }
117}
118
119/** Every token a response used: input, output, and the prompt cache's reads and writes. */
120export function usageTokens(usage: TokenUsage): number {
121 const counts = [usage.input_tokens, usage.output_tokens, usage.cache_read_input_tokens, usage.cache_creation_input_tokens]
122
123 return counts.reduce<number>((sum, n) => sum + (typeof n === 'number' ? n : 0), 0)
124}
125
126async function walk(io: ScanIO, dir: string, out: (ScanEntry & { path: string })[]): Promise<void> {
127 for (const entry of await io.list(dir)) {
128 const path = `${dir}/${entry.name}`
129 if (entry.kind === 'dir') {
130 await walk(io, path, out)
131 } else if (entry.kind === 'file' && entry.name.endsWith('.jsonl')) {
132 out.push({ ...entry, path })
133 }
134 }
135}
136
137export async function scanMonth(io: ScanIO, root: string, cache: ScanCache | null, now: number): Promise<ScanCache> {
138 const month = monthKey(now)
139 // Files back to the month's start or the weekly window's, whichever is earlier; a cache without hourly costs (an
140 // older one) is rebuilt so its hours are filled in.
141 const since = now - HOURS_KEPT_MS
142 const start = Math.min(monthStart(now), since)
143 const isCurrent = cache?.month === month && cache.version === CACHE_VERSION
144 const files: Record<string, FileCache> = isCurrent ? { ...cache.files } : {}
145 const seen = new Set(isCurrent ? cache.seenIds : [])
146 const hours = { since, usd: { ...(isCurrent ? cache.hours : {}) } }
147
148 const entries: (ScanEntry & { path: string })[] = []
149 await walk(io, root, entries)
150 for (const entry of entries) {
151 if (entry.mtimeMs < start) {
152 continue
153 }
154 const known = files[entry.path]
155 if (known && known.size === entry.size && !known.isFailed) {
156 continue
157 }
158 const file: FileCache =
159 known && entry.size >= known.offset
160 ? { ...known }
161 : { offset: 0, size: 0, usd: 0, tokens: 0, isEstimate: false, isFailed: false }
162 const take = (bytes: Uint8Array): number => {
163 const parsed = parseChunk(bytes, month, seen, hours)
164 file.usd += parsed.usd
165 file.tokens = (file.tokens ?? 0) + parsed.tokens
166 file.offset += parsed.consumed
167 file.isEstimate ||= parsed.isEstimate
168
169 return parsed.consumed
170 }
171 try {
172 if (entry.size <= MAX_READ) {
173 take((await io.readBytes(entry.path)).subarray(file.offset))
174 } else {
175 let isMore = true
176 while (isMore) {
177 const { bytes, isTruncated } = await io.tail(entry.path, file.offset)
178 isMore = take(bytes) > 0 && isTruncated
179 }
180 }
181 file.isFailed = false
182 } catch {
183 file.isFailed = true
184 }
185 file.size = entry.size
186 files[entry.path] = file
187 }
188
189 const all = Object.values(files)
190 const oldest = Math.floor(since / HOUR_MS)
191
192 return {
193 version: CACHE_VERSION,
194 month,
195 files,
196 seenIds: [...seen],
197 usd: all.reduce((sum, f) => sum + f.usd, 0),
198 isEstimate: all.some(f => f.isEstimate || f.isFailed),
199 hours: Object.fromEntries(Object.entries(hours.usd).filter(([key]) => Number(key) >= oldest)),
200 }
201}
202
203/** The cost of the hours from `from` on: the hour `from` falls in counts whole. */
204export function usdSince(hours: Record<string, number> | undefined, from: number): number {
205 const first = Math.floor(from / HOUR_MS)
206
207 return Object.entries(hours ?? {}).reduce((sum, [key, usd]) => (Number(key) >= first ? sum + usd : sum), 0)
208}
209
210/** The session's own transcript files: its main one and its helpers' beside it. */
211function sessionFiles(cache: ScanCache, sessionId: string): FileCache[] {
212 return Object.entries(cache.files)
213 .filter(([path]) => path.endsWith(`/${sessionId}.jsonl`) || path.includes(`/${sessionId}/`))
214 .map(([, file]) => file)
215}
216
217export function sessionUsd(cache: ScanCache, sessionId: string): number | undefined {
218 const own = sessionFiles(cache, sessionId)
219
220 return own.length > 0 ? own.reduce((sum, f) => sum + f.usd, 0) : undefined
221}
222
223/** Every token this month's responses in the session used, its helpers' included; undefined without its files. */
224export function sessionTokens(cache: ScanCache, sessionId: string): number | undefined {
225 const own = sessionFiles(cache, sessionId)
226
227 return own.length > 0 ? own.reduce((sum, f) => sum + (f.tokens ?? 0), 0) : undefined
228}
229src/noteseditor.tsx 166 lines1/**
2 * The Notes pane's text box: a `Client` surface module, so it takes keys and clicks itself. It keeps the text and the
3 * cursor in its own state, draws the text wrapped to its region with the cursor as an inverted cell, and posts the
4 * whole text to the hooks module after every change, which saves it.
5 *
6 * A click places the cursor; typing and pasting insert, Enter starts a new line, the arrows, Home and End move
7 * (Ctrl+A and Ctrl+E too), Backspace and Delete remove, Page Up and Page Down move ten rows. Escape hands the keys back
8 * to the prompt.
9 */
10
11import type { ClientKeyEvent, ClientSurface } from 'claude-code'
12
13/** What the hooks module hands the box: the note as saved, and the pane's width for the first layout. */
14export type NotesProps = { text: string; columns: number }
15/** The text and where the cursor sits in it, as an index. */
16export type Editor = { text: string; cursor: number }
17/** A visual row: the part of the text from `start` to `end`, a line or one wrapped piece of it. */
18export type Row = { start: number; end: number }
19
20/** The fewest rows the box takes, so an empty note still looks like a place to write. */
21export const MIN_ROWS = 10
22/** How far Page Up and Page Down move. */
23const PAGE = 10
24export const PLACEHOLDER = 'Click here and type your notes.'
25
26/** Keys with a name of their own (and F1 to F24); anything else is text typed or pasted. */
27const NAMED = new Set([
28 'up', 'down', 'left', 'right', 'return', 'enter', 'tab', 'backspace', 'delete', 'pageup', 'pagedown', 'home', 'end',
29 'escape', 'esc', 'insert', 'clear', 'space',
30])
31const FUNCTION_KEY = /^f\d{1,2}$/
32
33/** The text's visual rows at `columns` wide: each line, cut into pieces where it is longer. */
34export function layout(text: string, columns: number): Row[] {
35 const width = Math.max(1, columns)
36 const rows: Row[] = []
37 let start = 0
38 for (const line of text.split('\n')) {
39 if (line.length === 0) {
40 rows.push({ start, end: start })
41 }
42 for (let i = 0; i < line.length; i += width) {
43 rows.push({ start: start + i, end: start + Math.min(i + width, line.length) })
44 }
45 start += line.length + 1
46 }
47
48 return rows
49}
50
51/** The row the cursor is on, and its column there: at a wrap, the start of the next piece. */
52export function cursorAt(rows: readonly Row[], cursor: number): { row: number; col: number } {
53 let row = 0
54 for (let r = 0; r < rows.length; r++) {
55 if (rows[r]!.start <= cursor) row = r
56 }
57
58 return { row, col: cursor - rows[row]!.start }
59}
60
61/** The index at a row and column, held to the row's length (and to the rows there are). */
62export function indexAt(rows: readonly Row[], row: number, col: number): number {
63 const r = rows[Math.min(Math.max(0, row), rows.length - 1)]!
64
65 return r.start + Math.min(Math.max(0, col), r.end - r.start)
66}
67
68const insert = (ed: Editor, s: string): Editor => ({
69 text: ed.text.slice(0, ed.cursor) + s + ed.text.slice(ed.cursor),
70 cursor: ed.cursor + s.length,
71})
72
73/** The editor after one key at `columns` wide, or null when the key does nothing here. */
74export function applyKey(ed: Editor, event: ClientKeyEvent, columns: number): Editor | null {
75 const { key } = event
76 const rows = layout(ed.text, columns)
77 const at = cursorAt(rows, ed.cursor)
78 const move = (cursor: number): Editor => ({ ...ed, cursor })
79 if (event.ctrl || event.meta) {
80 if (key === 'a') return move(rows[at.row]!.start)
81 if (key === 'e') return move(rows[at.row]!.end)
82 return null
83 }
84 switch (key) {
85 case 'return':
86 case 'enter':
87 return insert(ed, '\n')
88 case 'tab':
89 return insert(ed, ' ')
90 case 'space':
91 return insert(ed, ' ')
92 case 'backspace':
93 return ed.cursor === 0 ? ed : { text: ed.text.slice(0, ed.cursor - 1) + ed.text.slice(ed.cursor), cursor: ed.cursor - 1 }
94 case 'delete':
95 return { ...ed, text: ed.text.slice(0, ed.cursor) + ed.text.slice(ed.cursor + 1) }
96 case 'left':
97 return move(Math.max(0, ed.cursor - 1))
98 case 'right':
99 return move(Math.min(ed.text.length, ed.cursor + 1))
100 case 'up':
101 return move(at.row === 0 ? 0 : indexAt(rows, at.row - 1, at.col))
102 case 'down':
103 return move(at.row === rows.length - 1 ? ed.text.length : indexAt(rows, at.row + 1, at.col))
104 case 'pageup':
105 return move(indexAt(rows, at.row - PAGE, at.col))
106 case 'pagedown':
107 return move(indexAt(rows, at.row + PAGE, at.col))
108 case 'home':
109 return move(rows[at.row]!.start)
110 case 'end':
111 return move(rows[at.row]!.end)
112 }
113 if (NAMED.has(key) || FUNCTION_KEY.test(key) || key.length === 0) {
114 return null
115 }
116
117 // Typed or pasted text; a paste's line breaks come as \r\n or \r on some terminals.
118 return insert(ed, key.replace(/\r\n?/g, '\n'))
119}
120
121export default function NotesEditor(props: NotesProps, surface: ClientSurface<Editor>) {
122 const { Box, Text } = surface.elements
123 const columns = surface.columns || props.columns || 60
124 const ed = surface.state ?? { text: props.text, cursor: props.text.length }
125 const rows = layout(ed.text, columns)
126 const at = cursorAt(rows, ed.cursor)
127
128 // Listeners read the state as it stands when the key or click arrives, not as it was drawn.
129 surface.onKey(event => {
130 const now = surface.state ?? ed
131 const next = applyKey(now, event, columns)
132 if (next === null) return
133 surface.setState(next)
134 if (next.text !== now.text) surface.post({ text: next.text })
135 })
136 surface.onPointer(event => {
137 if (event.type !== 'down') return
138 const now = surface.state ?? ed
139 surface.setState({ ...now, cursor: indexAt(layout(now.text, columns), event.y, event.x) })
140 })
141
142 const lines = rows.map((r, i) => {
143 const text = ed.text.slice(r.start, r.end)
144 if (i !== at.row) {
145 return <Text key={`row-${i}`}>{text || ' '}</Text>
146 }
147 const under = text.slice(at.col, at.col + 1)
148 return (
149 <Text key={`row-${i}`}>
150 {text.slice(0, at.col)}
151 <Text inverse>{under || ' '}</Text>
152 {text.slice(at.col + 1)}
153 {ed.text.length === 0 && <Text dimColor>{PLACEHOLDER}</Text>}
154 </Text>
155 )
156 })
157 const pad = Array.from({ length: Math.max(0, MIN_ROWS - lines.length) }, (_, i) => <Text key={`pad-${i}`}> </Text>)
158
159 return (
160 <Box flexDirection="column">
161 {lines}
162 {pad}
163 </Box>
164 )
165}
166src/format.ts 71 lines1const MIN = 60_000
2const HOUR = 60 * MIN
3const DAY = 24 * HOUR
4const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
5
6export function usd(n: number, isEstimate = false): string {
7 const [whole = '0', cents = '00'] = n.toFixed(2).split('.')
8 const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
9
10 return `${isEstimate ? '~' : ''}$${grouped}.${cents}`
11}
12
13export function tokens(n: number): string {
14 if (n < 1000) {
15 return String(Math.round(n))
16 }
17 if (n < 1_000_000) {
18 return `${Math.round(n / 1000)}k`
19 }
20
21 return `${Number((n / 1_000_000).toFixed(1))}M`
22}
23
24export function duration(ms: number): string {
25 if (ms < MIN) {
26 return '<1m'
27 }
28 if (ms < HOUR) {
29 return `${Math.floor(ms / MIN)}m`
30 }
31 if (ms < DAY) {
32 return `${Math.floor(ms / HOUR)}h${Math.floor((ms % HOUR) / MIN)}m`
33 }
34
35 return `${Math.floor(ms / DAY)}d${Math.floor((ms % DAY) / HOUR)}h`
36}
37
38export function weeklyReset(iso: string, now: number): string {
39 const at = Date.parse(iso)
40 const hours = (at - now) / HOUR
41
42 return hours < 24 ? `${Math.max(1, Math.ceil(hours))}h` : (WEEKDAYS[new Date(at).getDay()] ?? '')
43}
44
45export function clockTime(ms: number): string {
46 const d = new Date(ms)
47 const hours = d.getHours()
48 const minutes = String(d.getMinutes()).padStart(2, '0')
49
50 return `${hours % 12 || 12}:${minutes}${hours < 12 ? 'am' : 'pm'}`
51}
52
53/** Minutes and seconds, `1:05`, or hours too once past an hour; a clock-style running time. */
54/** How long ago, in its largest unit alone: `just now`, `3m ago`, `1h ago`, `2d ago`. */
55export function ago(ms: number): string {
56 const m = Math.floor(Math.max(0, ms) / 60_000)
57 if (m < 1) {
58 return 'just now'
59 }
60
61 return m < 60 ? `${m}m ago` : m < 24 * 60 ? `${Math.floor(m / 60)}h ago` : `${Math.floor(m / (24 * 60))}d ago`
62}
63
64export function elapsed(ms: number): string {
65 const s = Math.max(0, Math.floor(ms / 1000))
66 const [h, m, sec] = [Math.floor(s / 3600), Math.floor((s % 3600) / 60), s % 60]
67 const pad = (n: number) => String(n).padStart(2, '0')
68
69 return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`
70}
71src/totem.ts 99 lines1/**
2 * The Subagents pane's picture on surfaces that draw SVG (the desktop Code tab): Clawd with a mini Clawd stacked on
3 * his head for each subagent running, asleep when none is.
4 *
5 * A 24 x 26 pixel grid at 2.5 screen pixels per grid pixel (60 x 65 px), a little shorter than the pane's four option
6 * rows.
7 * Clawd is the bar's mascot moved down DROP rows to stand on the bottom one; each mini Clawd is a 9 x 3 body on four
8 * 1-pixel legs, standing on the head of the one below.
9 */
10
11import { D, O, ZZZ, figure, rect, sitting, standing, zzz } from './mascot'
12
13const LEFT = 3
14const W = 24
15const H = 26
16const SCALE = 2.5
17/** How far Clawd's frame moves down to stand on the bottom row. */
18const DROP = 14
19/** The top of Clawd's body, where the stack starts. */
20const HEAD = 5 + DROP
21/** The left edge of a mini Clawd's body, centered over Clawd's. */
22const MINI_X = 9
23
24/** Asleep, Clawd and his Zzz turn this quiet grey, so an idle pane stays calm. */
25const ASLEEP = '#5C5C58'
26
27/** CSS pixels the picture takes in the pane. */
28export const TOTEM_WIDTH = W * SCALE
29export const TOTEM_HEIGHT = H * SCALE
30/** Mini Clawds the stack shows at most; with more running it stays full. */
31export const TOTEM_MAX = 4
32
33const ms = (n: number) => `${Math.round(n)}ms`
34
35/** Shown for `share` of each `dur` ms cycle from `begin`, hidden the rest. */
36const blinking = (content: string, share: number, dur: number, begin: number) =>
37 `<g><animate attributeName="visibility" values="visible;hidden" keyTimes="0;${share.toFixed(3)}" calcMode="discrete" dur="${ms(dur)}" begin="${ms(begin)}" repeatCount="indefinite"/>${content}</g>`
38
39/**
40 * The `k`th mini Clawd up the stack: stepping in place, blinking now and then, and (above the first) swaying a pixel
41 * either way, each a little out of step with the one below.
42 */
43function mini(k: number): string {
44 const y = HEAD - 4 * (k + 1)
45 const body = rect(MINI_X, y, 9, 3, O) + rect(MINI_X - 1, y + 1, 1, 1, O) + rect(MINI_X + 9, y + 1, 1, 1, O)
46 const legs = (lifted: number) =>
47 [1, 3, 5, 7].map((dx, i) => (i % 2 === lifted ? '' : rect(MINI_X + dx, y + 3, 1, 1, O))).join('')
48 const step = 600
49 const stepping = blinking(legs(0), 0.5, step, k * 150) + blinking(legs(1), 0.5, step, k * 150 + step / 2)
50 const eyes = blinking(rect(MINI_X + 2, y + 1, 1, 1, D) + rect(MINI_X + 6, y + 1, 1, 1, D), 0.95, 3000 + k * 700, k * 400)
51 const sway =
52 k === 0
53 ? ''
54 : `<animateTransform attributeName="transform" type="translate" values="0 0;1 0;0 0;-1 0" calcMode="discrete" dur="1600ms" begin="${ms(k * 200)}" repeatCount="indefinite"/>`
55
56 return `<g>${sway}${body}${stepping}${eyes}</g>`
57}
58
59/** Clawd at the bottom of the stack: asleep (in grey) with no subagent running, else standing and blinking. */
60function base(running: number): string {
61 if (running === 0) {
62 const asleep = (figure(sitting()) + zzz(0)).replaceAll(O, ASLEEP).replaceAll(ZZZ, ASLEEP)
63 return `<g transform="translate(0 ${DROP})">${asleep}</g>`
64 }
65 const awake = blinking(figure(standing()), 0.96, 3400, 0)
66 const blink = `<g visibility="hidden"><animate attributeName="visibility" values="hidden;visible" keyTimes="0;0.96" calcMode="discrete" dur="3400ms" repeatCount="indefinite"/>${figure(standing({ eyes: 'half' }))}</g>`
67
68 return `<g transform="translate(0 ${DROP})">${awake}${blink}</g>`
69}
70
71/** What the picture says, for a reader that cannot see it. */
72export function totemAlt(running: number): string {
73 return running === 0 ? 'Clawd asleep' : `Clawd with ${running} mini Clawd${running === 1 ? '' : 's'} stacked on his head`
74}
75
76/**
77 * The picture for `running` subagents. The source only changes with what is drawn, so the pane's redraws while the
78 * count holds keep the animation playing.
79 */
80export function totemSvg(running: number): string {
81 const n = Math.min(TOTEM_MAX, Math.max(0, running))
82 let source = drawn.get(n)
83 if (source === undefined) {
84 source = draw(n)
85 drawn.set(n, source)
86 }
87
88 return source
89}
90
91/** Each drawing built once: there are only TOTEM_MAX + 1 of them, and the pane redraws each second while one runs. */
92const drawn = new Map<number, string>()
93
94function draw(n: number): string {
95 const stack = Array.from({ length: n }, (_, k) => mini(k)).join('')
96
97 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${LEFT} 0 ${W} ${H}" width="${TOTEM_WIDTH}" height="${TOTEM_HEIGHT}" shape-rendering="crispEdges">${base(n)}${stack}</svg>`
98}
99