SLOPSHOPPER

aitools

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

newpanebandrowsguardcommand
v0.2.0no licenseupdated 2026-10-06pkkid/claude-mods/plugins/aitools
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · aitools
│ ┃ Subagents ✕ › fix the failing auth test╭───────────────────────────────────────╮ │ ┃ Showing: All │ aitools │ │ ┃ Hide completed: Never ⏺ Read(src/auth.ts) │ Handoff brief copied to the clipboard │ │ ┃ Set team size: Default ⎿ Read 6 lines ╰───────────────────────────────────────╯ │ ┃ Set model: Same as chat Fast & cheap ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Workflow and tool subagents will appear here │ ┃ when created. │ ✻ Worked for 42s · done 4:20 PM │ │ › /aitools │ ⎿ aitools: aitools bar hidden. Run /aitools to show it. │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ aitools: Clean View

Draws

Pane · Subagents
Showing: All Hide completed: Never Set team size: Default Set model: Same as chat Fast & cheap Workflow and tool subagents will appear here when created.
Pane · handoff
Writing the handoff brief…
Pane · notes
Loading the note…
README

aitools

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   [🛠] [⁝]
SegmentMeaning
ClawdThe mascot, at the start of the bar in the desktop Code tab only (see below)
AI ToolsA dim title naming the bar
5h 42%, 1h12m5-hour usage window: percent used, time until it resets
wk 18%, ThuWeekly window: percent used, the day it resets (hours when under a day)
ctx 31% 62k/200kContext 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 42mHow 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.12This 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.20This calendar month's Claude Code usage at API prices, across all sessions on this machine
limit ~3:40pmAt 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.

Show or hide the bar

/aitools toggles the whole bar (buttons included); /aitools on and /aitools off set it. The choice holds across sessions. /handoff works either way.

Settings

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.

Mascot

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.

PoseWhen
WavingA new session starts (a few seconds)
StandingNothing is running: breathing, blinking, glancing around
TypingA turn is running: tapping at a laptop beside him, code typing out on its screen
ReadingA read-only tool runs (Read, Grep, Glob, a web fetch or search): reading a sheet of paper
PuzzledA question waits on you: an open AskUserQuestion dialog, or a reply that ends with a question mark. Your next prompt settles it
CelebratingA turn finishes with an answer (a few seconds, then Standing)
OopsA tool call fails or is refused, or a turn is interrupted or fails (a few seconds)
SleepingStanding 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

/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

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.

Task View and Clean View

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

  • Task View shows the checklist and leaves the chat as it is (only the checklist tool's own rows are hidden).
  • Clean View shows the checklist and hides every tool call and everything Claude writes while it works; only the final reply of each finished request stays. It hides tool calls and in-progress replies already in the chat too.

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

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.

  • Set team size: how many helpers may run at once (3, 5, 10, 20 or 30). Past 10 the pane warns in yellow that a big team uses your Claude usage much faster.
  • Set model (always shown; faint and unpickable on Default): Same as chat (the default) uses the chat's model and reasoning level; Fast & cheap runs every helper request on Sonnet 5.5 at low reasoning.
  • With a size picked, each prompt carries a hidden note asking Claude to split a job with separate parts across the team, starting the pieces in parallel.
  • Every subagent Claude starts with its Agent tool while the pane is open (a team helper or not) has its prompt ask it to report how far along it is through the mod's 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.
  • A full team refuses further starts with a note to try again when a helper finishes (a hook may not hold a start for long); the header counts those pieces as queued until they start.
  • Showing: All (the default) lists every line; Tool agents leaves out workflow agents (only subagents Claude started with its Agent tool); Current task only those started since your last prompt. With nothing to list, the pane says what will appear there, e.g. Tool subagents will appear here when created.
  • Hide completed: how long a finished line stays: Never (the default), 15m, 5m, 1m or Immediate. Either way the pane keeps at most the newest 50 finished lines.
  • The team, model, Showing and Hide completed picks are saved.
  • A workflow's agents are told apart from the engine's own background forks (compaction, memory) only by whether a workflow has been started this session; after one, such a fork may briefly show as one of its agents.
  • The pane itself starts closed in a new session.

How thread cost % is estimated

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.

How monthly cost is computed

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.

Limits

  • Claude Code only: the Code tab in Claude Desktop and the terminal. Conversations in Claude Desktop's chat tab are not visible to mods and are not counted.
  • Usage percentages come from the API's rate-limit headers, so they update after each response.
  • Dollar figures are what the same tokens would cost on the API, not what the subscription costs.
Source 14 files
hooks/register.tsx 1388 lines
1import { 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 lines
1import 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}
459
src/bar.tsx 326 lines
1import 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}
326
src/checklist.tsx 237 lines
1import 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}
237
src/burnrate.ts 46 lines
1const 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}
46
src/handoff.ts 24 lines
1export 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}
24
src/mascot.ts 515 lines
1/**
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}
515
src/settings.ts 64 lines
1import 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}
64
src/transcripts.ts 229 lines
1import { 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}
229
src/noteseditor.tsx 166 lines
1/**
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}
166
src/format.ts 71 lines
1const 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}
71
src/totem.ts 99 lines
1/**
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