SLOPSHOPPER

ap-toolkit

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

newpanebandspinnerrowsguard
v0.1.38no licenseupdated 2026-10-25adamperr/ap-toolkit/ap-toolkit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ap-toolkit
│ ┃ Today's report ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ Today's report · Thursday, October 9 │ ap-toolkit │ │ ┃ [ Today ] [ Week ] [ Month ] [ Year ] [ All ⏺ Read(src/auth.ts) │ This session is outside a project folder │ │ ┃ ⎿ Read 6 lines │ (/work/app). Start Claude Code in the │ │ ┃ 42s of Claude's work · 1 session · 1 request ⏺ Bash(bun test) │ project's own folder so its rules and map │ │ ┃ · ≈ $1 of work at $120/hr ⎿ 3 pass, 1 fail ╰────────────────────────────────────────────╯ │ ┃ │ ┃ Building ████████ 100% 42s · … ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Usage 6K tokens in · 1K out · 91K re-read f… ✻ Worked for 42s · done 4:20 PM │ ┃ Toolkit's own use none │ › /report │ ⎿ ap-toolkit: Today's report is open above the prompt. │ │ │ ⚠ This session is outside a project folder (/work/app). Start Claude Code in the project's own folder so its rules … Done ✓ 4:53 to 4:53 AM · took 42s · 4 shells (1 failed) this turn 8K in · 0 out · $0.00 ──────────────────────────────────────────────────────────────────────────────────────────────────── ◆ app 8K tokens · $0.42 at API prices Context ███▉ 49% 97K/200K Opus 5.5 ▴ Limits 5h ██▌ 69% left ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⚠ This session is outside a project folder (/work/app). Start Claude Code in the project's own fold… Done ✓ 4:53 to 4:53 AM · took 42s · 4 shells (1 failed) this turn 8K in · 0 out · $0.00 ──────────────────────────────────────────────────────────────────────────────────────────────────── ◆ app 8K tokens · $0.42 at API prices Context ███▉ 49% 97K/200K Opus 5.5 ▴ Limits 5h ██▌ 69% left
Pane · Today's report
Today's report · Thursday, October 9 [ Today ] [ Week ] [ Month ] [ Year ] [ All time ] 42s of Claude's work · 1 session · 1 request · ≈ $1 of work at $120/hr Building ████████ 100% 42s · 1 request Usage 6K tokens in · 1K out · 91K re-read from cache · ≈ $0… Toolkit's own use none
Pane · help
AP Toolkit puts the things you'd otherwise type on buttons. | Row or button | What it does | | --- | --- | | ◆ 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. | | 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. | | 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. | | 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. | | 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. | | Now | What Claude is doing right now. It turns amber after 3 minutes on one thing. | | 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. | | 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. | | 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. | | Motion | While Claude works, a new or changed part lights up briefly, numbers count up, bars fill smoothly, and ◆ beats. | | Model | Switches Claude's model. A pick made while Claude works applies when it finishes. | | Effort | How hard Claude thinks. Grayed out on models that don't use it. | | 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. | | 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. | | 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. | | 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. | | Start session | Asks Claude to read the project map and tell you where things stand. | | 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. | | Open project folder | Opens this session's folder in File Explorer. | | 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. | | Clear | Starts a fresh conversation in this tab, with the same folder and model. Press it twice: the first press asks Sure? | | 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. | | ▴ ▾ | ▴ at the right of the strip opens the controls box (model, effort, settings, and Launch), and ▾ closes it. It remembers which. | | Status row | The end of the gray hint line under the prompt: model, effort, and helpers working. | | "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. | | ⚠ Folder check | Warns when a session starts outside a project folder (one with its own CLAUDE.md). Got it hides it for this session. | | ⚠ 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. | | 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. | To change the report's hourly rate, open /config. Press Esc to close this help.
README

AP Toolkit

AP Toolkit is a mod for Claude Code on Windows. It adds:

  • A two-line strip above the prompt with this session's tokens, cost at API prices, context, and your 5-hour and weekly limits.
  • A live view while Claude works: its steps, a finish estimate, what it's doing now, its shell commands, and its helper agents.
  • A controls box (press ▴ on the strip) for model, effort, Clean View, helper agent models, and launch buttons.
  • Today's report, with time, tokens, and cost by type of work for today, this week, this month, this year, and all time.
  • A color for each project folder, shown in the strip and on tabs that New tab opens.
  • "Needs you" Windows alerts when Claude asks you something or finishes a long request.

Press ? in the controls box for in-app Help.

What you need

  • Windows 10 or 11, with Windows Terminal and Git for Windows
  • Claude Code 2.1.310 or later

Install

In Claude Code, run these two commands, then restart Claude Code:

/plugin marketplace add adamperr/ap-toolkit
/plugin install ap-toolkit@ap-toolkit

Your data stays on your PC

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.

Source 2 files
hooks/register.tsx 2763 lines
1import { 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 lines
1// 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