A clickable panel above the prompt: model, effort, helpers, Clean View, launch buttons, and Help, plus a status row and Today's report.

AP Toolkit is a mod for Claude Code on Windows. It adds:
Press ? in the controls box for in-app Help.
In Claude Code, run these two commands, then restart Claude Code:
/plugin marketplace add adamperr/ap-toolkit
/plugin install ap-toolkit@ap-toolkit
Today's report reads Claude Code's own session files on your PC. The toolkit makes no network calls and sends nothing anywhere.
New tab, the alerts, and the report's first fill run small PowerShell scripts from the scripts folder. If your PC's security policy blocks them, everything else still works.
hooks/register.tsx 2763 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, Timer } from 'claude-code'
3
4import type {
5 ReportDay,
6 ReportTab,
7 SessionCostCheck,
8 ToolkitAgent,
9 ToolkitCheck,
10 ToolkitDone,
11 ToolkitHelperMode,
12 ToolkitLimit,
13 ToolkitNudge,
14 ToolkitPending,
15 ToolkitProject,
16 ToolkitReport,
17 ToolkitRun,
18 ToolkitShell,
19 ToolkitTodo,
20} from '../types'
21
22const controlsOpenA = atom({ plugin: 'ap-toolkit', key: 'controlsOpen' } as const, false)
23const usageA = atom({ plugin: 'ap-toolkit', key: 'usage' } as const, null)
24const sessionTokensA = atom({ plugin: 'ap-toolkit', key: 'sessionTokens' } as const, 0)
25const modelA = atom({ plugin: 'ap-toolkit', key: 'model' } as const, null)
26const effortA = atom({ plugin: 'ap-toolkit', key: 'effort' } as const, null)
27const pendingA = atom({ plugin: 'ap-toolkit', key: 'pending' } as const, null)
28const noEffortA = atom({ plugin: 'ap-toolkit', key: 'noEffort' } as const, [])
29const hasEffortA = atom({ plugin: 'ap-toolkit', key: 'hasEffortCommand' } as const, true)
30const fiveHourA = atom({ plugin: 'ap-toolkit', key: 'fiveHour' } as const, null)
31const contextA = atom({ plugin: 'ap-toolkit', key: 'context' } as const, null)
32const helpersA = atom({ plugin: 'ap-toolkit', key: 'helpers' } as const, 0)
33
34const cleanViewA = atom({ plugin: 'ap-toolkit', key: 'cleanView' } as const, true)
35const peekA = atom({ plugin: 'ap-toolkit', key: 'peek' } as const, false)
36const todosA = atom({ plugin: 'ap-toolkit', key: 'todos' } as const, [])
37const runA = atom({ plugin: 'ap-toolkit', key: 'run' } as const, null)
38const costSeenA = atom({ plugin: 'ap-toolkit', key: 'costSeen' } as const, null)
39const doneA = atom({ plugin: 'ap-toolkit', key: 'done' } as const, null)
40const nowA = atom({ plugin: 'ap-toolkit', key: 'now' } as const, 0)
41const helperModeA = atom({ plugin: 'ap-toolkit', key: 'helperMode' } as const, 'lowest')
42const reportA = atom({ plugin: 'ap-toolkit', key: 'report' } as const, null)
43const nudgeA = atom({ plugin: 'ap-toolkit', key: 'nudge' } as const, null)
44const nudgeDismissedA = atom({ plugin: 'ap-toolkit', key: 'nudgeDismissed' } as const, [])
45const folderWarningA = atom({ plugin: 'ap-toolkit', key: 'folderWarning' } as const, null)
46const clearArmedA = atom({ plugin: 'ap-toolkit', key: 'clearArmed' } as const, false)
47const versionA = atom({ plugin: 'ap-toolkit', key: 'version' } as const, null)
48const agentsA = atom({ plugin: 'ap-toolkit', key: 'agents' } as const, [])
49const shellsA = atom({ plugin: 'ap-toolkit', key: 'shells' } as const, [])
50const stopArmedA = atom({ plugin: 'ap-toolkit', key: 'stopArmed' } as const, null)
51const projectA = atom({ plugin: 'ap-toolkit', key: 'project' } as const, null)
52
53// The wrap-up nudge shows once context or the 5-hour limit reaches these, and again after it drops back and returns.
54const NUDGE_CONTEXT = 75
55const NUDGE_FIVE_HOUR = 85
56
57// "Needs you" alerts: a request at least this long gets an alert when it finishes.
58const LONG_RUN_MS = 2 * 60_000
59const ALERT_TITLE = 'Claude needs you'
60// The engine's notifications Claude is waiting on an answer for.
61const ASKING_NOTICES = ['permission_prompt', 'elicitation_dialog']
62// How the toolkit runs its scripts in scripts\: Windows PowerShell, no profile, this run only.
63const POWERSHELL = ['powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File']
64
65// Clear, and Stop on a helper's card, count a second press within this long of the first.
66const CLEAR_ARM_MS = 5_000
67
68const REPORT_PANE = 'report'
69// What an hour of Claude's work is worth in Today's report, unless the person set their own rate.
70const DEFAULT_RATE = 120
71// Raised when the backfill counts differently, so it runs once more and redoes its past-day records.
72const BACKFILL_VERSION = 4
73// Tools that wait on the person's answer; that wait is left out of Claude's work time.
74const WAITING_TOOLS = ['AskUserQuestion', 'ExitPlanMode']
75// API prices in $ per million tokens (claude-api skill, cached 2026-10-06): input, output, 5-minute cache writes,
76// 1-hour cache writes, cache reads. They estimate the cost of work from past session files, which carry none.
77const PRICES: Record<string, readonly [number, number, number, number, number]> = {
78 'claude-fable-5-1': [10, 50, 12.5, 20, 0.25],
79 'claude-opus-5-5': [4, 20, 5, 8, 0.2],
80 'claude-sonnet-5-5': [2, 10, 2.5, 4, 0.2],
81 'claude-sonnet-5': [2, 10, 2.5, 4, 0.2],
82 // Haiku 5.5 at its price for prompts of 100K tokens or fewer; its cache prices aren't listed, so they follow Sonnet's
83 // ratios to its input price.
84 'claude-haiku-5-5': [0.1, 0.5, 0.125, 0.2, 0.01],
85}
86// The estimate check keeps this many past sessions.
87const CHECKS_KEPT = 20
88
89export const REPORT_TABS: readonly { key: ReportTab; label: string }[] = [
90 { key: 'today', label: 'Today' },
91 { key: 'week', label: 'Week' },
92 { key: 'month', label: 'Month' },
93 { key: 'year', label: 'Year' },
94 { key: 'all', label: 'All time' },
95]
96
97export const WORK_TYPES = [
98 { key: 'build', label: 'Building' },
99 { key: 'run', label: 'Running commands' },
100 { key: 'browser', label: 'Using the browser' },
101 { key: 'research', label: 'Reading & research' },
102 { key: 'helpers', label: 'Working with helpers' },
103 { key: 'talk', label: 'Planning & talk' },
104]
105
106// The Launch list, two to a row in this order, each with its icon and the icon's color.
107export const LAUNCHES = [
108 { key: 'start', label: 'Start session', short: 'Start', icon: '▶', color: 'claude' },
109 { key: 'tab', label: 'New tab', short: 'New tab', icon: '+', color: 'success' },
110 { key: 'report', label: "Today's report", short: 'Report', icon: '▤', color: 'warning' },
111 { key: 'folder', label: 'Open project folder', short: 'Folder', icon: '▣', color: 'ide' },
112]
113// The slot after the Launch list holds these two side by side, for a long conversation.
114export const FRESH = [
115 { key: 'clear', label: 'Clear', icon: '○', color: 'suggestion' },
116 { key: 'compact', label: 'Compact', icon: '≡', color: 'suggestion' },
117]
118// Below this column width the Launch list uses short names.
119const NARROW_LAUNCH = 22
120
121export const START_PROMPT =
122 'Start the session: read the project map ("Resume here" and "Waiting on you") and tell me where things stand ' +
123 'and the next step.'
124
125const HELP_PANE = 'help'
126const HELP = `**AP Toolkit** puts the things you'd otherwise type on buttons.
127
128| Row or button | What it does |
129| --- | --- |
130| **◆ Project name** | The strip's first line, above the prompt. It starts with this project's folder name, bold and in the project's color, or **Session** outside a project folder. Tokens are what Claude read new and wrote this session, helpers included; cache re-reads are left out, and Today's report still shows them. **At API prices** is Claude Code's own \`/cost\` figure: what the session would cost on the API, which your plan doesn't bill. **Context** is how full the conversation is. ◆ beats while Claude works. |
131| **Limits** | The strip's second line: how much of the 5-hour and weekly windows is left, and when each resets. When your pace would use up the 5-hour window before it resets, it says when, in amber. |
132| **Bars** | A bar fills as you use it, and its color says how full it is: green under 50%, amber from 50% to 79%, and red from 80%. That's the only color code. |
133| **Steps** | Shows above the strip while Claude works. The bar has one segment per step on Claude's to-do list: green when done, orange with a moving shine for the step it's on, and gray for what's left. With no to-do list, it reads **Working**. The step's clock turns amber after 3 minutes. On the right are this turn's tokens so far, as **in** (read new) and **out** (written), then its cost, helpers included. The checklist sits under it. |
134| **Time** | When you sent the message, how long Claude has worked, and when it should finish: the average finished step times the steps left. The estimate shows once a step finishes; without a to-do list, there's none. |
135| **Now** | What Claude is doing right now. It turns amber after 3 minutes on one thing. |
136| **Shells** | Each command Claude runs: ◐ running, ✓ worked, or ✗ failed with its exit code, then the command, the reason Claude gave, and its time. The newest 3 show, plus any that failed, until your next message. One that runs over 2 minutes turns amber. |
137| **Helpers** | One line per helper agent: its model, task, what it's doing now, tokens, cost at API prices (estimated from its tokens and model prices), and time. A helper's own helpers sit under it with ↳. **■** stops a working helper; press it twice within 5 seconds. A finished helper keeps its line until your next message. |
138| **Done** | After Claude finishes: when the turn started and ended, how long it took, how many shells and helpers ran, and the turn's tokens and cost. It stays until your next message, along with any helper still working. |
139| **Motion** | While Claude works, a new or changed part lights up briefly, numbers count up, bars fill smoothly, and ◆ beats. |
140| **Model** | Switches Claude's model. A pick made while Claude works applies when it finishes. |
141| **Effort** | How hard Claude thinks. Grayed out on models that don't use it. |
142| **Color** | This project's color, marked ✓, on its name in the strip and on tabs **New tab** opens. Each project gets one on its own, the one fewest projects have; press a swatch to change it. Other tabs open in this project change when they restart. Outside a project folder, it's the color of the folder Claude Code started in. |
143| **Helper agents** | Which model helper agents run on. Each press of its pill moves to the next: **Lowest that fits** lets Claude pick per task, **Fast & Cheap** uses Haiku, and **Same as me** uses Claude's own model. |
144| **Clean View** | Hides tool output (reads, edits, and commands), so Claude's words stand out. Questions and plans for your OK stay. The work view above the strip shows either way. |
145| **On / Off pills** | Press the pill at the right of a setting to switch it. The dot beside the setting turns green while it's on. |
146| **Start session** | Asks Claude to read the project map and tell you where things stand. |
147| **New tab** | Opens a Windows Terminal tab in this folder, in the project's color, and starts a fresh Claude session there, with an empty box. Tabs you open yourself stay plain. |
148| **Open project folder** | Opens this session's folder in File Explorer. |
149| **Today's report** | Claude's time, type of work, and usage by day, week, month, year, or all time. Also \`/report\`. A request that changed files counts as Building. Time Claude waits on your answers isn't counted as work. Work from before the toolkit started counting comes from past session files; its cost is estimated from tokens and model prices. Tokens and cost include helper agents. The estimate check line compares that estimate with Claude Code's own cost for past sessions. |
150| **Clear** | Starts a fresh conversation in this tab, with the same folder and model. Press it twice: the first press asks **Sure?** |
151| **Compact** | Claude summarizes the conversation so far and carries on from the summary, which frees up context. It reads the whole conversation once. A toast says when it's done. |
152| **▴ ▾** | **▴** at the right of the strip opens the controls box (model, effort, settings, and Launch), and **▾** closes it. It remembers which. |
153| **Status row** | The end of the gray hint line under the prompt: model, effort, and helpers working. |
154| **"Needs you" alerts** | A Windows notification when Claude asks a question, wants a plan OK or a permission, or finishes a request that took 2 minutes or more. The title names the project, such as **ClaudeMods needs you**, or reads **Claude needs you** outside a project folder. Turn them off in \`/config\`. |
155| **⚠ Folder check** | Warns when a session starts outside a project folder (one with its own CLAUDE.md). **Got it** hides it for this session. |
156| **⚠ Wrap-up nudge** | Shows when context reaches 75% or the 5-hour limit reaches 85%, with **Compact** and **Clear**. **Not now** hides it until the reading drops and climbs back. |
157| **Version** | The dim number in the controls box's bottom-right corner, such as v0.1.20: the second number is the major release, and the third is the batch in the project plan. A tab that shows an older number hasn't loaded the latest changes; restart it. |
158
159To change the report's hourly rate, open \`/config\`. Press **Esc** to close this help.`
160
161const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
162const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
163
164export const HELPER_MODES: readonly { key: ToolkitHelperMode; label: string; short: string }[] = [
165 { key: 'lowest', label: 'Lowest that fits', short: 'Lowest' },
166 { key: 'fast', label: 'Fast & Cheap', short: 'Cheap' },
167 { key: 'same', label: 'Same as me', short: 'Same' },
168]
169
170const LOWEST_THAT_FITS =
171 'When you start a helper agent, set its model to the lowest one that fits the task: haiku for mechanical work ' +
172 '(searching, listing, simple edits), sonnet for in-between reasoning, and opus only when the task truly needs it.'
173
174// Agent states that count as a helper still working, and how the work view names the ones that ended.
175const ACTIVE = ['pending', 'running', 'waiting']
176const ENDED: Partial<Record<string, ToolkitAgent['status']>> = { completed: 'done', failed: 'failed', killed: 'stopped' }
177
178// The work view: after this long on one step or action, its clock turns amber; a shell turns amber sooner.
179const SLOW_STEP_MS = 3 * 60_000
180const SLOW_SHELL_MS = 2 * 60_000
181const SPIN = ['◐', '◓', '◑', '◒']
182// Helpers' spinner, which each helper turns out of step.
183const DOTS = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
184// A checklist longer than this folds its finished items into one line.
185const MAX_ITEMS = 6
186// Tool rows Clean View keeps, because they hold something to read or answer.
187const KEEP_ROWS = ['AskUserQuestion', 'ExitPlanMode']
188// Lively motion: the clock moves this often while Claude or a helper works, a new or changed part keeps its highlight
189// this long, and a number counts up to a new value over this long.
190const FRAME_MS = 200
191const FLASH_MS = 700
192const TWEEN_MS = 600
193// The shine that sweeps the current step: it takes this many frames more than the step's cells to come round, and
194// lightens the cell it's on and, less, the cells beside it.
195const SHINE_REST = 5
196const SHINE_PEAK = '#FFE3D7'
197const SHINE_NEAR = '#F0A283'
198// The Steps bar: at most this many cells across, and at most 3 cells a step, with a one-cell gap between steps.
199const STEPS_BAR = 20
200// Shells: the newest this many show, plus up to this many older ones that failed; a command keeps this many
201// characters. The command and the helper's task share a column this wide, so the reasons line up.
202const SHELLS_SHOWN = 3
203const FAILED_KEPT = 3
204const COMMAND_KEPT = 200
205const WORK_COLUMN = 32
206// The rows the controls box takes when open: its border, title, divider, Model, Effort, two settings under their rule,
207// three Launch rows under theirs, and the version row. Once the folder check has run, the Color row adds one.
208const CONTROLS_ROWS = 14
209// How a finished helper's line is marked: done, stopped, or failed.
210const endMark = (a: ToolkitAgent) =>
211 a.status === 'done' ? { mark: '✓', color: 'success' } : a.status === 'stopped' ? { mark: '■', color: 'warning' } : { mark: '✗', color: 'error' }
212
213export const MODELS = [
214 { key: 'haiku', label: 'Haiku 5.5', short: 'Haiku', id: 'claude-haiku-5-5' },
215 { key: 'sonnet', label: 'Sonnet 5.5', short: 'Sonnet', id: 'claude-sonnet-5-5' },
216 { key: 'opus', label: 'Opus 5.5', short: 'Opus', id: 'claude-opus-5-5' },
217 { key: 'fable', label: 'Fable 5.1', short: 'Fable', id: 'claude-fable-5-1' },
218]
219
220export const EFFORTS = [
221 { key: 'low', label: 'Low', short: 'Low' },
222 { key: 'medium', label: 'Medium', short: 'Med' },
223 { key: 'high', label: 'High', short: 'High' },
224 { key: 'xhigh', label: 'xhigh', short: 'XHigh', chip: 'XHigh' },
225 { key: 'max', label: 'Max', short: 'Max' },
226]
227
228// A project's color, on its name in the strip and on tabs New tab opens. They skip green, amber, and red, which say
229// how full a bar is, and orange, which is Claude's.
230export const PROJECT_COLORS = [
231 { key: 'teal', label: 'Teal', hex: '#2EC4B6' },
232 { key: 'sky', label: 'Sky', hex: '#4CB4F0' },
233 { key: 'blue', label: 'Blue', hex: '#7B93FF' },
234 { key: 'violet', label: 'Violet', hex: '#B18CFF' },
235 { key: 'magenta', label: 'Magenta', hex: '#E07BE0' },
236 { key: 'silver', label: 'Silver', hex: '#C9CED6' },
237]
238// The longest project name the strip shows whole; a longer one is cut with "…".
239const NAME_MAX = 20
240
241// Below this width the rows use short names so they fit on one line.
242const NARROW = 64
243
244// The panel's box: its widest, the cells its border and padding take, and the label and setting-name columns.
245const PANEL_WIDTH = 74
246const PANEL_FRAME = 4
247const LABEL_WIDTH = 10
248const NAME_WIDTH = 17
249
250// The strip above the prompt: its label column, the cells in each bar, and the eighths a bar's last cell fills by.
251const STRIP_LABEL = 11
252const METER = 8
253const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
254// The 5-hour window's pace shows once this much of it has gone by, so the first minutes don't cry wolf.
255const FIVE_HOURS = 5 * 3_600_000
256const PACE_AFTER_MS = 15 * 60_000
257// At rest, the strip's reset countdown moves on once a minute.
258const STRIP_REFRESH_MS = 60_000
259
260// Which of the four models a name means: "claude-opus-5-5", "Opus 5.5" and "opus" are all Opus.
261export function family(name: string | undefined): string | null {
262 const n = (name ?? '').toLowerCase()
263 return MODELS.find(m => n.includes(m.key))?.key ?? null
264}
265
266// An effort level the panel shows, or null for anything else ("auto", a number, nothing).
267export function effortLevel(value: unknown): string | null {
268 const v = typeof value === 'string' ? value.trim().toLowerCase() : ''
269 return EFFORTS.some(x => x.key === v) ? v : null
270}
271
272export const modelLabel = (key: string | null) => MODELS.find(m => m.key === key)?.label ?? 'Model not known yet'
273export const effortLabel = (key: string | null) => EFFORTS.find(x => x.key === key)?.label
274
275function fiveHourOf(limits: readonly { kind: string; percentUsed: number }[]): number | null {
276 const l = limits.find(r => r.kind === 'five_hour')
277 return l ? Math.round(l.percentUsed) : null
278}
279
280const takesEffortOf = (hasEffort: boolean, noEffort: readonly string[], model: string | null) =>
281 hasEffort && !(model !== null && noEffort.includes(model))
282
283export function bar(percent: number, width: number): string {
284 const full = Math.round((Math.max(0, Math.min(100, percent)) / 100) * width)
285 return '█'.repeat(full) + '░'.repeat(width - full)
286}
287
288// A block bar: whole cells, then the cell the fill ends in by eighths, then the empty track.
289export function meter(percent: number, cells = METER): { full: string; part: string; empty: string } {
290 const eighths = Math.round((Math.max(0, Math.min(100, percent)) / 100) * cells * 8)
291 const whole = Math.floor(eighths / 8)
292 const part = EIGHTHS[eighths % 8] ?? ''
293 return { full: '█'.repeat(whole), part, empty: ' '.repeat(cells - whole - (part ? 1 : 0)) }
294}
295
296// A bar's color says how full it is, and nothing else: green under 50%, amber to 79%, red from 80%.
297export const tone = (percent: number) => (percent >= 80 ? 'error' : percent >= 50 ? 'warning' : 'success')
298
299// Token counts in the strip: 840, 84K, 1.24M; a whole million reads 1M.
300export const amount = (n: number) => (n >= 1_000_000 ? `${+(n / 1_000_000).toFixed(2)}M` : short(n))
301
302// An Eastern time of day as "3:10 PM" or "9 AM", with the weekday ("Thu 9 AM") when it falls on another day.
303export function easternClock(ms: number, now: number): string {
304 const d = new Date(ms + easternOffsetMs(ms))
305 const h = d.getUTCHours()
306 const m = d.getUTCMinutes()
307 const time = `${h % 12 || 12}${m > 0 ? `:${String(m).padStart(2, '0')}` : ''} ${h < 12 ? 'AM' : 'PM'}`
308 return easternDay(ms) === easternDay(now) ? time : `${WEEKDAYS[d.getUTCDay()]?.slice(0, 3)} ${time}`
309}
310
311const hoursMinutes = (ms: number) => {
312 const m = Math.max(1, Math.round(ms / 60_000))
313 return m >= 60 ? `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m` : `${m}m`
314}
315
316// A rate-limit window on the Limits line: how much is used (the bar), how much is left, and when it resets. When the
317// 5-hour window's pace so far would use it up before then, the note says when instead.
318export function limitText(l: ToolkitLimit, now: number): { used: number; left: string; note: string; isPace: boolean } {
319 const reset = l.resetsAt === undefined ? NaN : Date.parse(l.resetsAt)
320 if (Number.isFinite(reset) && reset <= now) {
321 return { used: 0, left: '100% left', note: `reset at ${easternClock(reset, now)}`, isPace: false }
322 }
323 const used = Math.max(0, l.percentUsed)
324 const left = `${Math.max(0, Math.round(100 - used))}% left`
325 if (!Number.isFinite(reset)) return { used, left, note: '', isPace: false }
326 if (l.kind === 'five_hour' && used > 0 && used < 100) {
327 const start = reset - FIVE_HOURS
328 const out = start + ((now - start) * 100) / used
329 if (now - start >= PACE_AFTER_MS && out < reset) {
330 return { used, left, note: `runs out about ${easternClock(out, now)} at this pace`, isPace: true }
331 }
332 }
333 const note = `resets ${easternClock(reset, now)}${l.kind === 'five_hour' ? ` (${hoursMinutes(reset - now)})` : ''}`
334 return { used, left, note, isPace: false }
335}
336
337export type StatusParts = {
338 model: string | null
339 effort: string | null
340 takesEffort: boolean
341 helpers: number
342}
343
344// The status row at the end of the gray hint line under the prompt: model · effort · helpers. The 5h and context bars
345// moved to the strip above the prompt. A part not known yet is left out.
346export function statusText(s: StatusParts): string {
347 return [
348 modelLabel(s.model),
349 s.takesEffort ? effortLabel(s.effort) : undefined,
350 s.helpers === 1 ? '1 helper working' : s.helpers > 1 ? `${s.helpers} helpers working` : undefined,
351 ]
352 .filter(Boolean)
353 .join(' · ')
354}
355
356// The strip's figures from the engine's latest reading; one it doesn't have yet keeps the last.
357async function noteUsage(
358 $: EngineInterface,
359 u: { context: { tokens?: number; percent?: number; window: number }; rateLimits: ToolkitLimit[]; cost?: { usd: number } },
360) {
361 try {
362 await update($, usageA, old => ({
363 costUsd: u.cost?.usd ?? old?.costUsd ?? null,
364 contextTokens: u.context.tokens ?? old?.contextTokens ?? null,
365 contextPercent: u.context.percent ?? old?.contextPercent ?? null,
366 contextWindow: u.context.window,
367 limits: u.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })),
368 }))
369 const now = await $.clock.now()
370 await update($, nowA, old => Math.max(old, now))
371 } catch {}
372}
373
374// This session's tokens as Today's report counts them: new ones in and out, helpers included, cache re-reads left out.
375// Work the report hasn't counted yet adds in: the main loop's request in progress (unless it was just counted) and
376// helpers still working, so the strip doesn't dip when one finishes. Resolves to the total, or null.
377async function countSessionTokens($: EngineInterface, isTurnCounted = false): Promise<number | null> {
378 try {
379 const id = await $.session.id()
380 let tokens = 0
381 for (const k of (await $.store.keys()).filter(k => k.startsWith('day:') && k.endsWith(`:${id}`))) {
382 const d = (await $.store.get(k)) as Partial<ReportDay> | undefined
383 tokens += (d?.tokensIn ?? 0) + (d?.tokensOut ?? 0)
384 }
385 if (!isTurnCounted) tokens += (await read($, runA))?.mainTokens ?? 0
386 tokens += ((await read($, agentsA)) ?? []).filter(isWorking).reduce((n, a) => n + a.tokens, 0)
387 await update($, sessionTokensA, () => tokens)
388 return tokens
389 } catch {
390 return null
391 }
392}
393
394// New tokens from one of Claude's or a helper's requests count into the strip right away; the main loop's also wait
395// in the run until the report counts its request. The ones written (`out`) count into this turn's "out".
396async function addTokens($: EngineInterface, tokens: number, isMain: boolean, out: number) {
397 if (tokens <= 0) return
398 try {
399 await update($, sessionTokensA, old => (old ?? 0) + tokens)
400 await update($, runA, r =>
401 r == null
402 ? null
403 : { ...r, ...(isMain ? { mainTokens: (r.mainTokens ?? 0) + tokens } : {}), outTokens: (r.outTokens ?? 0) + out },
404 )
405 } catch {}
406}
407
408// A helper's run that just started may not be listed yet, and one that just ended may still be.
409async function countHelpers($: EngineInterface, change: { started?: string; ended?: string } = {}) {
410 try {
411 const ids = new Set((await $.agent.list()).filter(a => ACTIVE.includes(a.status)).map(a => a.id))
412 if (change.started !== undefined) ids.add(change.started)
413 if (change.ended !== undefined) ids.delete(change.ended)
414 await update($, helpersA, () => ids.size)
415 } catch {}
416}
417
418export function duration(ms: number): string {
419 const s = Math.max(0, Math.round(ms / 1000))
420 const h = Math.floor(s / 3600)
421 const m = Math.floor((s % 3600) / 60)
422 if (h > 0) return `${h}h ${m}m`
423 if (m > 0) return `${m}m ${s % 60}s`
424 return `${s}s`
425}
426
427// Plain words for what Claude is doing right now, for the "Now:" line.
428export function doing(tool: string, input: Record<string, unknown> = {}): string {
429 const file = typeof input.file_path === 'string' ? input.file_path.split(/[\\/]/).pop() : undefined
430 const about = typeof input.description === 'string' && input.description ? input.description : undefined
431 if (tool === 'Bash' || tool === 'PowerShell') return about ? `running a command: ${about}` : 'running a command'
432 if (tool === 'Read') return file ? `reading ${file}` : 'reading a file'
433 if (tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit') return file ? `editing ${file}` : 'editing a file'
434 if (tool === 'Grep' || tool === 'Glob') return 'searching files'
435 if (tool === 'WebSearch' || tool === 'WebFetch') return 'looking on the web'
436 if (tool === 'Agent') return about ? `starting a helper: ${about}` : 'starting a helper'
437 if (tool === 'AskUserQuestion') return 'waiting for your answer'
438 if (tool === 'Skill') return 'loading a skill'
439 if (tool === 'TodoWrite' || tool === 'TaskCreate' || tool === 'TaskUpdate') return 'updating the checklist'
440 if (tool.startsWith('mcp__')) return `using ${tool.split('__')[1] ?? tool}`
441 return `using ${tool}`
442}
443
444const isTodoStatus = (s: unknown): s is ToolkitTodo['status'] =>
445 s === 'pending' || s === 'in_progress' || s === 'completed'
446
447// An item that turns in progress starts its clock; one already in progress keeps it. One that turns done notes when,
448// for the finish estimate; one already done keeps that.
449function withClock(t: ToolkitTodo, before: ToolkitTodo | undefined, now: number): ToolkitTodo {
450 const { startedAt: _, doneAt: __, ...rest } = t
451 if (t.status === 'completed') {
452 return { ...rest, doneAt: before?.status === 'completed' && before.doneAt !== undefined ? before.doneAt : now }
453 }
454 if (t.status !== 'in_progress') return rest
455 return { ...rest, startedAt: before?.status === 'in_progress' && before.startedAt !== undefined ? before.startedAt : now }
456}
457
458// The checklist after one of Claude's to-do tools ran: TodoWrite sends the whole list, TaskCreate and TaskUpdate one item.
459export function nextTodos(
460 old: readonly ToolkitTodo[],
461 tool: string,
462 input: Record<string, unknown>,
463 result: unknown,
464 now: number,
465): ToolkitTodo[] {
466 if (tool === 'TodoWrite' && Array.isArray(input.todos)) {
467 return input.todos.flatMap((raw: Record<string, unknown>) => {
468 if (typeof raw.content !== 'string' || !isTodoStatus(raw.status)) return []
469 const activeForm = typeof raw.activeForm === 'string' && raw.activeForm ? raw.activeForm : raw.content
470 const item = { content: raw.content, activeForm, status: raw.status }
471 return [withClock(item, old.find(o => o.content === raw.content), now)]
472 })
473 }
474 if (tool === 'TaskCreate' && typeof input.subject === 'string') {
475 const id = (result as { task?: { id?: unknown } } | undefined)?.task?.id
476 if (typeof id !== 'string') return [...old]
477 const activeForm = typeof input.activeForm === 'string' && input.activeForm ? input.activeForm : input.subject
478 return [...old, { id, content: input.subject, activeForm, status: 'pending' }]
479 }
480 if (tool === 'TaskUpdate' && typeof input.taskId === 'string') {
481 if (input.status === 'deleted') return old.filter(o => o.id !== input.taskId)
482 return old.map(o => {
483 if (o.id !== input.taskId) return o
484 const item = {
485 ...o,
486 content: typeof input.subject === 'string' ? input.subject : o.content,
487 activeForm: typeof input.activeForm === 'string' && input.activeForm ? input.activeForm : o.activeForm,
488 status: isTodoStatus(input.status) ? input.status : o.status,
489 }
490 return withClock(item, o, now)
491 })
492 }
493 return [...old]
494}
495
496export function doneText(d: ToolkitDone): string {
497 const word = d.isAborted ? '■ Stopped' : d.completed < d.total ? '◆ Waiting on you' : '✓ All done'
498 const steps = d.total > 0 ? `${d.completed} of ${d.total} steps` : undefined
499 return [word, `took ${duration(d.tookMs)}`, steps].filter(Boolean).join(' · ')
500}
501
502// A clock that ticks in the work view, with seconds kept two wide so it doesn't jump: 45s, 4m 05s, 1h 02m.
503export function ticking(ms: number): string {
504 const s = Math.max(0, Math.floor(ms / 1000))
505 const pad = (n: number) => String(n).padStart(2, '0')
506 if (s >= 3600) return `${Math.floor(s / 3600)}h ${pad(Math.floor((s % 3600) / 60))}m`
507 if (s >= 60) return `${Math.floor(s / 60)}m ${pad(s % 60)}s`
508 return `${s}s`
509}
510
511// Minutes and seconds, as a running shell or a helper shows them: 0:41, 12:05.
512export const minSec = (ms: number) => {
513 const s = Math.max(0, Math.floor(ms / 1000))
514 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
515}
516
517// How long a finished shell took: 0.4s, 18s, 1:05.
518export function shellTime(ms: number): string {
519 const s = Math.max(0, ms) / 1000
520 if (s < 10) return `${s.toFixed(1)}s`
521 if (s < 60) return `${Math.round(s)}s`
522 return minSec(ms)
523}
524
525// The exit code a failed command's error names ("Exit code 2"), if it names one.
526export function exitCodeOf(text: string): number | undefined {
527 const m = /exit code:?\s*(-?\d+)/i.exec(text)
528 return m?.[1] === undefined ? undefined : Number(m[1])
529}
530
531// The shells the work view keeps: the newest SHELLS_SHOWN, plus older ones still running and the newest FAILED_KEPT
532// older ones that failed, in the order they ran.
533export function keptShells(shells: readonly ToolkitShell[]): ToolkitShell[] {
534 const older = shells.slice(0, Math.max(0, shells.length - SHELLS_SHOWN))
535 const failed = older.filter(s => s.status === 'failed').slice(-FAILED_KEPT)
536 return [...older.filter(s => s.status === 'running' || failed.includes(s)), ...shells.slice(older.length)]
537}
538
539// How many rows a row of parts takes when each part that doesn't fit after the one before moves to a new line. A part
540// wider than the room is cut, so it takes one row.
541export function packRows(widths: readonly number[], gap: number, room: number): number {
542 const fit = Math.max(1, room)
543 let rows = 0
544 let x = 0
545 for (const w of widths.map(n => Math.min(n, fit))) {
546 if (rows === 0 || x + gap + w > fit) {
547 rows++
548 x = w
549 } else x += gap + w
550 }
551 return Math.max(1, rows)
552}
553
554// Which parts of the work view fit in so many rows: parts give way in the order named until the rest fit. A part not
555// named never gives way.
556export function fitParts(rows: number, parts: readonly { key: string; rows: number }[], order: readonly string[]): Set<string> {
557 const kept = new Set(parts.map(p => p.key))
558 let total = parts.reduce((n, p) => n + p.rows, 0)
559 for (const key of order) {
560 if (total <= rows) break
561 const p = parts.find(x => x.key === key)
562 if (p !== undefined && kept.delete(key)) total -= p.rows
563 }
564 return kept
565}
566
567// Cells per step in the Steps bar: 3 each, fewer for a long list, never under 1.
568export const stepCells = (steps: number) => Math.max(1, Math.min(3, Math.floor((STEPS_BAR + 1) / Math.max(1, steps)) - 1))
569
570// When Claude should finish: the average step finished this turn, times the steps left, after the last one finished;
571// never sooner than 30 seconds from now. Null until a step finishes this turn.
572export function finishAt(todos: readonly ToolkitTodo[], startedAt: number, now: number): number | null {
573 const finished = todos.filter(t => t.status === 'completed' && t.doneAt !== undefined && t.doneAt >= startedAt)
574 if (finished.length === 0) return null
575 const last = Math.max(...finished.map(t => t.doneAt ?? startedAt))
576 const left = todos.filter(t => t.status !== 'completed').length
577 return Math.max(last + ((last - startedAt) / finished.length) * left, now + 30_000)
578}
579
580export const leftText = (ms: number) => (ms < 60_000 ? 'under 1m left' : `${hoursMinutes(ms)} left`)
581
582// From one Eastern time to another, as "2:14 to 2:18 PM" when both share AM or PM on the same day.
583export function spanText(from: number, to: number, now: number): string {
584 const a = easternClock(from, now)
585 const b = easternClock(to, now)
586 const suffix = b.slice(-3)
587 return `${a.endsWith(suffix) && easternDay(from) === easternDay(to) ? a.slice(0, -3) : a} to ${b}`
588}
589
590// The work view's Done line: how the turn ended, when, how long it took, and the shells and helpers it folds in.
591export function doneLine(d: ToolkitDone, now: number): { mark: string; color: string; text: string } {
592 const mark = d.isAborted
593 ? { mark: '■', color: 'warning', word: 'Stopped' }
594 : d.completed < d.total
595 ? { mark: '◆', color: 'claude', word: 'Waiting on you' }
596 : { mark: '✓', color: 'success', word: undefined }
597 const shells = d.shells ? `${plural(d.shells, 'shell')}${d.failed ? ` (${d.failed} failed)` : ''}` : undefined
598 const text = [
599 mark.word,
600 d.startedAt !== undefined && d.endedAt !== undefined ? spanText(d.startedAt, d.endedAt, now) : undefined,
601 `took ${ticking(d.tookMs)}`,
602 d.completed < d.total ? `${d.completed} of ${d.total} steps` : undefined,
603 shells,
604 d.helpers ? plural(d.helpers, 'helper') : undefined,
605 ]
606 .filter(Boolean)
607 .join(' · ')
608 return { mark: mark.mark, color: mark.color, text }
609}
610
611// What a helper's line says it's doing: the latest action while it works, else how it ended.
612export const helperDoing = (a: ToolkitAgent) =>
613 isWorking(a) ? a.doing : a.status === 'done' ? 'finished' : a.status === 'stopped' ? 'stopped' : 'failed'
614
615// The work view's order: each helper, then the helpers it started, one level deeper. A helper whose parent isn't in
616// the list starts a family of its own.
617export function nest(agents: readonly ToolkitAgent[]): { agent: ToolkitAgent; depth: number }[] {
618 const out: { agent: ToolkitAgent; depth: number }[] = []
619 const seen = new Set<string>()
620 const add = (a: ToolkitAgent, depth: number) => {
621 if (seen.has(a.id)) return
622 seen.add(a.id)
623 out.push({ agent: a, depth })
624 for (const child of agents) if (child.parentId === a.id) add(child, depth + 1)
625 }
626 for (const a of agents) if (!agents.some(p => p.id === a.parentId)) add(a, 0)
627 // Anything left (a loop of parents, which shouldn't happen) still gets a line.
628 for (const a of agents) add(a, 0)
629 return out
630}
631
632// Ticks once a second while Claude or a helper works, so the clocks move. While the band is being drawn, frames move
633// it every FRAME_MS as well, so the shine sweeps and numbers count up; with nothing drawing it, the frames stop.
634let ticker: Timer | null = null
635let frames: Timer | null = null
636let stripTimer: Timer | null = null
637let lastDrawn = 0
638
639const moveClock = ($: EngineInterface) =>
640 void $.clock
641 .now()
642 .then(now => update($, nowA, () => now))
643 .catch(() => {})
644
645function startTicker($: EngineInterface) {
646 if (ticker !== null) return
647 ticker = $.clock.every(1000, () => moveClock($))
648}
649
650// Called as the band draws while something moves; the frames stop once the band hasn't drawn for a second.
651function startFrames($: EngineInterface, now: number) {
652 lastDrawn = now
653 if (frames !== null) return
654 try {
655 frames = $.clock.every(FRAME_MS, () => {
656 void $.clock
657 .now()
658 .then(async t => {
659 if (t - lastDrawn > 1000) stopFrames()
660 else await update($, nowA, () => t)
661 })
662 .catch(() => {})
663 })
664 } catch {}
665}
666
667function stopFrames() {
668 frames?.cancel()
669 frames = null
670}
671
672// Numbers count up and bars fill smoothly while the clock moves: each shown value eases from where it was toward its
673// new one over TWEEN_MS. At rest a value shows as it is.
674const tweens = new Map<string, { from: number; to: number; at: number }>()
675
676function tweened(key: string, target: number, now: number, isMoving: boolean): number {
677 const t = tweens.get(key)
678 const eased = (x: { from: number; to: number; at: number }) => {
679 const f = Math.min(1, Math.max(0, (now - x.at) / TWEEN_MS))
680 return x.from + (x.to - x.from) * (1 - (1 - f) ** 3)
681 }
682 if (!isMoving || t === undefined) {
683 tweens.set(key, { from: target, to: target, at: now })
684 return target
685 }
686 if (t.to === target) return eased(t)
687 const shown = eased(t)
688 tweens.set(key, { from: shown, to: target, at: now })
689 return shown
690}
691
692function stopTicker() {
693 ticker?.cancel()
694 ticker = null
695 stopFrames()
696}
697
698async function setDoing($: EngineInterface, words: string) {
699 const now = await $.clock.now()
700 await update($, runA, r => (r == null ? null : r.doing === words ? r : { ...r, doing: words, doingSince: now }))
701}
702
703const isWorking = (a: ToolkitAgent) => a.status === 'working'
704// New tokens in and out, without what Claude re-read from cache.
705const newTokens = (u: TurnTokens | undefined) => (u ? u.input_tokens + u.cache_creation_input_tokens + u.output_tokens : 0)
706
707// A helper that just started gets a line in the work view, under the helper that started it if one did.
708async function startHelper($: EngineInterface, id: string, parentId: string | undefined, task: string, model: string) {
709 try {
710 const now = await $.clock.now()
711 const line: ToolkitAgent = {
712 id,
713 ...(parentId === undefined ? {} : { parentId }),
714 task,
715 model: family(model),
716 startedAt: now,
717 status: 'working',
718 doing: 'starting',
719 tokens: 0,
720 costUsd: 0,
721 }
722 await update($, agentsA, old => [...(old ?? []).filter(a => a.id !== id), line])
723 await update($, nowA, () => now)
724 startTicker($)
725 } catch {}
726}
727
728let shellCount = 0
729
730// A shell Claude starts gets a line in the work view, with the command on one line and the reason Claude gave.
731async function startShell($: EngineInterface, input: Record<string, unknown>): Promise<string | null> {
732 try {
733 const now = await $.clock.now()
734 const id = `${now}-${++shellCount}`
735 const shell: ToolkitShell = {
736 id,
737 command: typeof input.command === 'string' ? input.command.replace(/\s+/g, ' ').trim().slice(0, COMMAND_KEPT) : '',
738 why: typeof input.description === 'string' ? input.description : '',
739 startedAt: now,
740 status: 'running',
741 }
742 await update($, shellsA, old => keptShells([...(old ?? []), shell]))
743 await update($, runA, r => (r == null ? null : { ...r, shells: (r.shells ?? 0) + 1 }))
744 return id
745 } catch {
746 return null
747 }
748}
749
750// A shell's line shows how it ended: worked, failed with the exit code its error names, or moved to the background.
751async function endShell($: EngineInterface, id: string, result: { deny?: string; isError?: true; text?: string; result?: unknown }) {
752 try {
753 const now = await $.clock.now()
754 const isFailed = result.deny !== undefined || result.isError === true
755 const isBackground =
756 !isFailed && typeof (result.result as { backgroundTaskId?: unknown } | undefined)?.backgroundTaskId === 'string'
757 const code = isFailed ? exitCodeOf(result.deny ?? result.text ?? '') : undefined
758 const status: ToolkitShell['status'] = isFailed ? 'failed' : isBackground ? 'background' : 'done'
759 await update($, shellsA, old =>
760 keptShells(
761 (old ?? []).map(s => (s.id === id ? { ...s, endedAt: now, status, ...(code === undefined ? {} : { exitCode: code }) } : s)),
762 ),
763 )
764 if (isFailed) await update($, runA, r => (r == null ? null : { ...r, failed: (r.failed ?? 0) + 1 }))
765 } catch {}
766}
767
768async function updateAgent($: EngineInterface, id: string, change: (a: ToolkitAgent) => ToolkitAgent) {
769 try {
770 await update($, agentsA, old => (old ?? []).map(a => (a.id === id ? change(a) : a)))
771 } catch {}
772}
773
774// A helper's line shows how it ended; the clock stops once neither Claude nor a helper is working.
775async function endAgent($: EngineInterface, id: string, reason: string, usage: TurnTokens | undefined) {
776 try {
777 const now = await $.clock.now()
778 const status = reason === 'answer' ? 'done' : reason === 'aborted' ? 'stopped' : 'failed'
779 // A helper the person stopped stays stopped, whatever its own end says.
780 await updateAgent($, id, a => ({
781 ...a,
782 status: a.status === 'stopped' ? 'stopped' : status,
783 doing: '',
784 endedAt: now,
785 tokens: Math.max(a.tokens, newTokens(usage)),
786 }))
787 if (!((await read($, agentsA)) ?? []).some(isWorking) && (await read($, runA)) == null) stopTicker()
788 } catch {}
789}
790
791let stopTimer: Timer | null = null
792
793// ■ on a helper's line ends it on a second press within CLEAR_ARM_MS, through Claude Code's own TaskStop tool.
794// Claude hears that the helper stopped, as when Claude stops one itself.
795async function pressStop($: EngineInterface, id: string) {
796 stopTimer?.cancel()
797 stopTimer = null
798 if ((await read($, stopArmedA)) !== id) {
799 await update($, stopArmedA, () => id)
800 stopTimer = $.clock.after(CLEAR_ARM_MS, () => void update($, stopArmedA, () => null))
801 $.ui.toast('Press Stop again to stop this helper.')
802 return
803 }
804 await update($, stopArmedA, () => null)
805 try {
806 const r = await $.tool.call({ tool: 'TaskStop', task_id: id })
807 if (r.deny !== undefined || r.isError) {
808 $.ui.toast(`Couldn't stop that helper: ${r.deny ?? r.text ?? 'Claude Code gave no reason.'}`)
809 return
810 }
811 const task = ((await read($, agentsA)) ?? []).find(a => a.id === id)?.task
812 await endAgent($, id, 'aborted', undefined)
813 $.ui.toast(task === undefined ? 'Helper stopped.' : `Stopped "${task}".`)
814 } catch {
815 $.ui.toast("Couldn't stop that helper. Ask Claude to stop it.")
816 }
817}
818
819// A line still working whose helper the engine lists as ended is marked ended, so a missed end can't keep it spinning.
820async function settleAgents($: EngineInterface) {
821 try {
822 if (!((await read($, agentsA)) ?? []).some(isWorking)) return
823 const listed = await $.agent.list()
824 const now = await $.clock.now()
825 await update($, agentsA, old =>
826 (old ?? []).map(a => {
827 const status = ENDED[listed.find(l => l.id === a.id)?.status ?? '']
828 return isWorking(a) && status !== undefined ? { ...a, status, doing: '', endedAt: now } : a
829 }),
830 )
831 } catch {}
832}
833
834// The person's next message starts the work view over: the shells go, and so do the helpers that finished.
835async function clearWork($: EngineInterface) {
836 try {
837 await settleAgents($)
838 await update($, agentsA, old => ((old ?? []).some(a => !isWorking(a)) ? (old ?? []).filter(isWorking) : (old ?? [])))
839 await update($, shellsA, old => ((old ?? []).length > 0 ? [] : (old ?? [])))
840 } catch {}
841}
842
843async function setCleanView($: EngineInterface, value: boolean) {
844 await update($, cleanViewA, () => value)
845 try {
846 await $.store.set('cleanView', value)
847 } catch {}
848}
849
850async function setHelperMode($: EngineInterface, value: ToolkitHelperMode) {
851 await update($, helperModeA, () => value)
852 try {
853 await $.store.set('helperMode', value)
854 } catch {}
855}
856
857export function short(n: number): string {
858 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
859 if (n >= 1_000) return `${Math.round(n / 1_000)}K`
860 return `${Math.round(n)}`
861}
862
863const money = (usd: number) => `$${Math.max(0, usd).toFixed(2)}`
864const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
865
866// Eastern Time is UTC−4 from the second Sunday of March at 2:00 to the first Sunday of November at 2:00, else UTC−5.
867function easternOffsetMs(ms: number): number {
868 const y = new Date(ms).getUTCFullYear()
869 const sunday = (month: number, n: number) => 1 + ((7 - new Date(Date.UTC(y, month, 1)).getUTCDay()) % 7) + 7 * (n - 1)
870 const start = Date.UTC(y, 2, sunday(2, 2), 7)
871 const end = Date.UTC(y, 10, sunday(10, 1), 6)
872 return (ms >= start && ms < end ? -4 : -5) * 3_600_000
873}
874
875// The Eastern date of a moment, as YYYY-MM-DD.
876export function easternDay(ms: number): string {
877 return new Date(ms + easternOffsetMs(ms)).toISOString().slice(0, 10)
878}
879
880// An Eastern moment as "October 18, 1:32 PM".
881export function easternWhen(ms: number): string {
882 const d = new Date(ms + easternOffsetMs(ms))
883 const h = d.getUTCHours()
884 const minutes = String(d.getUTCMinutes()).padStart(2, '0')
885 return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}, ${h % 12 || 12}:${minutes} ${h < 12 ? 'AM' : 'PM'}`
886}
887
888const noonOf = (day: string) => new Date(`${day}T12:00:00Z`)
889const addDays = (day: string, n: number) => new Date(noonOf(day).getTime() + n * 86_400_000).toISOString().slice(0, 10)
890
891// The first day a tab covers: today, this week from Monday, this month, this year, or everything.
892export function periodStart(tab: ReportTab, today: string): string {
893 if (tab === 'today') return today
894 if (tab === 'week') return addDays(today, -((noonOf(today).getUTCDay() + 6) % 7))
895 if (tab === 'month') return `${today.slice(0, 8)}01`
896 if (tab === 'year') return `${today.slice(0, 5)}01-01`
897 return '0000-01-01'
898}
899
900function periodTitle(tab: ReportTab, today: string, from: string): string {
901 const d = noonOf(today)
902 const monthDay = (day: string) => `${MONTHS[noonOf(day).getUTCMonth()]} ${noonOf(day).getUTCDate()}`
903 if (tab === 'today') return `${WEEKDAYS[d.getUTCDay()]}, ${monthDay(today)}`
904 if (tab === 'week') return `week of ${monthDay(from)}`
905 if (tab === 'month') return `${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`
906 if (tab === 'year') return `${d.getUTCFullYear()}`
907 return 'all time'
908}
909
910function kindOfTool(tool: string): string | null {
911 if (tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit') return 'build'
912 if (tool === 'Bash' || tool === 'PowerShell') return 'run'
913 if (tool.startsWith('mcp__claude-in-chrome__') || tool.includes('Claude_Browser')) return 'browser'
914 if (tool === 'Read' || tool === 'Grep' || tool === 'Glob' || tool === 'WebSearch' || tool === 'WebFetch') return 'research'
915 if (tool === 'Agent') return 'helpers'
916 return null
917}
918
919// A request's type of work, judged from the tools it used: Building when it changed files (Claude reads before it
920// edits, so reads outnumber edits), else the kind it used most; with none, planning and talk.
921export function workType(tools: readonly string[]): string {
922 const counts = new Map<string, number>()
923 for (const t of tools) {
924 const kind = kindOfTool(t)
925 if (kind !== null) counts.set(kind, (counts.get(kind) ?? 0) + 1)
926 }
927 if (counts.has('build')) return 'build'
928 let best = 'talk'
929 let most = 0
930 for (const w of WORK_TYPES) {
931 const n = counts.get(w.key) ?? 0
932 if (n > most) {
933 best = w.key
934 most = n
935 }
936 }
937 return best
938}
939
940const emptyDay = (): ReportDay => ({
941 sessions: [],
942 requests: 0,
943 ms: 0,
944 types: {},
945 tokensIn: 0,
946 tokensCached: 0,
947 tokensOut: 0,
948 costUsd: 0,
949 ownCalls: 0,
950 ownTokens: 0,
951})
952
953// Each session keeps its own record per day (`day:YYYY-MM-DD:<session>`), so two tabs finishing at once can't
954// overwrite each other's counts.
955async function updateDay($: EngineInterface, now: number, change: (d: ReportDay, session: string) => ReportDay) {
956 try {
957 let session = 'unknown'
958 try {
959 session = await $.session.id()
960 } catch {}
961 const key = `day:${easternDay(now)}:${session}`
962 const old = (await $.store.get(key)) as Partial<ReportDay> | undefined
963 await $.store.set(key, change({ ...emptyDay(), ...(old ?? {}) }, session))
964 } catch {}
965}
966
967// What the session has spent since it was last counted into the report: Claude's requests, helper agents (also those
968// that finish after Claude's reply), and anything in between. /clear starts the session's cost over.
969async function spentSinceCounted($: EngineInterface): Promise<number> {
970 try {
971 const u = await $.session.usage()
972 if (u.cost === undefined) return 0
973 const usd = u.cost.usd
974 let spent = 0
975 await update($, costSeenA, old => {
976 const seen = old === null ? usd : old.since === u.startedAt ? old.usd : 0
977 spent = Math.max(0, usd - seen)
978 return { usd: Math.max(seen, usd), since: u.startedAt }
979 })
980 return spent
981 } catch {
982 return 0
983 }
984}
985
986// The estimate check's record of this session: Claude Code's own cost so far. A later session checks it.
987async function noteSessionCost($: EngineInterface) {
988 try {
989 const u = await $.session.usage()
990 if (u.cost === undefined || u.cost.usd <= 0) return
991 const record: SessionCostCheck = { claudeUsd: u.cost.usd, since: u.startedAt, at: await $.clock.now() }
992 await $.store.set(`costCheck:${await $.session.id()}`, record)
993 } catch {}
994}
995
996// Counts a finished helper agent's tokens into today's report. Its time falls inside the request that started it.
997async function recordHelper($: EngineInterface, usage: TurnTokens | undefined) {
998 try {
999 const now = await $.clock.now()
1000 const spent = await spentSinceCounted($)
1001 await noteSessionCost($)
1002 await updateDay($, now, d => ({
1003 ...d,
1004 tokensIn: d.tokensIn + (usage ? usage.input_tokens + usage.cache_creation_input_tokens : 0),
1005 tokensCached: d.tokensCached + (usage?.cache_read_input_tokens ?? 0),
1006 tokensOut: d.tokensOut + (usage?.output_tokens ?? 0),
1007 costUsd: d.costUsd + spent,
1008 }))
1009 } catch {}
1010}
1011
1012// Counts one finished request of the main loop into today's report. Tokens in are the new ones; the conversation
1013// Claude re-reads from cache at every step is kept apart. Time spent waiting on the person's answers isn't work.
1014async function recordTurn($: EngineInterface, durationMs: number, usage: TurnTokens | undefined, run: ToolkitRun | null) {
1015 try {
1016 const now = await $.clock.now()
1017 const type = workType(run?.tools ?? [])
1018 const spent = await spentSinceCounted($)
1019 await noteSessionCost($)
1020 const waited = Math.min(durationMs, run?.waitMs ?? 0)
1021 const ms = durationMs - waited
1022 await updateDay($, now, (d, session) => ({
1023 ...d,
1024 sessions: d.sessions.includes(session) ? d.sessions : [...d.sessions, session],
1025 requests: d.requests + 1,
1026 ms: d.ms + ms,
1027 types: {
1028 ...d.types,
1029 [type]: { requests: (d.types[type]?.requests ?? 0) + 1, ms: (d.types[type]?.ms ?? 0) + ms },
1030 },
1031 tokensIn: d.tokensIn + (usage ? usage.input_tokens + usage.cache_creation_input_tokens : 0),
1032 tokensCached: d.tokensCached + (usage?.cache_read_input_tokens ?? 0),
1033 tokensOut: d.tokensOut + (usage?.output_tokens ?? 0),
1034 costUsd: d.costUsd + spent,
1035 ...(waited > 0 ? { waitMs: (d.waitMs ?? 0) + waited } : {}),
1036 }))
1037 } catch {}
1038}
1039
1040type TurnTokens = {
1041 input_tokens: number
1042 output_tokens: number
1043 cache_read_input_tokens: number
1044 cache_creation_input_tokens: number
1045}
1046
1047// A request's cost at API prices, from its model and tokens; null for a model with no price.
1048export function estimatedCost(
1049 model: string,
1050 t: { uncached: number; writes5m: number; writes1h: number; cached: number; out: number },
1051): number | null {
1052 if (t.uncached + t.writes5m + t.writes1h + t.cached + t.out === 0) return 0
1053 const key = Object.keys(PRICES)
1054 .filter(k => model.startsWith(k))
1055 .sort((a, b) => b.length - a.length)[0]
1056 const p = key === undefined ? undefined : PRICES[key]
1057 if (p === undefined) return null
1058 return (t.uncached * p[0] + t.out * p[1] + t.writes5m * p[2] + t.writes1h * p[3] + t.cached * p[4]) / 1_000_000
1059}
1060
1061// One line of the backfill script: a request, or from version 4 on a helper agent's tokens (a `helper` line, whose
1062// time falls inside the request that started it). `cost` is absent on lines from before version 3, which carry no
1063// model, and null for a model with no price.
1064type PastLine = {
1065 isHelper: boolean
1066 at: number
1067 session: string
1068 tools: string[]
1069 took: number
1070 waited: number
1071 tokensIn: number
1072 tokensCached: number
1073 tokensOut: number
1074 cost?: number | null
1075}
1076
1077function pastLine(line: string): PastLine | null {
1078 const fields = line.replace(/\r$/, '').split('\t')
1079 const isHelper = fields[0] === 'helper'
1080 const [ts, session, tools, took, tokensIn, tokensCached, tokensOut, model, uncached, writes5m, writes1h, waited] =
1081 isHelper ? [fields[1], fields[2], '', '0', ...fields.slice(3)] : fields
1082 const at = Date.parse(ts ?? '')
1083 if (!session || Number.isNaN(at)) return null
1084 return {
1085 isHelper,
1086 at,
1087 session,
1088 tools: tools ? tools.split(',') : [],
1089 took: Number(took) || 0,
1090 waited: Number(waited) || 0,
1091 tokensIn: Number(tokensIn) || 0,
1092 tokensCached: Number(tokensCached) || 0,
1093 tokensOut: Number(tokensOut) || 0,
1094 cost:
1095 model === undefined
1096 ? undefined
1097 : estimatedCost(model, {
1098 uncached: Number(uncached) || 0,
1099 writes5m: Number(writes5m) || 0,
1100 writes1h: Number(writes1h) || 0,
1101 cached: Number(tokensCached) || 0,
1102 out: Number(tokensOut) || 0,
1103 }),
1104 }
1105}
1106
1107// Turns the backfill script's lines into totals per Eastern day, for the requests made before live counting began.
1108// Lines from version 3 on add the model, the token split that prices them, and the time spent waiting on answers.
1109// Version 4 adds helper agents' tokens and cost.
1110export function backfillDays(text: string, beforeMs: number): Record<string, ReportDay> {
1111 const days: Record<string, ReportDay> = {}
1112 for (const line of text.split('\n')) {
1113 const p = pastLine(line)
1114 if (p === null || p.at >= beforeMs) continue
1115 const d = (days[easternDay(p.at)] ??= { ...emptyDay(), isBackfilled: true })
1116 if (!p.isHelper) {
1117 const type = workType(p.tools)
1118 const wait = Math.min(p.took, p.waited)
1119 const ms = p.took - wait
1120 if (!d.sessions.includes(p.session)) d.sessions.push(p.session)
1121 d.requests += 1
1122 d.ms += ms
1123 d.types[type] = { requests: (d.types[type]?.requests ?? 0) + 1, ms: (d.types[type]?.ms ?? 0) + ms }
1124 if (wait > 0) d.waitMs = (d.waitMs ?? 0) + wait
1125 }
1126 d.tokensIn += p.tokensIn
1127 d.tokensCached += p.tokensCached
1128 d.tokensOut += p.tokensOut
1129 if (p.cost !== undefined) {
1130 d.costUsd += p.cost ?? 0
1131 d.isCostEstimated = d.isCostEstimated !== false && p.cost !== null
1132 }
1133 }
1134 return days
1135}
1136
1137// The estimate check: each session's cost estimated from its session files, helpers included, counting only the
1138// work between the start of Claude Code's own count and the moment it was taken. Null where a model has no price.
1139export function sessionEstimates(
1140 text: string,
1141 windows: Record<string, { since: number; at: number }>,
1142): Record<string, number | null> {
1143 const estimates: Record<string, number | null> = {}
1144 for (const line of text.split('\n')) {
1145 const p = pastLine(line)
1146 const w = p === null ? undefined : windows[p.session]
1147 if (p === null || w === undefined || p.at < w.since || p.at > w.at) continue
1148 const old = estimates[p.session]
1149 estimates[p.session] = old === null || p.cost == null ? null : (old ?? 0) + p.cost
1150 }
1151 return estimates
1152}
1153
1154export function checkText(c: ToolkitCheck): string {
1155 const apart = Math.round(((c.estimateUsd - c.claudeUsd) / c.claudeUsd) * 100)
1156 const gap = apart === 0 ? 'they match' : `${Math.abs(apart)}% ${apart < 0 ? 'lower' : 'higher'}`
1157 return `Estimate check: ${plural(c.sessions, 'past session')} · Claude Code ${money(c.claudeUsd)} · estimate ${money(c.estimateUsd)} (${gap}).`
1158}
1159
1160// Once per BACKFILL_VERSION: the past year of session files fills in everything before live counting began, as one
1161// `day:YYYY-MM-DD:past` record per day. It replaces the whole-day records earlier versions kept.
1162async function backfill($: EngineInterface) {
1163 try {
1164 const home = await $.env.get('USERPROFILE')
1165 if (!home) return
1166 const now = await $.clock.now()
1167 const liveSince = Number(await $.store.get('liveSince')) || now
1168 const script = `${$.plugin.root}/scripts/backfill.ps1`
1169 const since = new Date(now - 366 * 86_400_000).toISOString()
1170 const r = await $.process.run([...POWERSHELL, script, '-Projects', `${home}\\.claude\\projects`, '-Since', since], {
1171 timeoutMs: 600_000,
1172 })
1173 if (r.exitCode !== 0) return
1174 const days = backfillDays(r.stdout, liveSince)
1175 for (const k of await $.store.keys()) {
1176 if (/^day:\d{4}-\d{2}-\d{2}(:past)?$/.test(k)) await $.store.delete(k)
1177 }
1178 for (const [day, d] of Object.entries(days)) await $.store.set(`day:${day}:past`, d)
1179 await $.store.set('backfilledAt', now)
1180 await $.store.set('backfillVersion', BACKFILL_VERSION)
1181 } catch {}
1182}
1183
1184// At session start: each earlier session not checked yet gets the toolkit's estimate for the same span, read from its
1185// session files by the backfill script. The newest CHECKS_KEPT checked sessions are kept.
1186async function checkEstimates($: EngineInterface) {
1187 try {
1188 const current = await $.session.id()
1189 const checks: Record<string, SessionCostCheck> = {}
1190 for (const k of await $.store.keys()) {
1191 const c = k.startsWith('costCheck:') ? ((await $.store.get(k)) as SessionCostCheck | undefined) : undefined
1192 if (c) checks[k.slice('costCheck:'.length)] = c
1193 }
1194 const due = Object.keys(checks).filter(s => s !== current && checks[s]?.estimateUsd === undefined)
1195 const home = await $.env.get('USERPROFILE')
1196 if (due.length > 0 && home) {
1197 const since = new Date(Math.min(...due.map(s => checks[s]?.since ?? 0)) - 86_400_000).toISOString()
1198 const script = `${$.plugin.root}/scripts/backfill.ps1`
1199 const r = await $.process.run(
1200 [...POWERSHELL, script, '-Projects', `${home}\\.claude\\projects`, '-Since', since, '-Sessions', due.join(',')],types/index.d.ts 196 lines1// A switch pressed while Claude works waits here until the command runs.
2export type ToolkitPending = { model?: string; effort?: string }
3
4// One item of Claude's to-do list as the checklist draws it; `startedAt` while it's in progress, `doneAt` once done.
5export type ToolkitTodo = {
6 id?: string
7 content: string
8 activeForm: string
9 status: 'pending' | 'in_progress' | 'completed'
10 startedAt?: number
11 doneAt?: number
12}
13
14// The turn Claude is working on: when it started, what it's doing now and since when, the tools it has used,
15// and how long it has waited on the person's answers. The work view adds the session's tokens and cost when it
16// started, the main loop's new tokens not yet counted into the report, the tokens Claude and helpers wrote this turn
17// (`outTokens`), and the shells it ran and how many failed.
18export type ToolkitRun = {
19 startedAt: number
20 doing: string
21 doingSince: number
22 tools: string[]
23 waitMs?: number
24 tokensAtStart?: number
25 costAtStart?: number | null
26 mainTokens?: number
27 outTokens?: number
28 shells?: number
29 failed?: number
30}
31
32// One shell command in the work view: the command, the reason Claude gave, when it ran, and how it ended. A failed
33// one keeps its exit code when the error names one.
34export type ToolkitShell = {
35 id: string
36 command: string
37 why: string
38 startedAt: number
39 endedAt?: number
40 status: 'running' | 'done' | 'failed' | 'background'
41 exitCode?: number
42}
43
44// The session's cost as Claude Code counts it, at the last time the report counted it, and when that count began
45// (`$.session.usage().startedAt`; /clear starts it over).
46export type ToolkitCostSeen = { usd: number; since: number }
47
48// One Eastern day of Today's report, kept in $.store as `day:YYYY-MM-DD:<session>` live and `day:YYYY-MM-DD:past`
49// backfilled. Tokens in are new ones; `tokensCached` is what Claude re-read from cache. Tokens and cost include
50// helper agents (backfilled from version 4 on). `ms` leaves out the time Claude waited on the person's answers, kept
51// in `waitMs`. A backfilled day's cost is estimated from tokens and model prices when `isCostEstimated`; days
52// backfilled before version 3 have no cost.
53export type ReportDay = {
54 sessions: string[]
55 requests: number
56 ms: number
57 types: Record<string, { requests: number; ms: number }>
58 tokensIn: number
59 tokensCached: number
60 tokensOut: number
61 costUsd: number
62 ownCalls: number
63 ownTokens: number
64 waitMs?: number
65 isBackfilled?: boolean
66 isCostEstimated?: boolean
67}
68
69export type ReportTab = 'today' | 'week' | 'month' | 'year' | 'all'
70
71// The estimate check's record of one session, kept in $.store as `costCheck:<session>`: Claude Code's own cost for
72// it, counted from `since` (`$.session.usage().startedAt`) to `at`. `estimateUsd` is the toolkit's estimate for the
73// same span from the session files, once a later session has checked it; null where a model has no price.
74export type SessionCostCheck = { claudeUsd: number; since: number; at: number; estimateUsd?: number | null }
75
76// The report's estimate check line: the past sessions checked, and both totals.
77export type ToolkitCheck = { sessions: number; claudeUsd: number; estimateUsd: number }
78
79// One rate-limit window as the strip's Limits line reads it: `five_hour` or `seven_day`, how much is used, and when it
80// resets (ISO 8601).
81export type ToolkitLimit = { kind: string; percentUsed: number; resetsAt?: string }
82
83// The strip's figures from `$.session.usage()`: Claude Code's own cost for the session (/cost, at API prices), the
84// context window's fill, and the rate-limit windows. A figure the engine hasn't reported yet is null.
85export type ToolkitUsage = {
86 costUsd: number | null
87 contextTokens: number | null
88 contextPercent: number | null
89 contextWindow: number
90 limits: ToolkitLimit[]
91}
92
93// The wrap-up nudge on show: which limit is near, and how full it is.
94export type ToolkitNudge = { reason: 'context' | 'fiveHour'; percent: number }
95
96// What the report pane draws: one period's totals, worked out when the tab is picked.
97export type ToolkitReport = {
98 tab: ReportTab
99 title: string
100 sessions: number
101 requests: number
102 ms: number
103 types: { key: string; label: string; requests: number; ms: number }[]
104 tokensIn: number
105 tokensCached: number
106 tokensOut: number
107 costUsd: number
108 ownCalls: number
109 ownTokens: number
110 fiveHour: number | null
111 week: number | null
112 waitMs: number
113 hasBackfill: boolean
114 hasCost: boolean
115 liveSince: number | null
116 check: ToolkitCheck | null
117}
118
119// How the last turn ended, for "All done · took X · N of N steps" and the work view's Done line: its start and end,
120// the shells and helpers it ran, and its tokens, how many of them were written, and cost (null when Claude Code
121// reported none).
122export type ToolkitDone = {
123 tookMs: number
124 completed: number
125 total: number
126 isAborted: boolean
127 startedAt?: number
128 endedAt?: number
129 shells?: number
130 failed?: number
131 helpers?: number
132 tokens?: number
133 tokensOut?: number
134 costUsd?: number | null
135}
136
137// Which model helper agents run on: picked per task, Haiku, or Claude's own.
138export type ToolkitHelperMode = 'lowest' | 'fast' | 'same'
139
140// The project this session is in: its folder's name, the folder, and its color's key. Colors are kept in $.store as
141// `projectColors`, keyed by the folder in lowercase. Outside a project folder, it's the folder Claude Code started in,
142// and `isOutside` is true.
143export type ToolkitProject = { name: string; dir: string; color: string; isOutside?: boolean }
144
145// One helper agent in the work view: its task and model, what it's doing now, its new tokens (in and out, without
146// cache re-reads), and their cost estimated from model prices. `parentId` names the helper that started it; absent
147// when Claude did.
148export type ToolkitAgent = {
149 id: string
150 parentId?: string
151 task: string
152 model: string | null
153 startedAt: number
154 endedAt?: number
155 status: 'working' | 'done' | 'stopped' | 'failed'
156 doing: string
157 tokens: number
158 costUsd?: number
159}
160
161declare module 'claude-code' {
162 interface PluginState {
163 'ap-toolkit': {
164 controlsOpen: boolean
165 usage: ToolkitUsage | null
166 sessionTokens: number
167 model: string | null
168 effort: string | null
169 pending: ToolkitPending | null
170 noEffort: string[]
171 hasEffortCommand: boolean
172 fiveHour: number | null
173 context: number | null
174 helpers: number
175 cleanView: boolean
176 peek: boolean
177 todos: ToolkitTodo[]
178 run: ToolkitRun | null
179 costSeen: ToolkitCostSeen | null
180 done: ToolkitDone | null
181 now: number
182 helperMode: ToolkitHelperMode
183 report: ToolkitReport | null
184 nudge: ToolkitNudge | null
185 nudgeDismissed: string[]
186 folderWarning: string | null
187 clearArmed: boolean
188 version: string | null
189 agents: ToolkitAgent[]
190 shells: ToolkitShell[]
191 stopArmed: string | null
192 project: ToolkitProject | null
193 }
194 }
195}
196