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

A one-row bar above the prompt showing how close your Claude subscription is to its limits, and what the work would cost at Anthropic API list prices.
AI Tools 5h 42%, 1h12m wk 18%, Thu ctx 31% 62k/200k tok 1.3M (+48k) cache 42m, $0.50 thread $3.12 (+$0.41) month $184.20 limit ~3:40pm [🛠] [⁝]
| Segment | Meaning |
|---|---|
| Clawd | The mascot, at the start of the bar in the desktop Code tab only (see below) |
AI Tools | A dim title naming the bar |
5h 42%, 1h12m | 5-hour usage window: percent used, time until it resets |
wk 18%, Thu | Weekly window: percent used, the day it resets (hours when under a day) |
ctx 31% 62k/200k | Context window fill: percent and tokens used / window size |
tok 1.3M (+48k) | Tokens: this thread's total this month (its helpers' included) and the last turn's, counting input, output and prompt-cache reads and writes |
cache 42m | How long the prompt cache should stay warm: Claude Code writes the main conversation's cache with a 1-hour TTL, restarted by every request. Amber under 10 minutes, cache cold once expired. An estimate from request times; the API does not report cache state, and Anthropic may evict early |
cache 42m, $0.50 | What a cold cache costs the next message: the whole context written to the cache again at the model's 1-hour cache-write price (list prices, ~ for a model priced by its family), where a warm cache reads it for a small fraction of that. Shown after the cache time (cache 42m, $0.50, or cache cold, $0.50 once it has lapsed), or as cache $0.50 with cache warmth off. It's an upper bound: the context figure includes the last reply |
thread $3.12 | This session's cost at API prices |
(+$0.41) | What the last turn cost (last $0.41 when thread cost is hidden) |
~2.1% wk (+0.03%) | Thread cost %: the thread's and the last turn's estimated share of your weekly allowance (see below) |
month $184.20 | This calendar month's Claude Code usage at API prices, across all sessions on this machine |
limit ~3:40pm | At your pace over the last hour, when the 5-hour window would run out; shown only if before it resets |
Labels are dim and values a lighter grey; usage and context values turn yellow at 70% and red at 90%. A value not known yet shows -; until the first response of a session the bar shows the figures from your previous session.
/aitools toggles the whole bar (buttons included); /aitools on and /aitools off set it. The choice holds across sessions. /handoff works either way.
Press ⁝ (opts in the terminal) to open the AI Tool Settings pane; press it again, or the pane's own close mark, to close it. Opening it closes the 🛠 menu. Clawd stands at its top left in a hard hat, cranking a cog (on the desktop), beside the files the settings are saved in. Every option, or every meter with its options, gets a row, in two sections:
Global Settings May be overridden by project settings
● Show mascot Clawd, acting out what Claude is doing
● Show title AI Tools at the start of the bar
● Threshold colors Yellow from 70%, red from 90%; a cache about to go cold
● Show project row A second row: repo, board, issue and tools, in every project
○ Statusline file Written each turn, as the CLI's status line does: ~/.claude/statusline.json
Meters
5-hour ● Percent ● Reset 5h 42%, 1h12m
Weekly ● Percent ● Reset wk 18%, Thu
Context ● Percent ● Tokens ctx 31% 62k/200k
Tokens ● Thread ● Last turn tok 1.3M (+48k)
Cache ● Time left ● Cold cost cache 42m, $0.50
Thread cost ● Thread ● Last turn ● Weekly share
thread $3.12 (+$0.41) ~2.1% wk
Month ● Cost month $184.20
Burn rate ● Warning limit ~3:40pm
Project Settings Saved for vowelit only
● Show project row The row in vowelit
Name [Vowelit ]
In place of the repo's name
● Board Its open items on the row, and the Project pane
Board URL [https://github.com/users/pkkid/projects/1 ]
Tools Server · Test · Docs. Edit project.tools in the file.
Global Settings holds the bar's display options. First the bar's own: show mascot (offered on the desktop only), show title, threshold colors, show project row (in every project) and statusline file. Then each meter in the bar is one row, its options beside its name and a sample of it after them, all under Meters in the order the bar shows them: 5-hour, weekly, context, tokens, cache, thread cost, month and burn rate. A meter shows while any of its options is on. The 🛠 button always shows.
Statusline file (off by default) writes ~/.claude/statusline.json after each turn, in the shape of the JSON the CLI hands a statusLine command, so a program outside Claude (a desktop meter such as Conky) can read the 5-hour and weekly limits even from the desktop app, which runs no status line command. Fields the mod has no figure for are left out (prompt_cache, effort, output_style and the like). Each write goes to a private temp file that is then renamed over the old one, as a status line command would, so a reader never sees half a file and sessions writing at once each land whole; the last one wins, and the limits are the account's, so its reading is the newest.
Global Settings are saved for all your projects, in your settings file. Project Settings are saved for the project, in its own file, and show only in a GitHub repository: Show project row (whether the row shows here, when the global one is on too), the name it shows, and its board (off, or its URL). Each heading says where its settings go. Type a name or URL and press Enter to save it; an empty field goes back to the repository's name or the board looked up for it, and a URL that isn't a GitHub Project's is refused with a line saying why. These are written to the project file (see Project row); its tools are edited in the file itself.
Changes show in the bar right away and are saved across sessions. An option that is off does no work in the background: the transcript scan runs only for monthly cost, thread tokens or thread cost %; the cost lookups only for the cost options; the mascot follows nothing while hidden. (Request times are kept either way: /keepwarm needs them.) Turned back on, an option catches up at once, except the burn-rate warning, which needs fresh samples (a few responses) to project from. Hiding the bar with /aitools off stops all of it.
Everything the mod's controls change is saved to ~/.claude/aitools.json ($CLAUDE_CONFIG_DIR/aitools.json when that is set): the display options (the project row among them), /aitools on|off, the view, and the Background tasks and Branches panes' picks. The first run writes every setting into it, taking over what earlier versions kept in the mod's own store, so the file lists everything there is to change. Edit it by hand and the bar follows within 30 seconds, when ⁝ opens, or at once when Claude edits it. A file that isn't valid JSON is left alone: the settings stay as they were, and one dim line in the chat says what's wrong.
{
"bar": {
"hidden": false,
"show": { "mascot": true, "label": true, "projectRow": true, "fiveHour": true, "cacheWarmth": true, "coldCost": true, "...": true }
},
"view": { "tasks": false, "clean": false },
"subagents": { "showing": "all", "hideAfter": "never" },
"branches": { "where": "both", "show": "all" }
}
A write reads the file again first and changes only its own keys, so anything else in it stays. The display options are always written here; a project's own .claude/aitools.json gets only what you set under Project Settings (see Project row).
In the desktop Code tab, Clawd, Claude Code's pixel mascot, stands at the start of the bar (48 × 20 px) and acts out what Claude is doing. Every pose is animated, and a change of pose plays a short transition first: he walks over to his laptop or paper, sits down to sleep, jolts awake.
| Pose | When |
|---|---|
| Waving | A new session starts (a few seconds) |
| Standing | Nothing is running: breathing, blinking, glancing around |
| Typing | A turn is running: tapping at a laptop beside him, code typing out on its screen |
| Reading | A read-only tool runs (Read, Grep, Glob, a web fetch or search): reading a sheet of paper |
| Puzzled | A question waits on you: an open AskUserQuestion dialog, or a reply that ends with a question mark. Your next prompt settles it |
| Celebrating | A turn finishes with an answer (a few seconds, then Standing) |
| Oops | A tool call fails or is refused, or a turn is interrupted or fails (a few seconds) |
| Sleeping | Standing idle for 5 minutes; your next prompt wakes him |
He is SVG, so he never shows in the terminal, and Mascot in the settings pane (on by default) is offered on the desktop only.
/handoff asks the current conversation for a brief (goal, current state, decisions, open tasks, key files, next step), prints it in the chat as formatted text, and copies the raw markdown to the clipboard. Paste it into a new session to continue there. The brief is a command output row, so the current session's model reads it too.
The 🛠 button opens a row above the bar with Handoff, Notes, Tasks, Branches, Project (in a GitHub repository), Task View and Clean View, right-aligned; press it again to close the row without picking. Handoff writes the same brief into a Handoff brief pane, drawn as formatted text with a Copy button; nothing is copied until you press it, and the brief stays out of the chat (so the current session's model does not read it).
Stepping away for longer than an hour lets the prompt cache lapse, and the next message pays to write the whole context again (the dollar figure after the cache time on the bar). /keepwarm holds it warm while you are gone: 5 minutes before the cache's hour is up, it sends one small request over the conversation, which reads the cache and restarts its hour.
/keepwarm keep warm for 6 hours
/keepwarm 90m for a time of your own (also 2h30m, 10h)
/keepwarm status time left, the next ping, and what the last ping read and cost
/keepwarm off stop
/keepwarm help these lines
While it runs, a ☼ shows left of 🛠 (tools in the terminal), right of ? when that shows.
$.model.fork: the conversation exactly as Claude last sent it, plus "Reply with the single word: warm". It reads the cached context (about $0.01 for 62k tokens on Opus 5.5), and nothing joins the conversation./keepwarm status say why.promptCacheTtl set to "1h".The logic follows cache-tax's keepwarm, pinging 5 minutes before the hour where cache-tax pings after 50 idle minutes.
When Claude ends an answer with questions for you in plain text, /askme asks them again as pop-up questions with clickable options. Claude's recommended option comes first, marked "(Recommended)", and each option says what picking it means. Yes-or-no confirmations come four to a question, and you can always type your own answer under "Other". Anything typed after the command is passed along as a note, as in /askme only the M2 ones. After you answer, Claude sums up the decisions and carries on.
You don't have to type the command: when Claude's last answer asks you something and it hasn't already used the pop-up for that request, a ? button appears just left of 🛠 (tools in the terminal). It goes away once you send your next prompt. Pressing it sends /askme straight away. If it can't, because Claude is still working or the prompt was refused, it puts /askme in the prompt box for you to send. Anything you had typed there stays, after the command, and goes along as its note. Only a question mark right after a word counts: a bare ?, or one inside code or a link, doesn't.
Notes in the 🛠 menu opens a Notes pane with one text box for a single scratch note. It is kept in .claude/notes.md under the project root, a plain Markdown file you can open anywhere else too, and saved as you type. Copy, at the end of the line above the box, copies the note to the clipboard. Add .claude/notes.md to the project's .gitignore if it should stay out of git.
Click in the box to type. Enter starts a new line, the arrows, Home and End move the cursor (Ctrl+A and Ctrl+E too), Page Up and Page Down move ten rows, and Backspace and Delete remove. Escape hands the keys back to the prompt. There is no selection or undo. Pasting works in the terminal but not in the desktop app, where a mod cannot read the clipboard; paste into .claude/notes.md in another editor and reopen the pane instead. The pane stays open until you close it, and comes back after the mod reloads. Where a surface cannot draw the box (VS Code, the mobile app), the pane says where the note is kept instead.
Two views from the 🛠 menu, each turned on and off on its own: Task View draws Claude's work as a checklist in the bar, and Clean View hides Claude's work in the chat, leaving its final replies. In the menu a ● marks a view that is on and a ○ one that is off; picking one turns it on or off. The choices hold across sessions (view.tasks and view.clean in the settings file; a file from when the views were one choice reads "task" as Task View and "clean" as both).
While a view is on, its name shows in the status line under the prompt (both, comma separated). /taskview and /cleanview do the same as the menu from the prompt: no argument toggles that view, on turns it on and off turns it off.
The checklist sits between the bar's top row and the project row (an open menu still shows above the bar): done steps marked with a green ✓, the step in progress with a blue ●, steps not started with a dim ○. Its title row starts with ▾, which folds the list to that row for the session (▸ opens it again), and ends with how many steps are done and ×, which closes the list until your next prompt; Claude updating it meanwhile leaves it closed.
▾ Adding dark mode 3 of 5 ×
✓ Read the theme code 100% ▰▰▰▰▰▰
✓ Add the color tokens 100% ▰▰▰▰▰▰
● Wire up the toggle 40% ▰▰▱▱▱▱
○ Update tests 0% ▱▱▱▱▱▱
○ Run checks 0% ▱▱▱▱▱▱
Each step has a bar: full once done, empty until it starts, and for the step in progress Claude's own estimate of how far along it is, which moves each time Claude updates the checklist (the note asks for about every quarter of a step).
While Task View is on, each prompt you send carries a hidden note asking Claude to plan the work as plain-language steps and report them through the mod's checklist tool (mcp__aitools__checklist), updating it as each step starts and finishes. The mod answers that tool itself, before Claude Code's permission check, so it should not ask for permission. Each new prompt starts a fresh checklist; once every step is done and Claude stops, it folds to one line, ✓ Done: <title> (<n> steps), with a × at its right end that closes it. A quick question that needs no work gets no checklist.
While Clean View is on, each prompt also carries a note asking Claude not to narrate between steps and to put what you need in a brief final reply. Clean View recognizes a final reply as the last thing Claude wrote before your next prompt. It reads them from the conversation when the mod loads (so replies from before a reload or restart still show) and adds each new one as a request finishes.
While any background task runs, the project row counts them (1 bg task, 3 bg tasks); pressing it opens the Background tasks pane. Tasks in the 🛠 menu opens it too (where there is no project row), and /tasks opens it, or closes it if open; the pane's own close mark closes it. It stays open until you close it, and comes back after the mod reloads.
On the desktop, Clawd stands left of the pane's option rows with a mini Clawd stacked on his head for each task running in the list (the stack stops growing at four), and sleeps in quiet grey when none is. The header line under the options sits beside him too. The terminal has no picture.
Every subagent, workflow agent and monitor of the session gets a line, whether the pane is open or not, one per line with its type:
review-changes · agent 3, since a workflow's agents have no names of their own).Shell commands run in the background get no line: Claude Code offers no way to ask whether one is still running, so a line whose end notice was missed would stay running for good. Claude Code lists them itself.
The ones running come first (oldest first), then the finished ones, the most recently finished first. Marks are Task View's: a blue ● running, a ✓ done, a red × failed, and a dim × stopped (killed by you or Claude). A running task's name is a lighter grey, followed in dim grey by what it is doing (a subagent's latest tool call, Reading bar.tsx, or what a monitor watches) and its running time. A line's name and what it is doing take the room the pane's width leaves, cut with … at the edge. A finished line is all dim (but a failed one's ×): no bar, just how long it ran and how long ago it finished, 0:09 (3m ago).
Showing: [All] Subagents Workflows Monitors
Hide after: [Never] 1h 15m 5m Immediate
2 running · 1 done
● Subagent Research business licenses · Reading the city site 40% ▰▰▱▱▱▱ 1:20
● Monitor Wait for the dev server · curl localhost:3000 4:02
✓ Subagent Draft the floor plan 0:56 (3m ago)
In each option row the choice in effect is drawn at full strength (shown as […] above); the others are dim, and pressing one picks it. They are the same compact buttons as the bar's.
Tasks run as Claude Code normally runs them: the pane only watches, adding no note to your prompts, changing no model and putting nothing in the status line. Tracking costs no tokens: the lines are kept in memory for the session.
agent_progress tool (mcp__aitools__agent_progress); once it does, its line shows its percent (its own estimate) and a bar. Workflow agents, monitors, and subagents started while the pane was closed are not asked, so they show no bar.Monitors will appear here when started.Branches in the 🛠 menu, or the project row's 5 branches, opens the Branches pane; the pane's own close mark closes it. /branches opens it, or closes it if open. It stays open until you close it, and comes back after the mod reloads.
On the desktop, Clawd stands left of the pane's controls, snipping a stray branch off a tree. He sweeps with a broom while branches are being removed, and naps under the tree, he and it in grey, when there is nothing to clean up. The terminal has no picture.
Branches: [Local + remote] Local Remote
Show: [All] Active Done
6 branches · 2 worktrees · 3 done
Clean up removes 3 done branches: 1 worktree, 1 local and 2 remote branches.
Clean up 3 done Refresh
Branch On Checked out Last Changes PR CI
○ main · base both main folder 5m - - - •
● feat/background-tasks · 3 changed both worktree 12m +612 -48 #12 • ×
↑ feat/totem-sizes · 2 unpushed local worktree 2d +96 -210 - - ×
✓ claude/repo-rename-watcher-setup-f7486b · … local worktree 11h - - - - ×
✓ feat/project-pane remote - 29h - - #6 • ×
✓ feat/project-row remote - 2d - - #5 • ×
The base branch is always the top row, then the branches with work to keep, then the done ones, each newest commit first (the main folder's and this session's lead their group). One row is one branch, wherever it lives. A worktree is where a branch is checked out, so it shows on its branch's row rather than as a row of its own. The desktop app checks its worktrees out detached, beside a branch named for the folder (claude/<folder>); such a worktree joins that branch's row, and one no branch matches is a row of its own, named by its folder.
origin).hooks/register.tsx 3019 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Events, Register, Timer } from 'claude-code'
3
4import {
5 PROGRESS_SPEC,
6 PROGRESS_TOOL_ID,
7 SUBAGENT_NOTE,
8 DEFAULT_HIDE_AFTER,
9 DOCK_PANE,
10 DOCK_TITLE,
11 KIND_LABELS,
12 activityText,
13 addCard,
14 clip,
15 finishCard,
16 isRunning,
17 kindOf,
18 newRun,
19 noteActivity,
20 noticeEnd,
21 parseTaskNotices,
22 runningCount,
23 renderDock,
24 reportProgress,
25 workflowName,
26} from '../src/agentdock'
27import type { EndStatus } from '../src/agentdock'
28import { CACHE_TTL, renderBar } from '../src/bar'
29import { BOARD_QUERY, BOARD_REFRESH_MS, PROJECT_PANE, filterCounts, parseBoardItems, parseBoardUrl, renderBoard } from '../src/board'
30import { price } from '../src/pricing'
31import {
32 CHECKLIST_SPEC,
33 CHECKLIST_TOOL_ID,
34 VIEW_NAMES,
35 addFinal,
36 finalReplies,
37 isFinalReply,
38 nextToggle,
39 parseChecklist,
40 renderChecklist,
41 viewNote,
42} from '../src/checklist'
43import { FAINT_TEXT, RED } from '../src/palette'
44import { EMPTY_BURN, addSample, project } from '../src/burnrate'
45import type { BurnState } from '../src/burnrate'
46import { HANDOFF_PROMPT, handoffOutput, paneMarkdown } from '../src/handoff'
47import { ASK_TOOL, askmeText, asksQuestions, isAskableIn } from '../src/askme'
48import { CONFIG_FILE, STORED_KEYS, configText, migrated, parseConfig, personalFrom, withPersonal } from '../src/config'
49import type { Json, Personal, StoredSettings } from '../src/config'
50import {
51 LOG_KEEP,
52 expand,
53 issueInBranch,
54 issueInPrompt,
55 logPaneId,
56 parseGithubRemote,
57 projectFrom,
58 renderProjectRow,
59 splitOutput,
60} from '../src/project'
61import type { ToolVars } from '../src/project'
62import {
63 KEEPWARM_HELP,
64 KEEPWARM_LEAD_MS,
65 KEEPWARM_PROMPT,
66 coldPingText,
67 failureText,
68 isWarmReadback,
69 keepWarmStatus,
70 parseKeepWarm,
71 pingDelay,
72} from '../src/keepwarm'
73import {
74 MASCOT_START,
75 ONE_SHOT_MS,
76 READ_TOOLS,
77 SLEEP_AFTER_MS,
78 endsWithQuestion,
79 introMs,
80 isQuestionOpenIn,
81 mascotDrawing,
82} from '../src/mascot'
83import {
84 AFTER_TOOL_MS,
85 BRANCHES_PANE,
86 BRANCHES_TITLE,
87 CHANGING_TOOLS,
88 FIRST_PARENT_DEPTH,
89 GITHUB_EVERY,
90 REFRESH_MS,
91 SWEEP_MS,
92 baseName,
93 countBranches,
94 countChanged,
95 firstLine,
96 isCleanable,
97 isDeletable,
98 joinBranches,
99 parsePrs,
100 parseRefs,
101 parseRuns,
102 parseShortstat,
103 parseWorktreeList,
104 refFormat,
105 removedText,
106 renderBranches,
107} from '../src/branches'
108import type { CheckoutFacts, PrFacts, Removal, WorktreeEntry } from '../src/branches'
109import { DEFAULT_SETTINGS, nextHidden, normalizeSettings } from '../src/settings'
110import { SETTINGS_PANE, SETTINGS_TITLE, boardProblem, homeRelative, renderSettings, withProject } from '../src/settingspane'
111import type { ProjectChange } from '../src/settingspane'
112import { STATUSLINE_FILE, STATUSLINE_WRITE, projectSlug, statusline } from '../src/statusline'
113import { scanMonth, sessionTokens, sessionUsd, usageTokens } from '../src/transcripts'
114import type { ScanCache, ScanIO } from '../src/transcripts'
115import type { BoardData, Branch, BranchTarget, BranchesShow, BranchesWhere, CiState, GithubRepo, ProjectTool, ToolListener, ToolRun } from '../types'
116import type { AgentCard, AgentKind, AgentRun, AgentShowing, Brief, HideAfter, LimitWindow, MascotPose, MascotState, Snapshot, ToggleKey } from '../types'
117
118// The engine follows `$` only into functions declared in this file, so every
119// function that touches `$` lives here; src/ holds the pure units.
120
121const snapshot = atom({ plugin: 'aitools', key: 'snapshot' } as const, null)
122const month = atom({ plugin: 'aitools', key: 'month' } as const, { usd: 0, isEstimate: false, status: 'loading' })
123const projection = atom({ plugin: 'aitools', key: 'projection' } as const, null)
124const settings = atom({ plugin: 'aitools', key: 'settings' } as const, DEFAULT_SETTINGS)
125const isHidden = atom({ plugin: 'aitools', key: 'isHidden' } as const, false)
126const isSettingsOpen = atom({ plugin: 'aitools', key: 'isSettingsOpen' } as const, false)
127const cacheAt = atom({ plugin: 'aitools', key: 'cacheAt' } as const, null)
128const turnBaseline = atom({ plugin: 'aitools', key: 'turnBaseline' } as const, null)
129const tick = atom({ plugin: 'aitools', key: 'tick' } as const, 0)
130const isHandingOff = atom({ plugin: 'aitools', key: 'isHandingOff' } as const, false)
131const isToolsOpen = atom({ plugin: 'aitools', key: 'isToolsOpen' } as const, false)
132const brief = atom({ plugin: 'aitools', key: 'brief' } as const, null)
133const isTaskView = atom({ plugin: 'aitools', key: 'isTaskView' } as const, false)
134const isCleanView = atom({ plugin: 'aitools', key: 'isCleanView' } as const, false)
135const checklist = atom({ plugin: 'aitools', key: 'checklist' } as const, null)
136const isChecklistCollapsed = atom({ plugin: 'aitools', key: 'isChecklistCollapsed' } as const, false)
137const isChecklistClosed = atom({ plugin: 'aitools', key: 'isChecklistClosed' } as const, false)
138const finals = atom({ plugin: 'aitools', key: 'finals' } as const, [])
139const isDockOpen = atom({ plugin: 'aitools', key: 'isDockOpen' } as const, false)
140const agentShowing = atom({ plugin: 'aitools', key: 'agentShowing' } as const, 'all')
141const hideAfter = atom({ plugin: 'aitools', key: 'hideAfter' } as const, DEFAULT_HIDE_AFTER)
142const agentRun = atom({ plugin: 'aitools', key: 'agentRun' } as const, null)
143const runningTasks = atom({ plugin: 'aitools', key: 'runningTasks' } as const, 0)
144const branchCount = atom({ plugin: 'aitools', key: 'branchCount' } as const, null)
145const dockTick = atom({ plugin: 'aitools', key: 'dockTick' } as const, 0)
146const mascot = atom({ plugin: 'aitools', key: 'mascot' } as const, MASCOT_START)
147const asking = atom({ plugin: 'aitools', key: 'asking' } as const, 0)
148const isQuestionOpen = atom({ plugin: 'aitools', key: 'isQuestionOpen' } as const, false)
149const isAskable = atom({ plugin: 'aitools', key: 'isAskable' } as const, false)
150const mainModel = atom({ plugin: 'aitools', key: 'mainModel' } as const, null)
151const keepWarm = atom({ plugin: 'aitools', key: 'keepWarm' } as const, null)
152const githubRepo = atom({ plugin: 'aitools', key: 'githubRepo' } as const, null)
153const projectConfig = atom({ plugin: 'aitools', key: 'projectConfig' } as const, null)
154const boardUrl = atom({ plugin: 'aitools', key: 'boardUrl' } as const, null)
155const promptIssue = atom({ plugin: 'aitools', key: 'promptIssue' } as const, null)
156const issueTitle = atom({ plugin: 'aitools', key: 'issueTitle' } as const, null)
157const toolRuns = atom({ plugin: 'aitools', key: 'toolRuns' } as const, {})
158const toolListeners = atom({ plugin: 'aitools', key: 'toolListeners' } as const, {})
159const toolLogs = atom({ plugin: 'aitools', key: 'toolLogs' } as const, {})
160const keepWarmStopped = atom({ plugin: 'aitools', key: 'keepWarmStopped' } as const, null)
161const isNotesOpen = atom({ plugin: 'aitools', key: 'isNotesOpen' } as const, false)
162const notes = atom({ plugin: 'aitools', key: 'notes' } as const, null)
163const isBranchesOpen = atom({ plugin: 'aitools', key: 'isBranchesOpen' } as const, false)
164const branchList = atom({ plugin: 'aitools', key: 'branches' } as const, null)
165const branchAction = atom({ plugin: 'aitools', key: 'branchAction' } as const, null)
166const branchesWhere = atom({ plugin: 'aitools', key: 'branchesWhere' } as const, 'both' as BranchesWhere)
167const branchesShow = atom({ plugin: 'aitools', key: 'branchesShow' } as const, 'all' as BranchesShow)
168const isBoardOpen = atom({ plugin: 'aitools', key: 'isBoardOpen' } as const, false)
169const board = atom({ plugin: 'aitools', key: 'board' } as const, { status: 'loading' })
170const boardFilter = atom({ plugin: 'aitools', key: 'boardFilter' } as const, 'open')
171const isBoardReading = atom({ plugin: 'aitools', key: 'isBoardReading' } as const, false)
172const settingsNote = atom({ plugin: 'aitools', key: 'settingsNote' } as const, null)
173
174const HANDOFF_PANE = 'handoff'
175const NOTES_PANE = 'notes'
176/** The note's file, under the session's project root. */
177const NOTES_FILE = '.claude/notes.md'
178/** The editor's key in the Notes pane. */
179const NOTES_EDITOR = 'notes-editor'
180
181/** Prompt origins that are the person's own words; anything else (a subagent's report, a notice) is not a new request. */
182const PERSON_ORIGINS: ReadonlySet<string> = new Set(['composer', 'bridge', 'sdk'])
183/** How many Background tasks trace lines the store keeps. */
184const TRACE_KEEP = 60
185/** The mod's own tools: reporting progress is not work the mascot reacts to. */
186const QUIET_TOOLS: ReadonlySet<string> = new Set([CHECKLIST_TOOL_ID, PROGRESS_TOOL_ID])
187/** How long a subagent's first model request waits for its own start to be recorded, at most. */
188const SPAWN_WAIT_MS = 2000
189/** How long past its deadline a monitor's line waits for its own closing notice before it is ended anyway. */
190const MONITOR_GRACE_MS = 60_000
191
192/** Subagent starts in flight, which a subagent's first model request may wait on before its line exists. */
193const startsInFlight = new Set<Promise<void>>()
194/** Subagent loops already looked at: each gets a line once, or is passed over once (a fork, the pane closed). */
195const seenAgents = new Set<string>()
196/** Subagents with a running line, which shows their latest tool call. */
197const trackedAgents = new Set<string>()
198/** The last workflow the main chat started, and how many of its agents have a line: what workflow agents are called. */
199let lastWorkflow: string | null = null
200let workflowAgents = 0
201/** Read-only tool calls of the main chat in flight: the mascot reads until the last one returns. */
202let readsInFlight = 0
203/** Whether a main-chat turn is running: where the mascot stands when he is turned on mid-session. */
204let isTurnRunning = false
205/** Whether Claude has opened the question pop-up since the person's last prompt: then the bar offers no ? button. */
206let isAskedThisRequest = false
207/** /keepwarm's one pending timer: its next ping, or its end when no ping is due before then. */
208let keepWarmTimer: Timer | null = null
209/** The once-a-second timer that redraws running times: running only while the Background tasks pane is open. */
210let dockTimer: Timer | null = null
211/** The model tools registered so far: each is offered to the model only once its feature is first turned on. */
212const registeredTools = new Set<string>()
213/** The note's writes, one after another, so the last text typed is the one left on disk. */
214let notesSaving: Promise<void> = Promise.resolve()
215/** The note as it stands: as loaded, then as the editor last posted it. What Copy copies. */
216let latestNotes = ''
217/** Trace writes run one after another, so lines written at once are all kept. */
218let traceQueue: Promise<void> = Promise.resolve()
219/** The Branches pane's timers: its regular read, and the one a changing tool sets. Running only while it is open. */
220let branchTimer: Timer | null = null
221let branchSoon: Timer | null = null
222/** Reads of the pane so far since it opened: every GITHUB_EVERY-th asks GitHub too, and fetches. */
223let branchReads = 0
224/** The read in flight, and whether another (with GitHub's or not) waits for it to finish. */
225let branchReading: Promise<void> | null = null
226let branchQueued: { withGithub: boolean } | null = null
227/** The branch merges are measured against, found once each time the pane opens. */
228let branchBase: string | null = null
229/** The base the project row's branch count leaves out, found once per main checkout. */
230let countBase: { main: string; base: string } | null = null
231/** Lines added and removed since the base, by `<base commit>:<branch commit>`: a branch's are read once per commit. */
232let branchStats = new Map<string, { added: number; removed: number }>()
233/** GitHub's word, as last read: pull requests by branch and CI by commit. Dropped when the pane closes. */
234let githubPrs = new Map<string, PrFacts[]>()
235let githubRuns = new Map<string, CiState>()
236/**
237 * The board's next read, BOARD_REFRESH_MS after the last; armed only while the Project pane is open or the project row
238 * shows its open count.
239 */
240let boardTimer: Timer | null = null
241/** The board read in flight: a read asked for meanwhile is dropped, since this one brings the same. */
242let boardReading: Promise<void> | null = null
243
244const encoder = new TextEncoder()
245const TICK_MS = 30_000
246/** Cache times older than this are dropped from the per-session store. */
247const CACHE_KEEP_MS = 2 * 60 * 60_000
248/** Blank rows between the chat and the bar on the terminal, so the last of a reply does not run into it. */
249const TERMINAL_TOP_MARGIN = 1
250
251/** The latest scan, kept here too so a store write that fails costs nothing but persistence. */
252let memoryCache: ScanCache | null = null
253let isScanning = false
254let isScanPending = false
255let hasLoggedScanError = false
256
257function errorText(err: unknown): string {
258 return err instanceof Error ? err.message : String(err)
259}
260
261/** Whether a bar option is on and the bar shown: work that only feeds the bar is skipped otherwise. */
262async function isShown($: EngineInterface, keys: ToggleKey | readonly ToggleKey[]): Promise<boolean> {
263 if (await read($, isHidden)) {
264 return false
265 }
266 const current = normalizeSettings(await read($, settings))
267
268 return (typeof keys === 'string' ? [keys] : keys).some(key => current[key])
269}
270
271/** The options whose figures come from the transcript scan: the month's cost, the thread's tokens, the week's share. */
272const SCAN_KEYS: readonly ToggleKey[] = ['monthlyCost', 'threadTokens', 'threadPercent']
273/** The options that show the session's cost. */
274const COST_KEYS: readonly ToggleKey[] = ['threadCost', 'lastTurnCost', 'threadPercent']
275
276/** Whether the mascot is drawn anywhere: its option on, the bar shown, and a surface that draws SVG attached. */
277async function isMascotOn($: EngineInterface): Promise<boolean> {
278 if (!(await isShown($, 'mascot'))) {
279 return false
280 }
281
282 return (await $.session.surfaces().catch(() => [])).some(surface => surface !== 'terminal')
283}
284
285/** Offers one of the mod's tools to the model, the first time its feature is turned on in this load. */
286async function registerTool($: EngineInterface, spec: Parameters<EngineInterface['tool']['register']>[0]): Promise<void> {
287 if (registeredTools.has(spec.name)) {
288 return
289 }
290 registeredTools.add(spec.name)
291 await $.tool.register(spec).catch(err => {
292 registeredTools.delete(spec.name)
293 $.ui.log(`aitools: ${spec.name} tool not registered: ${errorText(err)}`)
294 })
295}
296
297function toWindow(limit: { percentUsed: number; resetsAt?: string } | undefined): LimitWindow | undefined {
298 return limit && { percentUsed: limit.percentUsed, resetsAt: limit.resetsAt }
299}
300
301
302function scanIO($: EngineInterface): ScanIO {
303 return {
304 list: dir => $.fs.list(dir),
305 readBytes: async path => encoder.encode(await $.fs.read(path)),
306 async tail(path, offset) {
307 const run = await $.process.run(['tail', '-c', `+${offset + 1}`, path])
308 if (run.exitCode !== 0) {
309 throw new Error(`tail exited ${run.exitCode}`)
310 }
311
312 return { bytes: encoder.encode(run.stdout), isTruncated: run.isStdoutTruncated }
313 },
314 }
315}
316
317async function projectsRoot($: EngineInterface): Promise<string> {
318 return `${await configDir($)}/projects`
319}
320
321async function readCache($: EngineInterface): Promise<ScanCache | null> {
322 return memoryCache ?? ((await $.store.get('scanCache')) as ScanCache | undefined) ?? null
323}
324
325async function scan($: EngineInterface): Promise<void> {
326 if (isScanning) {
327 isScanPending = true
328 return
329 }
330 isScanning = true
331 try {
332 do {
333 isScanPending = false
334 const next = await scanMonth(scanIO($), await projectsRoot($), await readCache($), await $.clock.now())
335 memoryCache = next
336 await $.store.set('scanCache', next).catch((err: unknown) => logScanError($, err))
337 await update($, month, () => ({ usd: next.usd, isEstimate: next.isEstimate, status: 'ready' as const, hours: next.hours }))
338 const threadTokens = sessionTokens(next, await $.session.id())
339 if (threadTokens !== undefined) {
340 await update($, snapshot, snap => ({ ...(snap ?? {}), threadTokens }))
341 }
342 } while (isScanPending)
343 } catch (err) {
344 await update($, month, m => ({ ...m, usd: m?.usd ?? 0, isEstimate: m?.isEstimate ?? false, status: 'error' as const }))
345 logScanError($, err)
346 } finally {
347 isScanning = false
348 }
349}
350
351function logScanError($: EngineInterface, err: unknown): void {
352 if (!hasLoggedScanError) {
353 hasLoggedScanError = true
354 $.ui.log(`aitools: monthly cost scan failed: ${errorText(err)}`)
355 }
356}
357
358type CacheTimes = Record<string, number>
359
360async function rememberCacheAt($: EngineInterface, at: number): Promise<void> {
361 const sessionId = await $.session.id()
362 const stored = ((await $.store.get('cacheAt')) as CacheTimes | undefined) ?? {}
363 const kept = Object.fromEntries(Object.entries(stored).filter(([, t]) => at - t < CACHE_KEEP_MS))
364 await $.store.set('cacheAt', { ...kept, [sessionId]: at })
365}
366
367async function restoreCacheAt($: EngineInterface): Promise<void> {
368 const stored = ((await $.store.get('cacheAt')) as CacheTimes | undefined) ?? {}
369 const at = stored[await $.session.id()]
370 if (at !== undefined) {
371 await update($, cacheAt, () => at)
372 }
373}
374
375/** Scans the transcripts soon, if any option showing their figures is on. */
376function startScan($: EngineInterface): void {
377 $.clock.after(0, () => void scanIfShown($))
378}
379
380async function scanIfShown($: EngineInterface): Promise<void> {
381 if (await isShown($, SCAN_KEYS)) {
382 await scan($)
383 }
384}
385
386/** Asks a fork of this conversation for the brief: the brief, or the failure's text. Null while one is already running. */
387async function writeBrief($: EngineInterface): Promise<Brief | null> {
388 if (await read($, isHandingOff)) {
389 return null
390 }
391 await update($, isHandingOff, () => true)
392 try {
393 const reply = await $.model.fork({ prompt: HANDOFF_PROMPT })
394 if (!reply.isAnswered) {
395 const reason = reply.reason === 'nothing-to-fork' ? 'nothing to hand off yet' : reply.reason
396 return { status: 'error', text: `Handoff failed: ${reason}` }
397 }
398
399 return { status: 'ready', text: reply.text.trim() }
400 } catch (err) {
401 return { status: 'error', text: `Handoff failed: ${errorText(err)}` }
402 } finally {
403 await update($, isHandingOff, () => false)
404 }
405}
406
407/** /handoff: copies the brief and toasts the outcome. Returns the chat row's text. */
408async function handoff($: EngineInterface): Promise<string> {
409 const written = await writeBrief($)
410 if (written === null) {
411 return 'Handoff already in progress.'
412 }
413 if (written.status === 'error') {
414 $.ui.toast(written.text)
415 return written.text
416 }
417 const copied = await $.ui.copy({ text: written.text })
418 $.ui.toast(copied.isCopied ? 'Handoff brief copied to the clipboard' : 'Handoff brief ready (copy failed)')
419
420 return handoffOutput(written.text)
421}
422
423/** Where the note is kept: `.claude/notes.md` under the session's project root. */
424async function notesPath($: EngineInterface): Promise<string> {
425 return `${(await $.session.root()).replace(/[\\/]$/, '')}/${NOTES_FILE}`
426}
427
428/**
429 * Reads the note for the pane, once any write still going has landed: none yet is an empty note; a file that is there
430 * but cannot be read is an error, so the editor never writes over it.
431 */
432async function loadNotes($: EngineInterface): Promise<void> {
433 await update($, notes, () => null)
434 await notesSaving
435 const path = await notesPath($)
436 let loaded: { text: string } | { error: string }
437 if (!(await $.fs.exists(path).catch(() => false))) {
438 loaded = { text: '' }
439 } else {
440 loaded = await $.fs.read(path).then(
441 text => (typeof text === 'string' ? { text } : { error: `${NOTES_FILE} is not text.` }),
442 (err: unknown) => ({ error: `Could not read ${NOTES_FILE}: ${errorText(err)}` }),
443 )
444 }
445 latestNotes = 'text' in loaded ? loaded.text : ''
446 await update($, notes, () => loaded)
447}
448
449/** The 🛠 menu's Notes: opens the Notes pane on the note as saved, its keys handed to the editor. */
450async function openNotes($: EngineInterface): Promise<void> {
451 await update($, isToolsOpen, () => false)
452 await update($, isNotesOpen, () => true)
453 await $.ui.open({ id: NOTES_PANE, title: 'Notes', focus: true })
454 await loadNotes($)
455 await $.ui.focus({ requestId: NOTES_PANE, key: NOTES_EDITOR }).catch(() => undefined)
456}
457
458/** Saves the note as the editor posted it, after any write before it. */
459function saveNotes($: EngineInterface, text: string): void {
460 latestNotes = text
461 notesSaving = notesSaving
462 .then(async () => $.fs.write(await notesPath($), text))
463 .catch((err: unknown) => $.ui.log(`aitools: could not save ${NOTES_FILE}: ${errorText(err)}`))
464}
465
466/** The Notes pane's Copy: the note as it stands, to the clipboard of the surface pressed on. */
467async function copyNotes($: EngineInterface, surface: Parameters<EngineInterface['ui']['copy']>[0]['surface']): Promise<void> {
468 const copied = await $.ui.copy({ text: latestNotes, surface })
469 $.ui.toast(copied.isCopied ? 'Note copied to the clipboard' : 'Copy failed')
470}
471
472/** The 🛠 menu's Handoff: writes the brief into the handoff pane, which has its own Copy button. */
473async function showHandoff($: EngineInterface): Promise<void> {
474 await update($, isToolsOpen, () => false)
475 if (await read($, isHandingOff)) {
476 $.ui.toast('Handoff already in progress.')
477 return
478 }
479 await update($, brief, () => ({ status: 'writing', text: '' }))
480 await $.ui.open({ id: HANDOFF_PANE, title: 'Handoff brief' })
481 const written = await writeBrief($)
482 if (written !== null) {
483 await update($, brief, () => written)
484 }
485}
486
487/** Copies the handoff pane's brief on the surface the press came from, and says whether it took. */
488async function copyBrief($: EngineInterface, surface: Parameters<EngineInterface['ui']['copy']>[0]['surface']): Promise<void> {
489 const shown = await read($, brief)
490 if (shown?.status !== 'ready') {
491 return
492 }
493 const copied = await $.ui.copy({ text: shown.text, surface })
494 $.ui.toast(copied.isCopied ? 'Handoff brief copied to the clipboard' : 'Copy failed')
495}
496
497async function toggleSetting($: EngineInterface, key: keyof typeof DEFAULT_SETTINGS): Promise<void> {
498 const changed = await update($, settings, s => {
499 const was = normalizeSettings(s)
500
501 return { ...was, [key]: !was[key] }
502 })
503 await savePersonal($, { settings: normalizeSettings(changed) })
504 if (normalizeSettings(changed)[key]) {
505 await catchUp($, key)
506 }
507 if (key === 'projectRow') {
508 // Its servers' ports, and its board's items, are read only while the row shows.
509 syncListenerPoll($)
510 syncBoardReads($)
511 }
512}
513
514/**
515 * Brings a feature up to date once it is turned on, or the whole bar once it is shown again (`key` absent): the work
516 * skipped while it was off.
517 */
518async function catchUp($: EngineInterface, key?: ToggleKey): Promise<void> {
519 startScan($)
520 if ((key === undefined || COST_KEYS.includes(key)) && (await isShown($, COST_KEYS))) {
521 const cost = (await $.session.usage()).cost?.usd
522 if (cost !== undefined) {
523 await update($, snapshot, snap => ({ ...(snap ?? {}), threadUsd: cost }))
524 }
525 }
526 if ((key === undefined || key === 'mascot') && (await isMascotOn($))) {
527 await syncMascot($)
528 }
529 if (key === 'statuslineFile') {
530 await writeStatusline($)
531 }
532}
533/** Whether a tool's row is hidden: every one under Clean View, the checklist's own under Task View (the band shows it). */
534async function isToolRowHidden($: EngineInterface, tool: string): Promise<boolean> {
535 return (await read($, isCleanView)) || ((await read($, isTaskView)) && tool === CHECKLIST_TOOL_ID)
536}
537
538/** Whether a view is on. */
539async function isViewOn($: EngineInterface, view: 'task' | 'clean'): Promise<boolean> {
540 return view === 'task' ? read($, isTaskView) : read($, isCleanView)
541}
542
543/** Turns one view on or off, on its own: saves it for later sessions and closes the 🛠 menu. */
544async function setView($: EngineInterface, view: 'task' | 'clean', isOn: boolean): Promise<void> {
545 await update($, isToolsOpen, () => false)
546 if (view === 'task') {
547 await update($, isTaskView, () => isOn)
548 } else {
549 await update($, isCleanView, () => isOn)
550 }
551 await savePersonal($, view === 'task' ? { taskView: isOn } : { cleanView: isOn })
552 await showStatus($)
553 await startViews($)
554}
555
556/** The 🛠 menu's Task View and Clean View: each turns its view on or off. */
557async function toggleView($: EngineInterface, view: 'task' | 'clean'): Promise<void> {
558 await setView($, view, !(await isViewOn($, view)))
559}
560
561/**
562 * What the views need once on: for Task View the checklist tool offered to the model, for Clean View the final
563 * replies so far (only kept while it is on).
564 */
565async function startViews($: EngineInterface): Promise<void> {
566 if (await read($, isTaskView)) {
567 await registerTool($, CHECKLIST_SPEC)
568 }
569 if (await read($, isCleanView)) {
570 const earlier = finalReplies(await $.session.messages().catch(() => []))
571 await update($, finals, list => earlier.reduce((acc, text) => addFinal(acc, text), list))
572 }
573}
574
575/** The status line under the prompt: the views that are on, comma separated; cleared when none. */
576async function showStatus($: EngineInterface): Promise<void> {
577 const on = [(await read($, isTaskView)) ? VIEW_NAMES.task : '', (await read($, isCleanView)) ? VIEW_NAMES.clean : ''].filter(Boolean)
578 $.ui.status(on.length > 0 ? on.join(', ') : undefined)
579}
580
581/** /taskview and /cleanview: toggles the view, or sets it with on/off, and says how it stands now. */
582async function viewCommand($: EngineInterface, view: 'task' | 'clean', args: string): Promise<{ text: string }> {
583 const name = VIEW_NAMES[view]
584 const isOn = nextToggle(await isViewOn($, view), args)
585 if (isOn === null) {
586 return { text: `Usage: /${view}view [on|off] (no argument toggles ${name})` }
587 }
588 await setView($, view, isOn)
589
590 return { text: `${name} ${isOn ? 'on' : 'off'}.` }
591}
592
593/** Redraws the dock's running times each second; with nothing running any more, the timer stops. */
594async function bumpDockTick($: EngineInterface): Promise<void> {
595 if (isRunning(await read($, agentRun))) {
596 await update($, dockTick, n => n + 1)
597 } else {
598 dockTimer?.cancel()
599 dockTimer = null
600 }
601}
602
603/** Starts the once-a-second redraw while the pane is open and a subagent runs; it stops itself once none does. */
604async function startDockTimer($: EngineInterface): Promise<void> {
605 if (dockTimer === null && (await read($, isDockOpen)) && isRunning(await read($, agentRun))) {
606 dockTimer = $.clock.every(1000, () => void bumpDockTick($))
607 }
608}
609
610/** Opens or closes the Background tasks pane; tasks are tracked either way. */
611async function setDockOpen($: EngineInterface, isOpen: boolean): Promise<void> {
612 await update($, isDockOpen, () => isOpen)
613 if (isOpen) {
614 await $.ui.open({ id: DOCK_PANE, title: DOCK_TITLE })
615 } else {
616 await $.ui.close({ id: DOCK_PANE })
617 }
618 await syncDock($)
619 await showStatus($)
620}
621
622/**
623 * Runs the pane's work only while it is open: its once-a-second redraw timer while a subagent runs, and the progress
624 * tool its subagents report through.
625 */
626async function syncDock($: EngineInterface): Promise<void> {
627 if (!(await read($, isDockOpen))) {
628 dockTimer?.cancel()
629 dockTimer = null
630 return
631 }
632 await startDockTimer($)
633 await registerTool($, PROGRESS_SPEC)
634}
635
636/** /subagents: opens the pane if closed, closes it if open. */
637async function toggleDock($: EngineInterface): Promise<boolean> {
638 const isOpen = !(await read($, isDockOpen))
639 await setDockOpen($, isOpen)
640
641 return isOpen
642}
643
644/** The 🛠 menu's Tasks: opens the Background tasks pane (its own close mark closes it), and closes the menu. */
645async function openDock($: EngineInterface): Promise<void> {
646 await update($, isToolsOpen, () => false)
647 await setDockOpen($, true)
648}
649
650/** The pane's Showing pick, saved for later sessions. */
651async function setShowing($: EngineInterface, showing: AgentShowing): Promise<void> {
652 await update($, agentShowing, () => showing)
653 await savePersonal($, { showing })
654}
655
656/** The pane's Hide after pick, saved for later sessions. */
657async function setHideAfter($: EngineInterface, minutes: HideAfter): Promise<void> {
658 await update($, hideAfter, () => minutes)
659 await savePersonal($, { hideAfter: minutes })
660}
661
662/** Appends one line to the Background tasks pane trace in the store, the newest TRACE_KEEP kept, one write after another. */
663function traceDock($: EngineInterface, line: string): Promise<void> {
664 traceQueue = traceQueue.then(() => appendTrace($, line)).catch(() => undefined)
665
666 return traceQueue
667}
668
669/** Reads the trace, adds one timed line and writes it back; only ever run through traceDock's queue. */
670async function appendTrace($: EngineInterface, line: string): Promise<void> {
671 const at = new Date(await $.clock.now()).toISOString().slice(11, 19)
672 const trace = ((await $.store.get('dockTrace')) as string[] | undefined) ?? []
673 await $.store.set('dockTrace', [...trace, `${at} ${line}`].slice(-TRACE_KEEP))
674}
675
676/** Waits for `work`, or `ms` at most, whichever comes first. */
677function waitAtMost($: EngineInterface, work: Promise<unknown>, ms: number): Promise<void> {
678 return new Promise(resolve => {
679 $.clock.after(ms, () => resolve())
680 void work.then(() => resolve(), () => resolve())
681 })
682}
683
684/**
685 * Changes the tasks, and the running count the project row shows with them: the row redraws only when the count
686 * changes, not on every line's activity.
687 */
688async function updateRun($: EngineInterface, change: (run: AgentRun | null) => AgentRun | null): Promise<void> {
689 await update($, agentRun, change)
690 const count = runningCount(await read($, agentRun))
691 if ((await read($, runningTasks)) !== count) {
692 await update($, runningTasks, () => count)
693 }
694}
695
696/** Marks a task finished: its line turns done, failed or stopped and moves down to the top of the finished ones. */
697async function endTask($: EngineInterface, id: string, status: EndStatus): Promise<void> {
698 trackedAgents.delete(id)
699 const now = await $.clock.now()
700 await updateRun($, run => (run === null ? run : finishCard(run, id, status, now)))
701}
702
703/** Finishes the line of every task a notice in `text` says has ended. */
704async function endNoticedTasks($: EngineInterface, text: string): Promise<void> {
705 for (const notice of parseTaskNotices(text)) {
706 const status = noticeEnd(notice)
707 if (status !== null) await endTask($, notice.id, status)
708 }
709}
710
711/** Gives a task a line, once; a subagent's or workflow agent's line follows its tool calls. */
712async function trackTask(
713 $: EngineInterface,
714 id: string,
715 task: string,
716 kind: AgentKind,
717 extra: Pick<AgentCard, 'doing' | 'expiresAt'> = {},
718): Promise<void> {
719 seenAgents.add(id)
720 if (kind === 'subagent' || kind === 'workflow') trackedAgents.add(id)
721 const now = await $.clock.now()
722 await updateRun($, r => addCard(r ?? newRun(now), id, task, now, kind, extra))
723 await startDockTimer($)
724}
725
726/**
727 * A subagent loop the pane has no line for yet, at its first model request: one the engine lists (started before the
728 * pane opened, or by another plugin) gets its own description; one it does not list is a workflow's agent while a
729 * workflow has been started, else the engine's own fork, which is passed over.
730 */
731async function noticeAgent($: EngineInterface, id: string): Promise<void> {
732 seenAgents.add(id)
733 if (await read($, isHandingOff)) {
734 return
735 }
736 const listed = (await $.agent.list().catch(() => [])).find(a => a.id === id)
737 if (listed !== undefined) {
738 await trackTask($, id, listed.description, 'subagent')
739 } else if (lastWorkflow !== null) {
740 workflowAgents += 1
741 await trackTask($, id, `${lastWorkflow} · agent ${workflowAgents}`, 'workflow')
742 }
743}
744
745/**
746 * Finishes any line whose task is over though its finish was never heard: a subagent the engine no longer has
747 * running, a monitor past its deadline.
748 */
749async function settleCards($: EngineInterface): Promise<void> {
750 const run = await read($, agentRun)
751 if (!isRunning(run)) {
752 return
753 }
754 const now = await $.clock.now()
755 for (const card of run?.cards.filter(c => c.status === 'running' && c.expiresAt !== undefined && c.expiresAt <= now) ?? []) {
756 await endTask($, card.id, 'done')
757 }
758 const agents = await $.agent.list().catch(() => null)
759 if (agents === null) {
760 return
761 }
762 // A subagent the engine no longer lists has finished too; only one listed as running or pending is still at work. A
763 // workflow's agents are never listed, nor are monitors: their own finish is the only word on them.
764 for (const card of run?.cards.filter(c => c.status === 'running' && kindOf(c) === 'subagent') ?? []) {
765 const status = agents.find(a => a.id === card.id)?.status ?? 'gone'
766 if (status !== 'running' && status !== 'pending') {
767 await traceDock($, `settled ${card.task}: ${status}`)
768 await endTask($, card.id, status === 'failed' ? 'failed' : status === 'killed' ? 'stopped' : 'done')
769 }
770 }
771}
772
773
774/**
775 * Starts a subagent as Claude Code would, given a line in the pane; while the pane is open it is also asked to report
776 * its progress there. Its start counts as in flight, so its first request finds the line rather than racing it.
777 */
778async function startSubagent(
779 $: EngineInterface,
780 e: Parameters<Events['agent.spawn']>[1],
781 next: Parameters<Events['agent.spawn']>[2],
782): Promise<Awaited<ReturnType<Events['agent.spawn']>>> {
783 const note = (await read($, isDockOpen)) ? SUBAGENT_NOTE : ''
784 let settle = () => {}
785 const inFlight = new Promise<void>(resolve => (settle = resolve))
786 startsInFlight.add(inFlight)
787 try {
788 const started = await next({ ...e, prompt: e.prompt + note })
789 if (started.agentId !== undefined) {
790 await trackTask($, started.agentId, e.description, 'subagent')
791 }
792
793 return started
794 } finally {
795 startsInFlight.delete(inFlight)
796 settle()
797 }
798}
799
800/**
801 * Shows `pose`, playing its transition from the pose before first; `rest` is the pose a moment pose (ONE_SHOT_MS)
802 * gives way to. Showing the pose already up only changes what it gives way to.
803 */
804async function showMascot($: EngineInterface, pose: MascotPose, rest: MascotPose = pose): Promise<void> {
805 if (!(await isMascotOn($))) {
806 return
807 }
808 const now = await $.clock.now()
809 // Checked inside the update, so two calls at once (parallel tool calls) change the pose once.
810 let isChanged = false
811 const shown = await update($, mascot, m => {
812 isChanged = m.pose !== pose
813 return isChanged ? { pose, rest, from: m.pose, change: m.change + 1, seq: m.seq + 1, since: now } : { ...m, rest }
814 })
815 if (!isChanged) {
816 return
817 }
818 const { change } = shown
819 // Once the intro has played the source drops it, so a redraw can only ever restart the loop.
820 $.clock.after(introMs(pose, shown.from), () => void settleMascot($, change))
821 const hold = ONE_SHOT_MS[pose]
822 if (hold !== undefined) {
823 $.clock.after(hold, () => void endMoment($, change))
824 }
825}
826
827/** Drops the played intro from the mascot's source, unless the pose changed since. */
828async function settleMascot($: EngineInterface, change: number): Promise<void> {
829 await update($, mascot, m => (m.change === change && m.from !== null ? { ...m, from: null, seq: m.seq + 1 } : m))
830}
831
832/** A moment pose that is still up gives way to the pose underneath it. */
833async function endMoment($: EngineInterface, change: number): Promise<void> {
834 const shown = await read($, mascot)
835 if (shown.change === change) {
836 await showMascot($, shown.rest)
837 }
838}
839
840/** The pose underneath when no turn runs: puzzled while a question waits on the person, standing otherwise. */
841async function restingPose($: EngineInterface): Promise<MascotPose> {
842 return (await read($, asking)) > 0 || (await read($, isQuestionOpen)) ? 'puzzled' : 'idle'
843}
844
845/**
846 * Where the mascot should be when he is turned on: his poses were not followed while he was off, so the question
847 * left open is read back from the conversation.
848 */
849async function syncMascot($: EngineInterface): Promise<void> {
850 const isAsked = isQuestionOpenIn(await $.session.messages().catch(() => []))
851 await update($, isQuestionOpen, () => isAsked)
852 await showMascot($, isTurnRunning ? 'working' : await restingPose($))
853}
854
855/** Standing idle long enough, the mascot sits down and falls asleep; the person's next prompt wakes him. */
856async function checkSleep($: EngineInterface): Promise<void> {
857 const shown = await read($, mascot)
858 if (shown.pose === 'idle' && (await $.clock.now()) - shown.since >= SLEEP_AFTER_MS && (await isMascotOn($))) {
859 await showMascot($, 'sleeping')
860 }
861}
862
863/** Draws nothing in a transcript row's place: how Clean View hides tool calls and in-progress replies. */
864function drawNothing($: EngineInterface, e: Parameters<EngineInterface['ui']['resolve']>[0]) {
865 const { Box } = $.ui.resolve(e)
866
867 return <Box key="aitools-hidden" display="none" />
868}
869
870/** Opens or closes the 🛠 menu above the bar. */
871async function toggleMenu($: EngineInterface): Promise<void> {
872 await update($, isToolsOpen, open => !open)
873}
874
875/** ⁝: opens the AI Tool Settings pane, both files read first so it shows them as they stand, or closes it. */
876async function toggleSettingsPane($: EngineInterface): Promise<void> {
877 await update($, isToolsOpen, () => false)
878 if (await read($, isSettingsOpen)) {
879 await update($, isSettingsOpen, () => false)
880 await $.ui.close({ id: SETTINGS_PANE })
881 return
882 }
883 await reloadConfigs($)
884 await update($, settingsNote, () => null)
885 await update($, isSettingsOpen, () => true)
886 await $.ui.open({ id: SETTINGS_PANE, title: SETTINGS_TITLE })
887}
888
889/**
890 * The settings pane's project fields, written to the project file the row reads (the checkout's when there is none
891 * yet), keeping everything else in it; then read back, so the row and the pane follow. A refused URL or a file that
892 * can't be read or written says why in the pane instead.
893 */
894async function saveProject($: EngineInterface, change: ProjectChange): Promise<void> {
895 const repo = await read($, githubRepo)
896 if (repo === null) {
897 return
898 }
899 const fail = (text: string) => update($, settingsNote, () => ({ text, isError: true }))
900 const problem = typeof change.board === 'string' ? boardProblem(change.board) : null
901 if (problem !== null) {
902 await fail(problem)
903 return
904 }
905 const path = projectPath ?? `${repo.checkout}/.claude/${CONFIG_FILE}`
906 const text = await readText($, path)
907 let raw: Json = {}
908 if (text !== null) {
909 const parsed = parseConfig(text)
910 if ('error' in parsed) {
911 await fail(`${path} isn't valid JSON (${parsed.error}), so it was left as it is.`)
912 return
913 }
914 raw = parsed.value
915 }
916 try {
917 await $.fs.write(path, configText(withProject(raw, change)))
918 } catch (err) {
919 await fail(`Couldn't save ${path}: ${errorText(err)}`)
920 return
921 }
922 await update($, settingsNote, () => null)
923 await loadProject($)
924}
925
926/** Every 30 s: countdowns redraw while the bar shows; the mascot may fall asleep. */
927async function onTick($: EngineInterface): Promise<void> {
928 if (!(await read($, isHidden))) {
929 await update($, tick, n => (n ?? 0) + 1)
930 }
931 await checkSleep($)
932 // The config files may have been edited by hand, and the branch changed in a terminal.
933 await reloadConfigs($)
934 await refreshRepo($)
935}
936
937/** The mascot drawing last built: it only changes with the pose, so redraws of the bar reuse it. */
938let lastMascot: { state: MascotState; drawing: ReturnType<typeof mascotDrawing> } | null = null
939
940function drawMascot(state: MascotState): ReturnType<typeof mascotDrawing> {
941 if (lastMascot?.state.pose !== state.pose || lastMascot.state.from !== state.from || lastMascot.state.seq !== state.seq) {
942 lastMascot = { state, drawing: mascotDrawing(state) }
943 }
944
945 return lastMascot.drawing
946}
947
948/** Where a session's /keepwarm window is kept, so a restart of the session picks it up again. */
949const keepWarmKey = (sessionId: string) => `keepWarm:${sessionId}`
950
951/**
952 * Sets /keepwarm's one timer: its next ping, KEEPWARM_LEAD_MS before the cache lapses, or, with no ping due (no request
953 * yet, the cache already cold, or warm past the window), the window's end. Run again after every main turn and ping.
954 */
955async function armKeepWarm($: EngineInterface): Promise<void> {
956 keepWarmTimer?.cancel()
957 keepWarmTimer = null
958 const kw = await read($, keepWarm)
959 if (kw === null) {
960 return
961 }
962 const now = await $.clock.now()
963 if (now >= kw.until) {
964 await stopKeepWarm($, null)
965 return
966 }
967 const delay = pingDelay(now, await read($, cacheAt), kw.until)
968 keepWarmTimer =
969 delay === null
970 ? $.clock.after(kw.until - now, () => void armKeepWarm($))
971 : $.clock.after(delay, () => void pingCache($))
972}
973
974/** Ends /keepwarm: on time or turned off (`why` null), or early, with the reason /keepwarm status gives. */
975async function stopKeepWarm($: EngineInterface, why: string | null): Promise<void> {
976 keepWarmTimer?.cancel()
977 keepWarmTimer = null
978 await update($, keepWarm, () => null)
979 await update($, keepWarmStopped, () => why)
980 await $.store.delete(keepWarmKey(await $.session.id())).catch(() => undefined)
981 if (why !== null) {
982 $.ui.log(`aitools: keepwarm stopped: ${why}`)
983 }
984}
985
986/**
987 * One keepwarm ping: a request over the conversation as the main thread last sent it (`$.model.fork`), which the API
988 * serves from the cache, restarting its hour. Its readback must show a read of the prefix; anything else stops the
989 * window, since the cache is gone and pinging on would pay a write every hour.
990 */
991async function pingCache($: EngineInterface): Promise<void> {
992 keepWarmTimer = null
993 const kw = await read($, keepWarm)
994 const at = await read($, cacheAt)
995 const now = await $.clock.now()
996 // A turn's own requests keep the cache warm, and its end sets the timer again; a request since the timer was set
997 // has moved the ping later.
998 if (kw === null || isTurnRunning || at === null || now < at + CACHE_TTL - KEEPWARM_LEAD_MS - 1000) {
999 if (!isTurnRunning) await armKeepWarm($)
1000 return
1001 }
1002 if (now >= kw.until || now >= at + CACHE_TTL) {
1003 await armKeepWarm($)
1004 return
1005 }
1006 let reply: Awaited<ReturnType<EngineInterface['model']['fork']>>
1007 try {
1008 reply = await $.model.fork({ prompt: KEEPWARM_PROMPT })
1009 } catch (err) {
1010 await stopKeepWarm($, `the ping failed: ${errorText(err)}`)
1011 return
1012 }
1013 if (!reply.isAnswered) {
1014 await stopKeepWarm($, failureText(reply.reason, 'status' in reply ? reply.status : null))
1015 return
1016 }
1017 if (!isWarmReadback(reply.usage)) {
1018 await stopKeepWarm($, coldPingText(reply.usage))
1019 return
1020 }
1021 const model = await read($, mainModel)
1022 const cost = model === null ? null : price(model, reply.usage)
1023 const pinged = await $.clock.now()
1024 const receipt = { at: pinged, read: reply.usage.cache_read_input_tokens, usd: cost?.usd ?? null, isEstimate: cost?.isEstimate ?? false }
1025 await update($, cacheAt, () => pinged)
1026 await rememberCacheAt($, pinged).catch(() => undefined)
1027 await update($, keepWarm, k => (k === null ? k : { ...k, lastPing: receipt }))
1028 await armKeepWarm($)
1029}
1030
1031/** `/keepwarm [6h|90m|off|status]`: starts (6 hours bare), ends or reports keeping the cache warm. */
1032async function keepWarmCommand($: EngineInterface, args: string): Promise<{ text: string }> {
1033 const asked = parseKeepWarm(args)
1034 if (asked === null || asked.kind === 'help') {
1035 return { text: KEEPWARM_HELP }
1036 }
1037 if (asked.kind === 'stop') {
1038 const wasOn = (await read($, keepWarm)) !== null
1039 await stopKeepWarm($, null)
1040 return { text: wasOn ? 'Keepwarm stopped: the cache lapses an hour after the last request.' : 'Keepwarm is already off.' }
1041 }
1042 // One reading of the clock, so `/keepwarm 5h` reports 5h0m, not the 4h59m a later reading would floor to.
1043 const now = await $.clock.now()
1044 if (asked.kind === 'start') {
1045 const until = now + asked.ms
1046 await update($, keepWarm, () => ({ until, lastPing: null }))
1047 await update($, keepWarmStopped, () => null)
1048 await $.store.set(keepWarmKey(await $.session.id()), until)
1049 await armKeepWarm($)
1050 }
1051
1052 return { text: keepWarmStatus(await read($, keepWarm), await read($, keepWarmStopped), await read($, cacheAt), now) }
1053}
1054
1055/** On load: a window this session saved (before a restart) comes back, and the timer is set again either way. */
1056async function restoreKeepWarm($: EngineInterface): Promise<void> {
1057 if ((await read($, keepWarm)) === null) {
1058 const saved = await $.store.get(keepWarmKey(await $.session.id()))
1059 if (typeof saved === 'number' && saved > (await $.clock.now())) {
1060 await update($, keepWarm, () => ({ until: saved, lastPing: null }))
1061 }
1062 }
1063 await armKeepWarm($)
1064}
1065
1066/**
1067 * The bar's ? button: /askme's prompt sent as the person's, or, while a turn runs or when the prompt cannot be sent,
1068 * `/askme` put in the prompt box ahead of any draft (which then rides along as its note) to send by hand. When the box
1069 * cannot take it either, the button stays.
1070 */
1071async function askFromBar($: EngineInterface, isWorking: boolean): Promise<void> {
1072 await update($, isAskable, () => false)
1073 if (!isWorking) {
1074 const sent = await $.prompt.submit({ text: askmeText(''), asUser: true }).catch(() => null)
1075 if (sent !== null && sent.drop === undefined) {
1076 return
1077 }
1078 }
1079 const draft = (await $.prompt.read()).text.trim()
1080 const filled = await $.prompt.fill({ text: draft === '' ? '/askme' : `/askme ${draft}` }).catch(() => null)
1081 if (!filled?.isFilled) {
1082 await update($, isAskable, () => true)
1083 }
1084}
1085
1086/** The bar, with the 🛠 or ⁝ menu above it while one is open, for the AbovePrompt band. */
1087async function drawBar($: EngineInterface, el: Parameters<typeof renderBar>[0], isWorking: boolean) {
1088 await read($, tick)
1089 const current = normalizeSettings(await read($, settings))
1090
1091 const view = {
1092 snapshot: await read($, snapshot),
1093 month: await read($, month),
1094 projection: await read($, projection),
1095 cacheAt: await read($, cacheAt),
1096 model: await read($, mainModel),
1097 settings: current,
1098 now: await $.clock.now(),
1099 }
1100 const flags = {
1101 isWorking,
1102 isHandingOff: await read($, isHandingOff),
1103 isToolsOpen: await read($, isToolsOpen),
1104 isAskable: await read($, isAskable),
1105 isKeepingWarm: (await read($, keepWarm)) !== null,
1106 isTaskView: await read($, isTaskView),
1107 isCleanView: await read($, isCleanView),
1108 hasRepo: (await read($, githubRepo)) !== null,
1109 // Read only where it is drawn and turned on, so no other bar redraws for the mascot.
1110 mascot: el.Svg && current.mascot ? drawMascot(await read($, mascot)) : null,
1111 }
1112 // Closed (× on its title row), the checklist stays away until the person's next prompt.
1113 const list =
1114 !flags.isTaskView || (await read($, isChecklistClosed))
1115 ? null
1116 : renderChecklist(el, await read($, checklist), isWorking, { isCollapsed: await read($, isChecklistCollapsed), markGap: el.Svg ? 0 : 1 }, {
1117 toggle: () => void update($, isChecklistCollapsed, isCollapsed => !isCollapsed),
1118 close: () => void update($, isChecklistClosed, () => true),
1119 })
1120
1121 const projectRow = await drawProjectRow($, el, el.Svg ? 0 : 2)
1122
1123 return renderBar(el, view, flags, {
1124 toggleTools: () => void toggleMenu($),
1125 askme: () => void askFromBar($, isWorking),
1126 handoff: () => void showHandoff($),
1127 openNotes: () => void openNotes($),
1128 toggleSettings: () => void toggleSettingsPane($),
1129 toggleView: view => void toggleView($, view),
1130 openDock: () => void openDock($),
1131 openBranches: () => void openBranches($),
1132 openProject: () => void openProject($),
1133 }, list, projectRow)
1134}
1135
1136async function measure($: EngineInterface, e: { rateLimits: readonly { kind: string; percentUsed: number; resetsAt?: string }[]; context: Snapshot['context'] & {}; cost?: { usd: number } }) {
1137 const previous = await read($, snapshot)
1138 // Off the subscription's ledger, the session's cost comes from the last scan, if a cost is shown at all.
1139 const cache = e.cost || !(await isShown($, COST_KEYS)) ? null : await readCache($)
1140 const fallback = cache ? sessionUsd(cache, await $.session.id()) : undefined
1141 const snap: Snapshot = {
1142 fiveHour: toWindow(e.rateLimits.find(r => r.kind === 'five_hour')) ?? previous?.fiveHour,
1143 weekly: toWindow(e.rateLimits.find(r => r.kind === 'seven_day')) ?? previous?.weekly,
1144 context: e.context.tokens === undefined ? (previous?.context ?? e.context) : e.context,
1145 threadUsd: e.cost?.usd ?? fallback,
1146 lastTurnUsd: previous?.lastTurnUsd,
1147 threadTokens: previous?.threadTokens,
1148 lastTurnTokens: previous?.lastTurnTokens,
1149 }
1150 await update($, snapshot, () => snap)
1151 // Kept for the next session's first figures; only the usage limits are read back.
1152 if (await isShown($, ['fiveHour', 'weekly'])) {
1153 await $.store.set('lastSnapshot', snap)
1154 }
1155 // The projection samples the 5-hour usage only while it is shown: turned on, it has a projection again once two
1156 // responses an hour apart or so have been sampled.
1157 if (!(await isShown($, 'burnRate'))) {
1158 await update($, projection, () => null)
1159 return
1160 }
1161 const now = await $.clock.now()
1162 let burn = ((await $.store.get('burn')) as BurnState | undefined) ?? EMPTY_BURN
1163 if (snap.fiveHour) {
1164 burn = addSample(burn, now, snap.fiveHour.percentUsed, snap.fiveHour.resetsAt)
1165 await $.store.set('burn', burn)
1166 }
1167 await update($, projection, () => project(burn, now))
1168}
1169
1170/** A context window this big is the model's 1M-context form, which the CLI names with a `[1m]` suffix. */
1171const ONE_M_WINDOW = 1_000_000
1172
1173/**
1174 * Writes the statusline file, when its option is on, from the figures the bar keeps: sessions writing at once each
1175 * land whole, the last one to write winning, its usage limits the account's newest reading.
1176 */
1177async function writeStatusline($: EngineInterface): Promise<void> {
1178 if (!normalizeSettings(await read($, settings)).statuslineFile) {
1179 return
1180 }
1181 const snap = await read($, snapshot)
1182 const [sessionId, cwd, projectDir, usage, version, dir, now] = await Promise.all([
1183 $.session.id(),
1184 $.session.cwd(),
1185 $.session.root(),
1186 $.session.usage(),
1187 $.session.version().then(v => v.version).catch(() => undefined),
1188 configDir($),
1189 $.clock.now(),
1190 ])
1191 const context = snap?.context ?? usage.context
1192 const model = (await read($, mainModel)) ?? (await $.session.model())
1193 const transcript = `${dir}/projects/${projectSlug(projectDir)}/${sessionId}.jsonl`
1194 const text = JSON.stringify(
1195 statusline({
1196 sessionId,
1197 transcriptPath: (await $.fs.exists(transcript).catch(() => false)) ? transcript : undefined,
1198 cwd,
1199 projectDir,
1200 model: context.window >= ONE_M_WINDOW && !model.endsWith('[1m]') ? `${model}[1m]` : model,src/agentdock.tsx 407 lines1import type { Elements } from 'claude-code'
2
3import type { AgentCard, AgentKind, AgentRun, AgentShowing, HideAfter } from '../types'
4import { progressBar } from './checklist'
5import { renderPick } from './picks'
6import { NOTE_SHRINK, ago, elapsed } from './format'
7import { BLUE, DOING_TEXT, FAINT_TEXT, RED } from './palette'
8import { TOTEM_HEIGHT, TOTEM_WIDTH, totemAlt, totemSvg } from './totem'
9
10/** How many finished tasks the pane keeps listing, newest first out of view last. */
11export const FINISHED_KEEP = 50
12/** The Showing picks, in order: every kind, then each kind on its own. */
13export const SHOWING_PICKS = ['all', 'subagent', 'workflow', 'monitor'] as const
14export const SHOWING_LABELS = {
15 all: 'All',
16 subagent: 'Subagents',
17 workflow: 'Workflows',
18 monitor: 'Monitors',
19} as const
20/** What the pane says while the Showing pick leaves it no lines. */
21export const EMPTY_LABELS = {
22 all: 'Background tasks will appear here when started.',
23 subagent: 'Subagents will appear here when started.',
24 workflow: 'Workflow agents will appear here when started.',
25 monitor: 'Monitors will appear here when started.',
26} as const
27/** Each line's type column. */
28export const KIND_LABELS = { subagent: 'Subagent', workflow: 'Workflow', monitor: 'Monitor' } as const
29/** The type column's width: its longest label and a space. */
30const KIND_WIDTH = 9
31/** The Hide after picks: never, then the longest wait to the shortest, in minutes. */
32export const HIDE_AFTER = ['never', 60, 15, 5, 0] as const
33export const DEFAULT_HIDE_AFTER: HideAfter = 'never'
34
35export function hideAfterLabel(minutes: HideAfter): string {
36 if (minutes === 'never') return 'Never'
37 if (minutes === 0) return 'Immediate'
38
39 return minutes % 60 === 0 ? `${minutes / 60}h` : `${minutes}m`
40}
41
42export function isShowing(value: unknown): value is AgentShowing {
43 return SHOWING_PICKS.some(pick => pick === value)
44}
45
46export function isHideAfter(value: unknown): value is HideAfter {
47 return HIDE_AFTER.some(m => m === value)
48}
49
50/** The lines the pane's picks leave: those of the kind Showing picks, and finished ones until Hide after's time is up. */
51export function visibleCards(
52 cards: readonly AgentCard[],
53 view: { showing: AgentShowing; hideAfter: HideAfter; now: number },
54): AgentCard[] {
55 return cards.filter(
56 c =>
57 (view.showing === 'all' || kindOf(c) === view.showing) &&
58 (c.status === 'running' || view.hideAfter === 'never' || view.now - (c.endedAt ?? view.now) < view.hideAfter * 60_000),
59 )
60}
61
62export const PROGRESS_TOOL = 'agent_progress'
63export const PROGRESS_TOOL_ID = 'mcp__aitools__agent_progress'
64export const DOCK_PANE = 'tasks'
65export const DOCK_TITLE = 'Background tasks'
66
67/** A line's kind; lines from before kinds existed were all subagents. */
68export function kindOf(card: AgentCard): AgentKind {
69 return card.kind ?? 'subagent'
70}
71
72export const PROGRESS_SPEC = {
73 name: PROGRESS_TOOL,
74 description:
75 'For a subagent listed in the Subagents pane: reports what you are doing and how far along your task is, shown on ' +
76 'your line in the pane. Call it when you start, about every quarter of your work, and at 100 when done.',
77 inputSchema: {
78 type: 'object',
79 properties: {
80 doing: { type: 'string', description: 'What you are doing now, in a few plain words' },
81 percent: { type: 'number', minimum: 0, maximum: 100, description: 'Your estimate of how far along your task is' },
82 },
83 required: ['doing', 'percent'],
84 },
85}
86
87/** How a subagent is asked to report, so its line can show a percent and bar. */
88const REPORT_ASK =
89 `Report progress with ${PROGRESS_TOOL_ID}: call it when you start with a few words on what you are doing and ` +
90 'percent 0, again about every quarter of your work, and with percent 100 when you finish.)'
91
92/** What a subagent's prompt gains while the pane is open, so its line can show progress. */
93export const SUBAGENT_NOTE = `\n\n(Your work shows as a line in the person's Subagents pane. ${REPORT_ASK}`
94
95export function newRun(now: number): AgentRun {
96 return { startedAt: now, cards: [] }
97}
98
99/** The run with a started task's line, once; `extra` adds what its kind knows from the start (a note, a deadline). */
100export function addCard(
101 run: AgentRun,
102 id: string,
103 task: string,
104 now: number,
105 kind: AgentKind = 'subagent',
106 extra: Pick<AgentCard, 'doing' | 'expiresAt'> = {},
107): AgentRun {
108 if (run.cards.some(c => c.id === id)) {
109 return run
110 }
111 const card: AgentCard = { id, task, kind, ...extra, status: 'running', startedAt: now }
112
113 return { ...run, cards: [...run.cards, card] }
114}
115
116/** The run with a subagent's latest activity on its line. */
117export function noteActivity(run: AgentRun, id: string, doing: string): AgentRun {
118 return { ...run, cards: run.cards.map(c => (c.id === id ? { ...c, doing } : c)) }
119}
120
121/** The last part of a path: what a line names a file by. */
122function baseName(path: unknown): string {
123 return typeof path === 'string' ? (path.split('/').filter(Boolean).at(-1) ?? path) : ''
124}
125
126/** Text on one line, cut to `n` characters with `…`; anything but a string is empty. */
127export function clip(text: unknown, n = 40): string {
128 const one = typeof text === 'string' ? text.replace(/\s+/g, ' ').trim() : ''
129 return one.length > n ? `${one.slice(0, n - 1)}…` : one
130}
131
132/** A short line on what a tool call does, for a subagent's line: `Reading bar.tsx`, `Running npm test`. */
133export function activityText(tool: string, input: Record<string, unknown>): string {
134 switch (tool) {
135 case 'Read':
136 return `Reading ${baseName(input.file_path)}`
137 case 'Edit':
138 case 'MultiEdit':
139 case 'NotebookEdit':
140 return `Editing ${baseName(input.file_path ?? input.notebook_path)}`
141 case 'Write':
142 return `Writing ${baseName(input.file_path)}`
143 case 'Bash':
144 return `Running ${clip(input.command)}`
145 case 'Grep':
146 return `Searching for ${clip(input.pattern, 30)}`
147 case 'Glob':
148 return `Finding ${clip(input.pattern, 30)}`
149 case 'WebFetch':
150 return `Fetching ${clip(typeof input.url === 'string' ? input.url.replace(/^https?:\/\//, '') : '', 30)}`
151 case 'WebSearch':
152 return `Searching the web for ${clip(input.query, 30)}`
153 case 'Agent':
154 return `Starting ${clip(input.description, 30) || 'a subagent'}`
155 default:
156 return `Using ${tool.replace(/^mcp__[^_]+__/, '')}`
157 }
158}
159
160/** A workflow's name, for its agents' lines: the tool's `name`, else its script's `meta` name. */
161export function workflowName(input: Record<string, unknown>): string {
162 if (typeof input.name === 'string' && input.name) {
163 return input.name
164 }
165 const script = typeof input.script === 'string' ? input.script : ''
166
167 return /\bname\s*:\s*['"`]([^'"`]+)['"`]/.exec(script)?.[1] ?? 'workflow'
168}
169
170/** One background task's notice, as the chat receives it: its id, and its status or event when it gives one. */
171export type TaskNotice = { id: string; status?: string; event?: string }
172
173/** The task notices in a prompt's text: `<task-notification>` blocks, each naming its task. */
174export function parseTaskNotices(text: string): TaskNotice[] {
175 const notices: TaskNotice[] = []
176 for (const [, body = ''] of text.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)) {
177 const tag = (name: string) => new RegExp(`<${name}>([\\s\\S]*?)</${name}>`).exec(body)?.[1]?.trim()
178 const id = tag('task-id')
179 if (id) notices.push({ id, status: tag('status'), event: tag('event') })
180 }
181
182 return notices
183}
184
185/**
186 * How a notice says its task ended, or null when it does not (a monitor's ordinary event): a status of completed,
187 * failed, or killed or stopped; a monitor's own closing event (`[Monitor expired after 30m ...]`).
188 */
189export function noticeEnd(notice: TaskNotice): EndStatus | null {
190 switch (notice.status) {
191 case 'completed':
192 return 'done'
193 case 'failed':
194 return 'failed'
195 case 'killed':
196 case 'stopped':
197 return 'stopped'
198 }
199
200 return notice.event?.startsWith('[Monitor ') ? 'done' : null
201}
202
203/** The run with one subagent's reported progress; a percent is rounded and held to 0 to 100. */
204export function reportProgress(run: AgentRun, id: string, doing: string, percent: number): AgentRun {
205 const pct = Math.min(100, Math.max(0, Math.round(Number.isFinite(percent) ? percent : 0)))
206
207 const report = (c: AgentCard): AgentCard => ({ ...c, doing: doing.trim() || c.doing, percent: pct })
208
209 return { ...run, cards: run.cards.map(c => (c.id === id ? report(c) : c)) }
210}
211
212/** How a finished task ended. */
213export type EndStatus = Exclude<AgentCard['status'], 'running'>
214
215/** The run with a task finished, as `status` says; a line already finished keeps how it ended. */
216export function finishCard(run: AgentRun, id: string, status: EndStatus, now: number): AgentRun {
217 if (!run.cards.some(c => c.id === id && c.status === 'running')) {
218 return run
219 }
220 const cards = run.cards.map((c): AgentCard => (c.id === id ? { ...c, status, endedAt: now } : c))
221 // Finished lines stay for the session, the newest FINISHED_KEEP of them.
222 const finished = cards.filter(c => c.status !== 'running').sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))
223 const dropped = new Set(finished.slice(FINISHED_KEEP).map(c => c.id))
224
225 return { ...run, cards: cards.filter(c => !dropped.has(c.id)) }
226}
227
228export function isRunning(run: AgentRun | null): boolean {
229 return run !== null && run.cards.some(c => c.status === 'running')
230}
231
232/** How many tasks are running: what the project row's button counts. */
233export function runningCount(run: AgentRun | null): number {
234 return run?.cards.filter(c => c.status === 'running').length ?? 0
235}
236
237/** The project row's button: `1 bg task`, `5 bg tasks` (background tasks, as Claude Code calls them). */
238export function tasksLabel(n: number): string {
239 return n === 1 ? '1 bg task' : `${n} bg tasks`
240}
241
242/** The pane's header line: the non-zero counts. */
243export function headerText(run: AgentRun | null): string {
244 const cards = run?.cards ?? []
245 const counts: [number, string][] = [
246 [cards.filter(c => c.status === 'running').length, 'running'],
247 [cards.filter(c => c.status === 'done').length, 'done'],
248 [cards.filter(c => c.status === 'failed').length, 'failed'],
249 [cards.filter(c => c.status === 'stopped').length, 'stopped'],
250 ]
251
252 return counts.filter(([n]) => n > 0).map(([n, label]) => `${n} ${label}`).join(' · ')
253}
254
255/** `Svg` only where the surface draws it (the desktop): the totem shows there alone. */
256type El = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'> & { Svg?: Elements['desktop']['Svg'] }
257
258const QUIET = { plain: true, dimColor: true } as const
259/** Task View's marks: ● running in blue, ✓ done (dim, as the rest of a finished line); × failed (red) or stopped. */
260const CARD_MARKS = { running: '●', done: '✓', failed: '×', stopped: '×' } as const
261
262/** The run's lines in order: running ones (oldest first), then finished ones, the most recently finished first. */
263export function orderedCards(run: AgentRun): AgentCard[] {
264 const running = run.cards.filter(c => c.status === 'running').sort((a, b) => a.startedAt - b.startedAt)
265 const finished = run.cards.filter(c => c.status !== 'running').sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))
266
267 return [...running, ...finished]
268}
269
270/**
271 * The Background tasks pane: controls on top, then the header (or what will appear when nothing is listed) and a
272 * line per task. Where SVG is drawn, the totem stands left of the controls, the header beside it too.
273 */
274export function renderDock(
275 el: El,
276 dock: {
277 showing: AgentShowing
278 hideAfter: HideAfter
279 run: AgentRun | null
280 now: number
281 },
282 on: {
283 setShowing(showing: AgentShowing): void
284 setHideAfter(minutes: HideAfter): void
285 },
286) {
287 const { Box, Text, Svg } = el
288 const { showing, run, now } = dock
289 const shown = run === null ? null : { ...run, cards: visibleCards(run.cards, { ...dock, now }) }
290 const isEmpty = shown === null || shown.cards.length === 0
291 const running = shown?.cards.filter(c => c.status === 'running').length ?? 0
292
293 return (
294 <Box flexDirection="column">
295 <Box flexDirection="row">
296 {Svg && (
297 <Box key="totem" flexShrink={0} marginRight={2}>
298 <Svg source={totemSvg(running)} alt={totemAlt(running)} width={TOTEM_WIDTH} height={TOTEM_HEIGHT} />
299 </Box>
300 )}
301 <Box flexDirection="column" flexShrink={1} gap={1}>
302 {renderControls(el, dock, on)}
303 <Text dimColor>{isEmpty ? EMPTY_LABELS[showing] : headerText(shown)}</Text>
304 </Box>
305 </Box>
306 {/* A blank line under the header, then the tasks running and the finished ones, the latest first. */}
307 {shown !== null && shown.cards.length > 0 && (
308 <Box flexDirection="column" marginTop={1}>
309 {orderedCards(shown).map(card => renderCard(el, card, now))}
310 </Box>
311 )}
312 </Box>
313 )
314}
315
316/** The pane's two option rows: Showing and Hide after. */
317function renderControls(
318 el: El,
319 dock: { showing: AgentShowing; hideAfter: HideAfter },
320 on: {
321 setShowing(showing: AgentShowing): void
322 setHideAfter(minutes: HideAfter): void
323 },
324) {
325 const { Box, Text } = el
326 const { showing, hideAfter } = dock
327
328 return (
329 <Box flexDirection="column">
330 <Box flexDirection="row" columnGap={1}>
331 <Text dimColor>Showing:</Text>
332 {SHOWING_PICKS.map(pick => (
333 renderPick(el, `showing-${pick}`, SHOWING_LABELS[pick], showing === pick, () => on.setShowing(pick))
334 ))}
335 </Box>
336 <Box flexDirection="row" columnGap={1}>
337 <Text dimColor>Hide after:</Text>
338 {HIDE_AFTER.map(minutes => (
339 renderPick(el, `hide-${minutes}`, hideAfterLabel(minutes), hideAfter === minutes, () => on.setHideAfter(minutes))
340 ))}
341 </Box>
342 </Box>
343 )
344}
345
346/**
347 * One task's row: its mark, its type column, then its name. Running: a blue ●, the name in the half-dim grey and what
348 * it is doing faint, then (once a subagent reports progress) its percent and bar, and its time so far. Finished: all
349 * faint (a failed one's × red), and its time with how long ago it finished: `0:09 (3m ago)`.
350 */
351function renderCard(el: El, card: AgentCard, now: number) {
352 const { Box, Text } = el
353 const time = elapsed((card.endedAt ?? now) - card.startedAt)
354 if (card.status !== 'running') {
355 return (
356 <Box key={`agent-${card.id}`} flexDirection="row" justifyContent="space-between">
357 <Box flexDirection="row" flexShrink={1} minWidth={0} overflow="hidden">
358 <Text color={card.status === 'failed' ? RED : FAINT_TEXT}>{`${CARD_MARKS[card.status]} `}</Text>
359 <Text color={FAINT_TEXT}>{KIND_LABELS[kindOf(card)].padEnd(KIND_WIDTH)}</Text>
360 <Box flexShrink={1} minWidth={0} overflow="hidden">
361 <Text color={FAINT_TEXT} wrap="truncate-end">
362 {card.task}
363 </Text>
364 </Box>
365 </Box>
366 <Text color={FAINT_TEXT}>{` ${time} (${ago(now - (card.endedAt ?? now))})`}</Text>
367 </Box>
368 )
369 }
370 const percent = card.percent ?? 0
371 const bar = progressBar(percent)
372 const isMeasured = card.percent !== undefined
373
374 return (
375 <Box key={`agent-${card.id}`} flexDirection="row" justifyContent="space-between">
376 <Box flexDirection="row" flexShrink={1} minWidth={0} overflow="hidden">
377 <Text color={BLUE}>{`${CARD_MARKS.running} `}</Text>
378 <Text dimColor>{KIND_LABELS[kindOf(card)].padEnd(KIND_WIDTH)}</Text>
379 <Box flexShrink={1} minWidth={0} overflow="hidden">
380 <Text color={DOING_TEXT} wrap="truncate-end">
381 {card.task}
382 </Text>
383 </Box>
384 {card.doing && (
385 <Box flexShrink={NOTE_SHRINK} minWidth={0} overflow="hidden">
386 <Text color={FAINT_TEXT} wrap="truncate-end">
387 {` · ${card.doing}`}
388 </Text>
389 </Box>
390 )}
391 </Box>
392 <Box flexDirection="row" flexShrink={0}>
393 {isMeasured ? (
394 <Box flexDirection="row">
395 <Text dimColor>{` ${String(percent).padStart(3)}% `}</Text>
396 <Text color={BLUE}>{bar.filled}</Text>
397 <Text dimColor>{bar.empty}</Text>
398 </Box>
399 ) : (
400 <Text>{' '.repeat(6 + bar.filled.length + bar.empty.length)}</Text>
401 )}
402 <Text dimColor>{` ${time.padStart(5)}`}</Text>
403 </Box>
404 </Box>
405 )
406}
407src/bar.tsx 360 lines1import type { Elements, RenderChildren } from 'claude-code'
2
3import { clockTime, duration, tokens, usd, weeklyReset } from './format'
4import { coldCacheUsd } from './pricing'
5import { usdSince } from './transcripts'
6import { VIEW_NAMES } from './checklist'
7import type { renderChecklist } from './checklist'
8import { MASCOT_HEIGHT, MASCOT_WIDTH } from './mascot'
9import { DOING_TEXT, RED, YELLOW } from './palette'
10import type { LimitWindow, MonthTotal, Settings, Snapshot } from '../types'
11
12export type View = {
13 snapshot: Snapshot | null
14 month: MonthTotal
15 projection: number | null
16 /** Start of the last main-thread model request, or null before one. */
17 cacheAt: number | null
18 /** The main chat's model, or null before its first request. */
19 model: string | null
20 settings: Settings
21 now: number
22}
23
24export type Tone = 'warn' | 'danger' | undefined
25
26/** One metric: `text` is the whole of it, `label` the leading word(s) naming it, drawn dimmer than the value after. */
27export type Segment = { key: string; label: string; text: string; tone?: Tone }
28
29export const SEPARATOR = ' '
30/** Names the mod at the start of the bar; the `label` toggle hides it. */
31export const LABEL = 'AI Tools'
32
33/** Claude Code writes the main conversation's prompt cache with the 1-hour TTL; each request restarts it. */
34export const CACHE_TTL = 60 * 60_000
35
36/** The bar's marker while /keepwarm runs: a sun with rays (U+263C), which has no color-emoji form. */
37export const KEEPWARM_MARK = '\u263C'
38
39const DASH = '-'
40const MIN = 60_000
41const CACHE_WARN = 10 * MIN
42const TONE_COLORS = { warn: YELLOW, danger: RED } as const
43/** Values in the checklist's half-dim grey: brighter than their dim labels, quieter than full text. */
44const VALUE_COLOR = DOING_TEXT
45
46export function toneFor(pct: number | undefined, settings: Settings): Tone {
47 if (!settings.thresholdColors || pct === undefined) {
48 return undefined
49 }
50
51 return pct >= 90 ? 'danger' : pct >= 70 ? 'warn' : undefined
52}
53
54/** What a cold cache costs the next request over a warm one, priced at the chat's model; null while unknown. */
55function coldCost(view: View): string | null {
56 const tokens = view.snapshot?.context?.tokens
57 const cost = tokens === undefined || view.model === null ? null : coldCacheUsd(view.model, tokens)
58
59 return cost === null ? null : usd(cost.usd, cost.isEstimate)
60}
61
62/**
63 * The cache segment: how long the cache stays warm, then (with Cold-cache cost on) what letting it lapse costs:
64 * `cache 42m, $0.50`, `cache cold, $0.50`. Cold-cache cost alone reads `cache $0.50`.
65 */
66function cacheSegment(view: View): Segment | null {
67 const { cacheAt, now, settings } = view
68 const cold = settings.coldCost ? coldCost(view) : null
69 if (!settings.cacheWarmth) {
70 return cold === null ? null : { key: 'cache', label: 'cache', text: `cache ${cold}` }
71 }
72 if (cacheAt === null) {
73 return { key: 'cache', label: 'cache', text: `cache ${DASH}${cold === null ? '' : `, ${cold}`}` }
74 }
75 const left = cacheAt + CACHE_TTL - now
76 const tone: Tone = !settings.thresholdColors ? undefined : left <= 0 ? 'danger' : left < CACHE_WARN ? 'warn' : undefined
77 const warmth = left <= 0 ? 'cache cold' : `cache ${Math.max(1, Math.ceil(left / MIN))}m`
78 const text = cold === null ? warmth : `${warmth}, ${cold}`
79
80 return { key: 'cache', label: 'cache', text, tone }
81}
82
83function monthText(month: MonthTotal): string {
84 if (month.status === 'loading') {
85 return 'month …'
86 }
87
88 return month.status === 'error' ? 'month ?' : `month ${usd(month.usd, month.isEstimate)}`
89}
90
91const WEEK_MS = 7 * 24 * 60 * 60_000
92
93/**
94 * What 1% of the weekly allowance costs at API prices, estimated: this machine's spend since the weekly window opened
95 * (its reset time less 7 days) over the weekly percent used. Null until both are known and above zero.
96 */
97export function usdPerWeeklyPercent(snapshot: Snapshot | null, month: MonthTotal): number | null {
98 const weekly = snapshot?.weekly
99 if (!weekly?.resetsAt || !(weekly.percentUsed > 0) || month.hours === undefined) {
100 return null
101 }
102 const spent = usdSince(month.hours, Date.parse(weekly.resetsAt) - WEEK_MS)
103
104 return spent > 0 ? spent / weekly.percentUsed : null
105}
106
107/** A share of the weekly allowance, with as many decimals as small shares need: `12%`, `2.1%`, `0.04%`, `<0.01%`. */
108export function percentText(pct: number): string {
109 if (pct < 0.01) {
110 return '<0.01%'
111 }
112
113 return `${pct >= 10 ? Math.round(pct) : pct >= 1 ? pct.toFixed(1) : pct.toFixed(2)}%`
114}
115
116/** The thread segment: its cost and the last turn's, each followed by its estimated share of the week when asked. */
117function threadSegment(view: View): Segment | null {
118 const { snapshot, settings: on } = view
119 const thread = snapshot?.threadUsd
120 const last = snapshot?.lastTurnUsd
121 const perPercent = on.threadPercent ? usdPerWeeklyPercent(snapshot, view.month) : null
122 const share = (spent: number | undefined) =>
123 perPercent === null || spent === undefined ? null : `~${percentText(spent / perPercent)}`
124 const threadShare = share(thread)
125 const lastShare = on.lastTurnCost ? share(last) : null
126
127 if (on.threadCost) {
128 const turn = on.lastTurnCost && last !== undefined ? ` (+${usd(last)})` : ''
129 const pct = threadShare === null ? '' : ` ${threadShare} wk${lastShare === null ? '' : ` (+${lastShare.slice(1)})`}`
130 return { key: 'thread', label: 'thread', text: `thread ${thread === undefined ? DASH : usd(thread)}${turn}${pct}` }
131 }
132 if (on.lastTurnCost) {
133 return { key: 'thread', label: 'last', text: `last ${last === undefined ? DASH : usd(last)}${lastShare === null ? '' : ` ${lastShare} wk`}` }
134 }
135 if (threadShare !== null) {
136 return { key: 'thread', label: 'thread', text: `thread ${threadShare} wk` }
137 }
138
139 return null
140}
141
142/** The token segment: the thread's total and the last turn's in parentheses, as the cost segment shows dollars. */
143function tokenSegment(snapshot: Snapshot | null, on: Settings): Segment {
144 const count = (n: number | undefined) => (n === undefined ? DASH : tokens(n))
145 if (on.threadTokens) {
146 const turn = on.lastTurnTokens && snapshot?.lastTurnTokens !== undefined ? ` (+${tokens(snapshot.lastTurnTokens)})` : ''
147 return { key: 'tokens', label: 'tok', text: `tok ${count(snapshot?.threadTokens)}${turn}` }
148 }
149
150 return { key: 'tokens', label: 'last tok', text: `last tok ${count(snapshot?.lastTurnTokens)}` }
151}
152
153export function segments(view: View): Segment[] {
154 const { snapshot, settings: on, now } = view
155 const segs: Segment[] = []
156 const fiveHour = snapshot?.fiveHour
157 const weekly = snapshot?.weekly
158 const context = snapshot?.context
159 const pct = (n: number | undefined) => (n === undefined ? DASH : `${Math.round(n)}%`)
160
161 // A limit's meter shows while its percent or its reset is on: `5h 42%, 1h12m`, `5h 42%`, `5h 1h12m`.
162 const limit = (window: LimitWindow | undefined, showPct: boolean, showReset: boolean, reset: (at: string) => string) => {
163 const parts = showPct ? [pct(window?.percentUsed)] : []
164 if (showReset && window?.resetsAt) parts.push(reset(window.resetsAt))
165 return parts.length > 0 ? parts.join(', ') : DASH
166 }
167 if (on.fiveHour || on.fiveHourReset) {
168 const text = limit(fiveHour, on.fiveHour, on.fiveHourReset, at => duration(Date.parse(at) - now))
169 segs.push({ key: 'fiveHour', label: '5h', text: `5h ${text}`, tone: toneFor(fiveHour?.percentUsed, on) })
170 }
171 if (on.weekly || on.weeklyReset) {
172 const text = limit(weekly, on.weekly, on.weeklyReset, at => weeklyReset(at, now))
173 segs.push({ key: 'weekly', label: 'wk', text: `wk ${text}`, tone: toneFor(weekly?.percentUsed, on) })
174 }
175 if (on.contextPercent || on.contextTokens) {
176 const parts: string[] = []
177 if (on.contextPercent) {
178 parts.push(pct(context?.percent))
179 }
180 if (on.contextTokens && context?.tokens !== undefined) {
181 parts.push(`${tokens(context.tokens)}/${tokens(context.window)}`)
182 } else if (on.contextTokens && !on.contextPercent) {
183 parts.push(DASH)
184 }
185 segs.push({ key: 'context', label: 'ctx', text: `ctx ${parts.join(' ')}`, tone: toneFor(context?.percent, on) })
186 }
187 if (on.threadTokens || on.lastTurnTokens) {
188 segs.push(tokenSegment(snapshot, on))
189 }
190 const cacheSeg = cacheSegment(view)
191 if (cacheSeg !== null) {
192 segs.push(cacheSeg)
193 }
194 const threadSeg = threadSegment(view)
195 if (threadSeg !== null) {
196 segs.push(threadSeg)
197 }
198 if (on.monthlyCost) {
199 segs.push({ key: 'month', label: 'month', text: monthText(view.month) })
200 }
201 if (on.burnRate && view.projection !== null) {
202 segs.push({ key: 'burn', label: 'limit', text: `limit ~${clockTime(view.projection)}` })
203 }
204
205 return segs
206}
207
208/** `Svg` only where the surface draws it (the desktop): the mascot and its option show there alone. */
209type El = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'> & { Svg?: Elements['desktop']['Svg'] }
210
211/** The 🛠 menu's view options, each turned on or off on its own. */
212const VIEWS = [
213 { view: 'task', label: VIEW_NAMES.task },
214 { view: 'clean', label: VIEW_NAMES.clean },
215] as const
216
217/** Quiet buttons, matching the other bands: dim text and no outline at rest, full strength under the pointer. */
218const QUIET = { plain: true, dimColor: true } as const
219
220/**
221 * The two menu buttons' labels. The terminal gets words: it lays a bare 🛠 out one column wide but paints it two,
222 * which pushes ⁝ under the icon's right half and both presses onto the icon.
223 */
224const MENU_LABELS = {
225 desktop: { tools: '🛠', settings: '⁝' },
226 terminal: { tools: 'tools', settings: 'opts' },
227} as const
228
229/** Room between neighboring buttons: the desktop's native chrome parts them, the terminal's plain words need spaces. */
230const BUTTON_GAP = { desktop: 0, terminal: 2 } as const
231
232export function renderBar(
233 el: El,
234 view: View,
235 flags: {
236 isWorking: boolean
237 isHandingOff: boolean
238 isToolsOpen: boolean
239 /** Whether Claude's last reply asks something it never put to the person as pop-up choices. */
240 isAskable: boolean
241 /** Whether /keepwarm is keeping the cache warm: the bar shows its marker. */
242 isKeepingWarm: boolean
243 isTaskView: boolean
244 isCleanView: boolean
245 /** Whether the session works in a GitHub repository: the 🛠 menu offers its Project pane. */
246 hasRepo: boolean
247 /** The mascot's drawing as it stands now. */
248 mascot: { source: string; alt: string } | null
249 },
250 on: {
251 toggleTools(): void
252 askme(): void
253 handoff(): void
254 openNotes(): void
255 toggleView(view: 'task' | 'clean'): void
256 openDock(): void
257 openBranches(): void
258 openProject(): void
259 /** ⁝: opens or closes the AI Tool Settings pane. */
260 toggleSettings(): void
261 },
262 /** The checklist, drawn between the bar and the project row; null while Task View is off or there is none. */
263 checklist: ReturnType<typeof renderChecklist> = null,
264 /** The project row, drawn right under the bar; null outside a GitHub repository. */
265 projectRow: RenderChildren | null = null,
266) {
267 const { Box, Text, Button, Svg } = el
268 const isHandoffIdle = !flags.isWorking && !flags.isHandingOff
269 const segs = segments(view)
270
271 const art = Svg && view.settings.mascot ? flags.mascot : null
272 const labels = Svg ? MENU_LABELS.desktop : MENU_LABELS.terminal
273 const gap = Svg ? BUTTON_GAP.desktop : BUTTON_GAP.terminal
274
275 // Segments wrap onto further rows when the band is narrow; the mascot and the buttons keep their places at the ends.
276 // Beside the mascot the row centers on him; without him the buttons stay on the first row of text.
277 const bar = (
278 <Box flexDirection="row" justifyContent="space-between" {...(art ? { alignItems: 'center' as const } : {})}>
279 {Svg && art && (
280 <Box key="mascot-art" flexShrink={0} marginRight={1}>
281 <Svg source={art.source} alt={art.alt} width={MASCOT_WIDTH} height={MASCOT_HEIGHT} />
282 </Box>
283 )}
284 <Box flexDirection="row" flexWrap="wrap" flexGrow={1} flexShrink={1}>
285 {view.settings.label && (
286 <Text key="label" dimColor>
287 {LABEL}
288 {segs.length > 0 ? SEPARATOR : ''}
289 </Text>
290 )}
291 {segs.map((s, i) => (
292 // Label and value in one row, so a narrow band wraps between metrics, never inside one.
293 <Box key={`segment-${s.key}`} flexDirection="row">
294 <Text dimColor>{`${s.label} `}</Text>
295 <Text color={s.tone ? TONE_COLORS[s.tone] : VALUE_COLOR}>
296 {s.text.slice(s.label.length + 1)}
297 {i < segs.length - 1 ? SEPARATOR : ''}
298 </Text>
299 </Box>
300 ))}
301 </Box>
302 <Box flexDirection="row" gap={gap} flexShrink={0}>
303 {flags.isAskable && <Button key="askme" label="?" {...QUIET} onPress={() => on.askme()} />}
304 {flags.isKeepingWarm && (
305 // On the desktop, room from ? only: the tools button's own chrome parts it from the mark.
306 <Box key="keepwarm" paddingLeft={Svg ? 1 : 0}>
307 <Text color={VALUE_COLOR}>{KEEPWARM_MARK}</Text>
308 </Box>
309 )}
310 <Button key="tools" label={labels.tools} {...QUIET} onPress={() => on.toggleTools()} />
311 <Button key="settings" label={labels.settings} {...QUIET} onPress={() => on.toggleSettings()} />
312 </Box>
313 </Box>
314 )
315
316 let menu = null
317 if (flags.isToolsOpen) {
318 menu = (
319 <Box flexDirection="row" justifyContent="flex-end" gap={gap}>
320 <Button
321 key="handoff"
322 label={flags.isHandingOff ? 'Handoff…' : 'Handoff'}
323 {...QUIET}
324 onPress={() => isHandoffIdle && on.handoff()}
325 />
326 <Button key="notes" label="Notes" {...QUIET} onPress={() => on.openNotes()} />
327 <Button key="tasks" label="Tasks" {...QUIET} onPress={() => on.openDock()} />
328 <Button key="branches" label="Branches" {...QUIET} onPress={() => on.openBranches()} />
329 {flags.hasRepo && <Button key="project" label="Project" {...QUIET} onPress={() => on.openProject()} />}
330 {VIEWS.map(v => {
331 const isOn = v.view === 'task' ? flags.isTaskView : flags.isCleanView
332
333 return <Button key={v.view} label={`${isOn ? '●' : '○'} ${v.label}`} {...QUIET} onPress={() => on.toggleView(v.view)} />
334 })}
335 </Box>
336 )
337 }
338 // The checklist sits between the bar and the project row. On the desktop the rows get a gap between them; the
339 // terminal keeps the bar and the project row on consecutive lines, with a blank line around the checklist.
340 const bars =
341 checklist === null && projectRow === null ? bar : (
342 <Box flexDirection="column" gap={Svg || checklist !== null ? 1 : 0}>
343 {bar}
344 {checklist}
345 {projectRow}
346 </Box>
347 )
348 if (!menu) {
349 return bars
350 }
351
352 // The open 🛠 menu sits above the bar, so the bar itself never shifts.
353 return (
354 <Box flexDirection="column" gap={1}>
355 {menu}
356 {bars}
357 </Box>
358 )
359}
360src/board.tsx 388 lines1/**
2 * The Project pane: the GitHub Project board's items, each with its id, title, Status and pull request, read with one
3 * GraphQL query at most every BOARD_REFRESH_MS while the pane is open or the project row shows the open count. A header links to the board and filters the
4 * items to the open ones, the done ones or all; where SVG is drawn, Clawd stacks task blocks at the top left.
5 */
6
7import type { Elements, RenderChildren } from 'claude-code'
8
9import { BLUE, DOING_TEXT, FAINT_TEXT, GREEN, RED, YELLOW } from './palette'
10import { TOWER_HEIGHT, TOWER_WIDTH, towerAlt, towerSvg } from './tower'
11import { age } from './branches'
12import type { BoardData, BoardFilter, BoardItem, BoardState, TowerMode } from '../types'
13
14export const PROJECT_PANE = 'project'
15
16/** How often the open pane reads the board, and how long a read stays fresh across closing and reopening it. */
17export const BOARD_REFRESH_MS = 5 * 60_000
18
19/** How many items a read keeps: GitHub's most for one page. */
20export const BOARD_PAGE = 100
21
22/** The Status names that mean done, as the board filter and isDone both read them. */
23export const DONE_STATUSES = ['Done', 'Closed', 'Complete', 'Completed', 'Finished', 'Shipped', 'Merged'] as const
24
25/** How far back a read looks for done items: the most recently changed are the ones worth listing. */
26export const DONE_WINDOW_DAYS = 30
27
28const ITEM_FIELDS =
29 'type updatedAt status:fieldValueByName(name:"Status"){... on ProjectV2ItemFieldSingleSelectValue{name}} ' +
30 'content{... on DraftIssue{title} ... on Issue{number title url state repository{nameWithOwner} ' +
31 'closedByPullRequestsReferences(first:3,includeClosedPrs:true){nodes{number url state}}} ' +
32 '... on PullRequest{number title url state repository{nameWithOwner}}}'
33
34/**
35 * The board in one query: its item count, its Status columns in order, up to BOARD_PAGE items not in a done column,
36 * and up to BOARD_PAGE done ones changed in the last DONE_WINDOW_DAYS days. GitHub sorts items only by their position
37 * on the board, so the query picks them by filter and the read sorts them (sortItems). `repositoryOwner` takes a
38 * user's login or an organization's, so either kind of board is one query.
39 */
40export const BOARD_QUERY =
41 'query($login:String!,$number:Int!){repositoryOwner(login:$login){... on ProjectV2Owner{projectV2(number:$number){' +
42 'title url all:items(first:0){totalCount} ' +
43 'field(name:"Status"){... on ProjectV2SingleSelectField{options{name}}} ' +
44 `open:items(first:${BOARD_PAGE},query:"-status:${DONE_STATUSES.join(',')}"){totalCount nodes{${ITEM_FIELDS}}} ` +
45 `done:items(first:${BOARD_PAGE},query:"status:${DONE_STATUSES.join(',')} updated:>@today-${DONE_WINDOW_DAYS}d"){nodes{${ITEM_FIELDS}}}` +
46 '}}}}'
47
48/** A Project board's owner and number from its URL: `https://github.com/users/<login>/projects/<n>`, or `/orgs/`. */
49export function parseBoardUrl(url: string): { login: string; number: number } | null {
50 const match = /^https?:\/\/github\.com\/(?:users|orgs)\/([^/\s]+)\/projects\/(\d+)(?:[/?#]|$)/.exec(url.trim())
51
52 return match?.[1] && match[2] ? { login: match[1], number: Number(match[2]) } : null
53}
54
55type Pr = { number: number; url: string; state: string }
56
57/** The pull request an issue links to, of those that close it: an open one first, then a merged one, newest first. */
58export function pickPr(prs: readonly Pr[]): { number: number; url: string } | null {
59 const rank = (pr: Pr) => (pr.state === 'OPEN' ? 0 : pr.state === 'MERGED' ? 1 : 2)
60 const best = [...prs].sort((a, b) => rank(a) - rank(b) || b.number - a.number)[0]
61
62 return best ? { number: best.number, url: best.url } : null
63}
64
65const STATES: ReadonlySet<string> = new Set(['OPEN', 'CLOSED', 'MERGED'])
66
67/** One item from the query's answer; null for one it cannot show (content the person may not read). */
68function toItem(node: unknown): BoardItem | null {
69 const n = (node ?? {}) as { type?: unknown; updatedAt?: unknown; status?: { name?: unknown } | null; content?: Record<string, unknown> | null }
70 const content = n.content ?? {}
71 const title = typeof content.title === 'string' ? content.title : null
72 if (title === null) {
73 return null
74 }
75 const status = typeof n.status?.name === 'string' ? n.status.name : null
76 const updated = typeof n.updatedAt === 'string' ? Date.parse(n.updatedAt) : Number.NaN
77 const updatedAt = Number.isFinite(updated) ? updated : null
78 if (n.type === 'DRAFT_ISSUE') {
79 return { kind: 'draft', number: null, repo: null, title, url: null, state: null, status, pr: null, updatedAt }
80 }
81 const number = typeof content.number === 'number' ? content.number : null
82 const url = typeof content.url === 'string' ? content.url : null
83 if (number === null || url === null) {
84 return null
85 }
86 const repo = (content.repository as { nameWithOwner?: unknown } | undefined)?.nameWithOwner
87 const state = typeof content.state === 'string' && STATES.has(content.state) ? (content.state as BoardItem['state']) : null
88 const isPr = n.type === 'PULL_REQUEST'
89 const closing = (content.closedByPullRequestsReferences as { nodes?: unknown } | undefined)?.nodes
90 const prs = Array.isArray(closing)
91 ? closing.filter((p): p is Pr => typeof p?.number === 'number' && typeof p?.url === 'string' && typeof p?.state === 'string')
92 : []
93
94 return {
95 kind: isPr ? 'pr' : 'issue',
96 number,
97 repo: typeof repo === 'string' ? repo : null,
98 title,
99 url,
100 state,
101 status,
102 pr: isPr ? { number, url } : pickPr(prs),
103 updatedAt,
104 }
105}
106
107/**
108 * The items as the pane lists them: open ones before done ones, open ones by their Status column against the board's
109 * order (`columns`), furthest along first, so Blocked, In Review, In Progress, then Todo; done ones in the board's order.
110 * One with no Status, or a column the board no longer has, goes after them; then the most recently changed first.
111 */
112export function sortItems(items: readonly BoardItem[], columns: readonly string[]): BoardItem[] {
113 const column = (item: BoardItem) => {
114 const at = item.status === null ? -1 : columns.indexOf(item.status)
115 if (at < 0) return columns.length
116 return isDone(item) ? at : columns.length - 1 - at
117 }
118
119 return [...items].sort(
120 (a, b) => Number(isDone(a)) - Number(isDone(b)) || column(a) - column(b) || (b.updatedAt ?? 0) - (a.updatedAt ?? 0),
121 )
122}
123
124/** The query's answer as the pane shows it, or why it holds no board. */
125export function parseBoardItems(json: string): { data: BoardData } | { error: string } {
126 let raw: unknown
127 try {
128 raw = JSON.parse(json)
129 } catch {
130 return { error: "GitHub's answer wasn't JSON" }
131 }
132 type Items = { totalCount?: unknown; nodes?: unknown } | null | undefined
133 const answer = raw as { data?: { repositoryOwner?: { projectV2?: unknown } | null }; errors?: { message?: unknown }[] }
134 const project = answer.data?.repositoryOwner?.projectV2 as
135 | { title?: unknown; url?: unknown; all?: Items; open?: Items; done?: Items; field?: { options?: unknown } | null }
136 | null
137 | undefined
138 if (!project) {
139 const message = answer.errors?.find(e => typeof e.message === 'string')?.message
140 return { error: typeof message === 'string' ? message : 'GitHub found no such Project board' }
141 }
142 const read = (list: Items) => (Array.isArray(list?.nodes) ? list.nodes : []).map(toItem).filter((item): item is BoardItem => item !== null)
143 const open = read(project.open)
144 const done = read(project.done)
145 const options = Array.isArray(project.field?.options) ? project.field.options : []
146 const columns = options.map(o => (o as { name?: unknown })?.name).filter((name): name is string => typeof name === 'string')
147 const items = sortItems([...open, ...done], columns).slice(0, BOARD_PAGE)
148 // The open filter also brings items with no Status whose issue is closed: they count as done.
149 const openCount = typeof project.open?.totalCount === 'number' ? project.open.totalCount - open.filter(isDone).length : undefined
150
151 return {
152 data: {
153 title: typeof project.title === 'string' ? project.title : 'Project',
154 url: typeof project.url === 'string' ? project.url : '',
155 total: typeof project.all?.totalCount === 'number' ? project.all.totalCount : items.length,
156 open: openCount,
157 items,
158 },
159 }
160}
161
162const DONE_STATUS = new RegExp(`^(${DONE_STATUSES.join('|')})$`, 'i')
163
164/** Whether an item counts as done: its Status says so, or with no Status, its issue or pull request is closed. */
165export function isDone(item: BoardItem): boolean {
166 if (item.status !== null) {
167 return DONE_STATUS.test(item.status.trim())
168 }
169
170 return item.state === 'CLOSED' || item.state === 'MERGED'
171}
172
173export function filterItems(items: readonly BoardItem[], filter: BoardFilter): BoardItem[] {
174 return filter === 'all' ? [...items] : items.filter(item => isDone(item) === (filter === 'done'))
175}
176
177export function filterCounts(items: readonly BoardItem[]): Record<BoardFilter, number> {
178 const done = items.filter(isDone).length
179
180 return { open: items.length - done, done, all: items.length }
181}
182
183/** An item's id: `#12` in the session's repository, `name#12` in another, `draft` for a draft. */
184export function itemId(item: BoardItem, repo: string | null): string {
185 if (item.number === null) {
186 return 'draft'
187 }
188 if (item.repo === null || repo === null || item.repo.toLowerCase() === repo.toLowerCase()) {
189 return `#${item.number}`
190 }
191
192 return `${item.repo.split('/').pop()}#${item.number}`
193}
194
195/** A Status's dot and color, read from its name: filled for one under way or finished, hollow for one not started. */
196export function statusMark(status: string | null): { dot: string; color: string } {
197 const s = status?.trim().toLowerCase() ?? ''
198 if (s === '') return { dot: '○', color: FAINT_TEXT }
199 if (DONE_STATUS.test(s)) return { dot: '●', color: GREEN }
200 if (/block/.test(s)) return { dot: '●', color: RED }
201 if (/review/.test(s)) return { dot: '●', color: BLUE }
202 if (/progress|doing|active|working|started/.test(s)) return { dot: '●', color: YELLOW }
203 if (/todo|to do|backlog|ready|new|triage|planned|later/.test(s)) return { dot: '○', color: FAINT_TEXT }
204
205 return { dot: '●', color: DOING_TEXT }
206}
207
208/** The status column's text: its dot and name, a long name cut to fit STATUS_MAX cells. */
209export function statusText(status: string | null): string {
210 const chars = Array.from(`${statusMark(status).dot} ${status?.trim() || '-'}`)
211
212 return chars.length > STATUS_MAX ? `${chars.slice(0, STATUS_MAX - 1).join('')}…` : chars.join('')
213}
214
215/** Clawd's mood: puzzled with no board to show, asleep with nothing open, else stacking. */
216export function towerMode(board: BoardState): TowerMode {
217 if (board.status === 'none' || board.status === 'error') return 'puzzled'
218 if (board.status === 'loading') return 'building'
219
220 return filterCounts(board.data.items).open === 0 ? 'resting' : 'building'
221}
222
223/** The header's faint note: how many items (and how many a read fetched, when fewer), and how long since the read. */
224export function metaText(data: BoardData, at: number, now: number): string {
225 const count =
226 data.total > data.items.length ? `${data.items.length} of ${data.total} items` : `${data.total} ${data.total === 1 ? 'item' : 'items'}`
227 const ago = age(now - at)
228
229 return `${count} · updated ${ago === 'now' ? 'just now' : `${ago} ago`}`
230}
231
232const FILTERS: readonly { filter: BoardFilter; label: string; empty: string }[] = [
233 { filter: 'open', label: 'Open', empty: 'Nothing open on the board.' },
234 { filter: 'done', label: 'Done', empty: 'Nothing done yet.' },
235 { filter: 'all', label: 'All', empty: 'The board is empty.' },
236]
237
238/**
239 * The id column's width in cells at least: room for a five-digit `#12345`, and the slack the desktop's id button needs
240 * around its label. A longer id (another repository's `name#12`) widens it.
241 */
242export const ID_WIDTH = 8
243/** The PR column's width in cells: room for `PR #12345`. */
244export const PR_WIDTH = 10
245/** The most cells the status column's text takes; a longer Status is cut. */
246export const STATUS_MAX = 16
247
248/** `Svg` only where the surface draws it (the desktop): Clawd shows there alone. */
249type El = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'> & { Svg?: Elements['desktop']['Svg'] }
250
251/** Quiet buttons, as the bar's own: dim text and no outline at rest, full strength under the pointer. */
252const QUIET = { plain: true, dimColor: true } as const
253
254export type BoardView = {
255 board: BoardState
256 filter: BoardFilter
257 isReading: boolean
258 /** The session's repository as `owner/name`: its own items' ids leave the name off. */
259 repo: string | null
260 now: number
261}
262
263type BoardActions = { open(url: string): void; setFilter(filter: BoardFilter): void; refresh(): void }
264
265/**
266 * The pane: the board's name, Open in GitHub and a faint note on one line, the filters and Refresh on the next, Clawd
267 * to their left; then a line per item in fixed-width columns.
268 */
269export function renderBoard(el: El, view: BoardView, on: BoardActions) {
270 const { Box, Text, Button, Svg } = el
271 const { board } = view
272 const ready = board.status === 'ready' ? board : null
273
274 let header: RenderChildren
275 if (board.status === 'none') {
276 header = <Text key="none" color={FAINT_TEXT}>This repository has no GitHub Project board. Set project.board in .claude/aitools.json.</Text>
277 } else if (board.status === 'loading') {
278 header = <Text key="loading" color={FAINT_TEXT}>Reading the board…</Text>
279 } else if (board.status === 'error') {
280 header = (
281 <Box key="error" flexDirection="column">
282 <Text color={FAINT_TEXT}>{`Couldn't read the board: ${board.text}`}</Text>
283 <Box marginTop={1}>{refreshButton(el, view, on)}</Box>
284 </Box>
285 )
286 } else {
287 const counts = filterCounts(board.data.items)
288 const url = board.data.url
289 header = (
290 <Box key="header" flexDirection="column">
291 <Box flexDirection="row" columnGap={2} flexWrap="wrap">
292 <Text key="title" color={DOING_TEXT}>{board.data.title}</Text>
293 {url && <Button key="open" label="Open in GitHub" {...QUIET} onPress={() => on.open(url)} />}
294 <Text key="meta" color={FAINT_TEXT}>{metaText(board.data, board.at, view.now)}</Text>
295 </Box>
296 <Box flexDirection="row" columnGap={2} flexWrap="wrap" marginTop={1}>
297 {FILTERS.map(f => (
298 <Button
299 key={`filter-${f.filter}`}
300 label={`${view.filter === f.filter ? '●' : '○'} ${f.label} ${counts[f.filter]}`}
301 {...QUIET}
302 onPress={() => on.setFilter(f.filter)}
303 />
304 ))}
305 {refreshButton(el, view, on)}
306 </Box>
307 {board.error && <Text key="stale" color={FAINT_TEXT}>{`Couldn't read the board again: ${board.error}`}</Text>}
308 </Box>
309 )
310 }
311
312 const items = ready ? filterItems(ready.data.items, view.filter) : []
313 const mode = towerMode(board)
314
315 return (
316 <Box flexDirection="column">
317 <Box flexDirection="row">
318 {Svg && (
319 <Box key="tower" flexShrink={0} marginRight={2}>
320 <Svg source={towerSvg(mode)} alt={towerAlt(mode)} width={TOWER_WIDTH} height={TOWER_HEIGHT} />
321 </Box>
322 )}
323 <Box flexDirection="column" flexShrink={1}>
324 {header}
325 </Box>
326 </Box>
327 {ready && (
328 <Box flexDirection="column" marginTop={1}>
329 {items.length === 0 ? (
330 <Text key="empty" color={FAINT_TEXT}>{FILTERS.find(f => f.filter === view.filter)?.empty}</Text>
331 ) : (
332 renderRows(el, items, view, on)
333 )}
334 </Box>
335 )}
336 </Box>
337 )
338}
339
340/** Refresh, or what it is doing while a read runs. */
341function refreshButton(el: El, view: BoardView, on: BoardActions) {
342 const { Text, Button } = el
343
344 return view.isReading ? (
345 <Text key="refresh" color={FAINT_TEXT}>Refreshing…</Text>
346 ) : (
347 <Button key="refresh" label="Refresh" {...QUIET} onPress={() => on.refresh()} />
348 )
349}
350
351/**
352 * One line per item: its id (opening it) and title on the left, its status and pull request on the right. The id and
353 * status columns are as wide as their widest entry, so every line lines up.
354 */
355function renderRows(el: El, items: readonly BoardItem[], view: BoardView, on: BoardActions) {
356 const { Box, Text, Button } = el
357 const ids = items.map(item => itemId(item, view.repo))
358 const idWidth = Math.max(ID_WIDTH, ...ids.map(id => Array.from(id).length + 2))
359 const statusWidth = Math.max(...items.map(item => Array.from(statusText(item.status)).length)) + 1
360 const cell = (key: string, width: number, child: RenderChildren) => (
361 <Box key={key} width={width} flexShrink={0} flexDirection="row">
362 {child}
363 </Box>
364 )
365
366 return items.map((item, i) => {
367 const id = ids[i] ?? ''
368 const { url, pr } = item
369
370 return (
371 <Box key={`item-${url ?? `draft-${i}`}`} flexDirection="row" justifyContent="space-between">
372 <Box flexDirection="row" flexShrink={1} minWidth={0} overflow="hidden">
373 {cell('id', idWidth, url === null ? <Text color={FAINT_TEXT}>{id}</Text> : <Button key={`id-${i}`} label={id} {...QUIET} onPress={() => on.open(url)} />)}
374 <Box flexShrink={1} minWidth={0} overflow="hidden">
375 <Text color={isDone(item) ? FAINT_TEXT : DOING_TEXT} wrap="truncate-end">
376 {item.title}
377 </Text>
378 </Box>
379 </Box>
380 <Box flexDirection="row" flexShrink={0} marginLeft={2}>
381 {cell('status', statusWidth, <Text color={statusMark(item.status).color}>{statusText(item.status)}</Text>)}
382 {cell('pr', PR_WIDTH, pr === null ? null : <Button key={`pr-${i}`} label={`PR #${pr.number}`} {...QUIET} onPress={() => on.open(pr.url)} />)}
383 </Box>
384 </Box>
385 )
386 })
387}
388src/pricing.ts 100 lines1// API list prices in $ per million tokens, as of 2026-09-25.
2// Cache writes are 1.25x input for the 5-minute TTL and 2x input for the 1-hour TTL.
3
4export type Rates = { input: number; output: number; cacheWrite5m: number; cacheWrite1h: number; cacheRead: number }
5
6export type TokenUsage = {
7 input_tokens?: number
8 output_tokens?: number
9 cache_read_input_tokens?: number
10 cache_creation_input_tokens?: number
11 cache_creation?: { ephemeral_5m_input_tokens?: number; ephemeral_1h_input_tokens?: number }
12}
13
14function rates(input: number, output: number, cacheRead: number): Rates {
15 return { input, output, cacheWrite5m: input * 1.25, cacheWrite1h: input * 2, cacheRead }
16}
17
18const FABLE_5_1 = rates(10, 50, 0.25)
19const FABLE_5 = rates(10, 50, 1)
20const OPUS_5_5 = rates(4, 20, 0.2)
21const OPUS_5 = rates(5, 25, 0.5)
22const OPUS_4_1 = rates(15, 75, 1.5)
23const SONNET_5 = rates(2, 10, 0.2)
24const SONNET_4 = rates(3, 15, 0.3)
25const HAIKU_4_5 = rates(1, 5, 0.1)
26
27const TABLE: Record<string, Rates> = {
28 'claude-fable-5-1': FABLE_5_1,
29 'claude-mythos-5-1': FABLE_5_1,
30 'claude-fable-5': FABLE_5,
31 'claude-mythos-5': FABLE_5,
32 'claude-opus-5-5': OPUS_5_5,
33 'claude-opus-5': OPUS_5,
34 'claude-opus-4-8': OPUS_5,
35 'claude-opus-4-7': OPUS_5,
36 'claude-opus-4-6': OPUS_5,
37 'claude-opus-4-5': OPUS_5,
38 'claude-opus-4-1': OPUS_4_1,
39 'claude-opus-4': OPUS_4_1,
40 'claude-sonnet-5-5': SONNET_5,
41 'claude-sonnet-5': SONNET_5,
42 'claude-sonnet-4-6': SONNET_4,
43 'claude-sonnet-4-5': SONNET_4,
44 'claude-sonnet-4': SONNET_4,
45 'claude-haiku-4-5': HAIKU_4_5,
46}
47
48const FAMILIES: [RegExp, Rates][] = [
49 [/fable|mythos/, FABLE_5_1],
50 [/opus/, OPUS_5_5],
51 [/sonnet/, SONNET_5],
52 [/haiku/, HAIKU_4_5],
53]
54
55function ratesFor(model: string): { rates: Rates | null; isEstimate: boolean } {
56 const exact = TABLE[model.replace(/-\d{8}$/, '')]
57 if (exact) {
58 return { rates: exact, isEstimate: false }
59 }
60 const family = FAMILIES.find(([pattern]) => pattern.test(model))
61
62 return { rates: family?.[1] ?? null, isEstimate: true }
63}
64
65/**
66 * What a cold cache costs the next request: `tokens` of context written afresh at the 1-hour write rate, as cache-tax
67 * prices it (a warm one reads them at the cache-read rate). Null for a model with no known rates.
68 */
69export function coldCacheUsd(model: string, tokens: number): { usd: number; isEstimate: boolean } | null {
70 const { rates, isEstimate } = ratesFor(model)
71
72 return rates === null ? null : { usd: (tokens * rates.cacheWrite1h) / 1_000_000, isEstimate }
73}
74
75export function price(model: string, usage: TokenUsage): { usd: number; isEstimate: boolean } {
76 const input = usage.input_tokens ?? 0
77 const output = usage.output_tokens ?? 0
78 const cacheRead = usage.cache_read_input_tokens ?? 0
79 const cacheWrite = usage.cache_creation_input_tokens ?? 0
80 if (input + output + cacheRead + cacheWrite === 0) {
81 return { usd: 0, isEstimate: false }
82 }
83
84 const { rates, isEstimate } = ratesFor(model)
85 if (!rates) {
86 return { usd: 0, isEstimate: true }
87 }
88 const split = usage.cache_creation
89 const write1h = split ? (split.ephemeral_1h_input_tokens ?? 0) : 0
90 const write5m = split ? (split.ephemeral_5m_input_tokens ?? 0) : cacheWrite
91 const perMillion =
92 input * rates.input +
93 output * rates.output +
94 cacheRead * rates.cacheRead +
95 write5m * rates.cacheWrite5m +
96 write1h * rates.cacheWrite1h
97
98 return { usd: perMillion / 1_000_000, isEstimate }
99}
100src/checklist.tsx 265 lines1import type { Elements } from 'claude-code'
2
3import type { Checklist, ChecklistItem } from '../types'
4import { BLUE, DOING_TEXT, GREEN } from './palette'
5
6/** The checklist tool's short name; the model calls it as `mcp__aitools__checklist`. */
7export const CHECKLIST_TOOL = 'checklist'
8export const CHECKLIST_TOOL_ID = 'mcp__aitools__checklist'
9
10const STATUSES = ['todo', 'doing', 'done'] as const
11
12export const CHECKLIST_SPEC = {
13 name: CHECKLIST_TOOL,
14 description:
15 'Shows the person your plan and progress as a checklist above their prompt. Use it only when a system note says ' +
16 'progress reporting is on. Send the whole list every call: a short title for the task and each step in plain ' +
17 'language (an outcome, never a tool name, file path or code).',
18 inputSchema: {
19 type: 'object',
20 properties: {
21 title: { type: 'string', description: 'What the task is, in a few words' },
22 items: {
23 type: 'array',
24 items: {
25 type: 'object',
26 properties: {
27 text: { type: 'string', description: 'One step, in plain language' },
28 status: { type: 'string', enum: [...STATUSES] },
29 percent: {
30 type: 'number',
31 minimum: 0,
32 maximum: 100,
33 description: 'For the step in progress: your estimate of how far along it is',
34 },
35 },
36 required: ['text', 'status'],
37 },
38 },
39 },
40 required: ['title', 'items'],
41 },
42}
43
44const TASK_NOTE =
45 `Progress reporting is on: the person follows your work through a checklist drawn from ${CHECKLIST_TOOL_ID}. ` +
46 'If the request needs more than a quick answer, call it before you start with a short title and your plan as ' +
47 '3 to 8 plain-language steps (outcomes, not tool names or code). Call it again with the whole list each time a ' +
48 'step starts (status "doing") or finishes ("done"), with one step doing at a time; add, drop or reword steps when ' +
49 'the plan changes. Give the step in progress a percent, your estimate of how far along it is, and send the list ' +
50 'again as it moves (roughly every quarter of the step) so its progress bar keeps up. Mark every step done before ' +
51 'your final reply. Skip the checklist for a quick question that needs no work.'
52
53/** Clean View's note: what the person sees besides the final reply depends on whether Task View is on too. */
54function cleanNote(isTaskView: boolean): string {
55 const sees = isTaskView ? 'only the checklist and your final reply' : 'only your final reply'
56
57 return (
58 `Clean View is on: the person sees ${sees}, not your tool calls or anything you write before it. Do not ` +
59 'narrate between steps; put what they need in a brief final reply.'
60 )
61}
62
63/** The views' names as the menu, the commands and their replies spell them. */
64export const VIEW_NAMES = { task: 'Task View', clean: 'Clean View' } as const
65
66/**
67 * Whether a view is on after `/taskview` or `/cleanview` with `args`: no argument toggles it, `on` and `off` set it;
68 * null for anything else. Each view is on or off on its own.
69 */
70export function nextToggle(isOn: boolean, args: string): boolean | null {
71 const word = args.trim().toLowerCase()
72
73 return word === '' ? !isOn : word === 'on' ? true : word === 'off' ? false : null
74}
75
76/** The hidden note a prompt carries while a view is on; null with neither. */
77export function viewNote(views: { isTaskView: boolean; isCleanView: boolean }): string | null {
78 const notes = [views.isTaskView ? TASK_NOTE : '', views.isCleanView ? cleanNote(views.isTaskView) : ''].filter(Boolean)
79
80 return notes.length === 0 ? null : notes.join(' ')
81}
82
83/** The checklist tool's input as a Checklist, or the reason it is not one. */
84export function parseChecklist(input: unknown): Checklist | string {
85 const given = input !== null && typeof input === 'object' ? (input as Record<string, unknown>) : {}
86 const title = typeof given.title === 'string' ? given.title.trim() : ''
87 if (!title) {
88 return 'title must be a non-empty string'
89 }
90 if (!Array.isArray(given.items)) {
91 return 'items must be an array of { text, status }'
92 }
93 const items: ChecklistItem[] = []
94 for (const raw of given.items as unknown[]) {
95 const item = raw !== null && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}
96 const text = typeof item.text === 'string' ? item.text.trim() : ''
97 const status = STATUSES.find(s => s === item.status)
98 if (!text || !status) {
99 return 'each item needs a non-empty text and a status of todo, doing or done'
100 }
101 const isPercent = typeof item.percent === 'number' && Number.isFinite(item.percent)
102 const percent = isPercent ? clampPercent(item.percent as number) : undefined
103 items.push(percent === undefined ? { text, status } : { text, status, percent })
104 }
105
106 return { title, items }
107}
108
109function clampPercent(n: number): number {
110 return Math.min(100, Math.max(0, Math.round(n)))
111}
112
113/** How far along a step's bar is: full once done, empty before it starts, the model's estimate in between. */
114export function stepPercent(item: ChecklistItem): number {
115 return item.status === 'done' ? 100 : item.status === 'todo' ? 0 : (item.percent ?? 0)
116}
117
118/** Cells in a step's progress bar. */
119export const BAR_CELLS = 6
120
121/** A step's progress bar: filled cells for the share done, light cells for the rest. */
122export function progressBar(percent: number, cells = BAR_CELLS): { filled: string; empty: string } {
123 const n = Math.round((clampPercent(percent) / 100) * cells)
124
125 return { filled: '▰'.repeat(n), empty: '▱'.repeat(cells - n) }
126}
127
128/** Whether Clean View shows an assistant text block: only one that is part of a finished turn's final reply. */
129export function isFinalReply(text: string, finals: readonly string[]): boolean {
130 const block = text.trim()
131
132 return block.length > 0 && finals.some(final => final.includes(block))
133}
134
135/** How many final replies Clean View keeps to match against. */
136export const FINALS_KEEP = 200
137
138/** Keeps the newest finals, so the list Clean View matches against stays small. */
139export function addFinal(finals: readonly string[], answer: string, keep = FINALS_KEEP): string[] {
140 const text = answer.trim()
141
142 return text && !finals.includes(text) ? [...finals, text].slice(-keep) : [...finals]
143}
144
145export type Row = { role: 'user' | 'assistant'; text: string; toolResults?: readonly unknown[] }
146
147/**
148 * Each request's final reply in a conversation: the last assistant text before the next prompt the person sent (a
149 * user row carrying text and no tool results), and the conversation's last assistant text. Read when the mod loads,
150 * so replies from turns that ended before it (a reload, a restart) still show in Clean View.
151 */
152export function finalReplies(rows: readonly Row[]): string[] {
153 const finals: string[] = []
154 let last = ''
155 for (const row of rows) {
156 if (row.role === 'assistant') {
157 last = row.text.trim() || last
158 } else if (row.text.trim() && !row.toolResults?.length) {
159 if (last) finals.push(last)
160 last = ''
161 }
162 }
163 if (last) finals.push(last)
164
165 return finals
166}
167
168type El = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
169
170const MARKS = { done: '✓', doing: '●', todo: '○' } as const
171/** Done marks green, the step in progress blue; steps not started stay dim. */
172const MARK_COLORS = { done: GREEN, doing: BLUE, todo: undefined } as const
173
174/** How far the steps sit in from the title row's fold mark, so they read as the title's. */
175export const STEP_INDENT = 2
176
177/** The collapse toggle's marks: ▸ folded to the title row, ▾ open. */
178export const FOLD_MARKS = { collapsed: '▸', expanded: '▾' } as const
179
180/**
181 * The checklist in the band: a title row (▸ or ▾ to fold it, the title, how many steps are done, × to close it), then
182 * the steps while work goes on, indented under the title; one line once every step is done. Folded, only the title
183 * row shows. `markGap` is the room between the fold mark and the title: the desktop's button chrome parts them already,
184 * the terminal's plain mark needs a space.
185 */
186export function renderChecklist(
187 el: El,
188 list: Checklist | null,
189 isWorking: boolean,
190 view: { isCollapsed: boolean; markGap: number },
191 on: { toggle(): void; close(): void },
192) {
193 const { isCollapsed } = view
194 const { Box, Text, Button } = el
195 if (list === null) {
196 return null
197 }
198 const done = list.items.filter(i => i.status === 'done').length
199 if (!isWorking && list.items.length > 0 && done === list.items.length) {
200 const steps = `${list.items.length} step${list.items.length === 1 ? '' : 's'}`
201 return (
202 <Box flexDirection="row" justifyContent="space-between">
203 <Box flexDirection="row" flexShrink={1}>
204 <Text color={MARK_COLORS.done}>{`${MARKS.done} `}</Text>
205 <Text dimColor wrap="truncate-end">{`Done: ${list.title} (${steps})`}</Text>
206 </Box>
207 <Button key="checklist-close" label="×" plain dimColor onPress={() => on.close()} />
208 </Box>
209 )
210 }
211 const titleRow = (
212 <Box key="checklist-title" flexDirection="row" justifyContent="space-between">
213 <Box flexDirection="row" flexShrink={1} minWidth={0} columnGap={view.markGap}>
214 <Button
215 key="checklist-fold"
216 label={isCollapsed ? FOLD_MARKS.collapsed : FOLD_MARKS.expanded}
217 plain
218 dimColor
219 onPress={() => on.toggle()}
220 />
221 <Box flexShrink={1} minWidth={0}>
222 <Text color={DOING_TEXT} wrap="truncate-end">{list.title}</Text>
223 </Box>
224 </Box>
225 <Box flexDirection="row" flexShrink={0} columnGap={1}>
226 <Text dimColor>{`${done} of ${list.items.length}`}</Text>
227 <Button key="checklist-close" label="×" plain dimColor onPress={() => on.close()} />
228 </Box>
229 </Box>
230 )
231 if (isCollapsed) {
232 return titleRow
233 }
234
235 return (
236 <Box flexDirection="column">
237 {titleRow}
238 {list.items.map((item, i) => {
239 const percent = stepPercent(item)
240 const bar = progressBar(percent)
241
242 return (
243 <Box key={`item-${i}`} flexDirection="row" justifyContent="space-between" paddingLeft={STEP_INDENT}>
244 <Box flexDirection="row" flexShrink={1}>
245 <Text color={MARK_COLORS[item.status]} dimColor={item.status === 'todo'}>{`${MARKS[item.status]} `}</Text>
246 <Text
247 color={item.status === 'doing' ? DOING_TEXT : undefined}
248 dimColor={item.status !== 'doing'}
249 wrap="truncate-end"
250 >
251 {item.text}
252 </Text>
253 </Box>
254 <Box flexDirection="row" flexShrink={0}>
255 <Text key="pct" dimColor>{`${String(percent).padStart(3)}% `}</Text>
256 <Text key="filled" color={MARK_COLORS[item.status]} dimColor={item.status !== 'doing'}>{bar.filled}</Text>
257 <Text key="empty" dimColor>{bar.empty}</Text>
258 </Box>
259 </Box>
260 )
261 })}
262 </Box>
263 )
264}
265src/palette.ts 76 lines1/**
2 * The Gruvbox palette (github.com/morhetz/gruvbox), the mods' source for text colors. Pick from here rather than a
3 * named terminal color or a hex of your own: the panes and bars draw on a dark background, so text takes the dark
4 * mode's foreground tones and BRIGHT accents. NEUTRAL and FADED are kept for the odd mark that needs less glare.
5 *
6 * Pictures (Clawd and his props) keep their own colors; this is for text.
7 */
8
9/** Dark mode's backgrounds, darkest first. */
10export const BG = {
11 bg0_h: '#1d2021',
12 bg0: '#282828',
13 bg0_s: '#32302f',
14 bg1: '#3c3836',
15 bg2: '#504945',
16 bg3: '#665c54',
17 bg4: '#7c6f64',
18} as const
19
20/** Dark mode's foregrounds, dimmest first; `gray` sits between the backgrounds and these. */
21export const FG = {
22 gray: '#928374',
23 fg4: '#a89984',
24 fg3: '#bdae93',
25 fg2: '#d5c4a1',
26 fg1: '#ebdbb2',
27 fg0: '#fbf1c7',
28} as const
29
30/** Dark mode's accents: the ones to use for text. */
31export const BRIGHT = {
32 red: '#fb4934',
33 green: '#b8bb26',
34 yellow: '#fabd2f',
35 blue: '#83a598',
36 purple: '#d3869b',
37 aqua: '#8ec07c',
38 orange: '#fe8019',
39} as const
40
41/** The accents' middle shades, shared by both modes. */
42export const NEUTRAL = {
43 red: '#cc241d',
44 green: '#98971a',
45 yellow: '#d79921',
46 blue: '#458588',
47 purple: '#b16286',
48 aqua: '#689d6a',
49 orange: '#d65d0e',
50} as const
51
52/** Light mode's accents, darkest: for text on a light background. */
53export const FADED = {
54 red: '#9d0006',
55 green: '#79740e',
56 yellow: '#b57614',
57 blue: '#076678',
58 purple: '#8f3f71',
59 aqua: '#427b58',
60 orange: '#af3a03',
61} as const
62
63// ---- the roles the mods use, named once ----
64
65export const RED = BRIGHT.red
66export const GREEN = BRIGHT.green
67export const YELLOW = BRIGHT.yellow
68export const BLUE = BRIGHT.blue
69/** Text that stands out without glaring: the step in progress, a pane's title. */
70export const DOING_TEXT = FG.fg3
71/**
72 * Faint text, as faint as the bar's dim labels: the desktop draws `dimColor` in a pane nearly as light as DOING_TEXT,
73 * so pane text that must read faint uses this instead.
74 */
75export const FAINT_TEXT = FG.gray
76src/burnrate.ts 46 lines1const MIN = 60_000
2const WINDOW = 60 * MIN
3const MIN_SPAN = 10 * MIN
4
5export type Sample = { t: number; pct: number }
6
7export type BurnState = { resetsAt: string | null; samples: Sample[] }
8
9export const EMPTY_BURN: BurnState = { resetsAt: null, samples: [] }
10
11export function addSample(s: BurnState, t: number, pct: number, resetsAt?: string): BurnState {
12 const window = resetsAt ?? null
13 const samples = window === s.resetsAt ? s.samples : []
14 if (samples.at(-1)?.pct === pct) {
15 return { resetsAt: window, samples }
16 }
17
18 return { resetsAt: window, samples: [...samples, { t, pct }].filter(x => x.t >= t - WINDOW) }
19}
20
21/** When the 5-hour window hits 100% at the recent pace, or null when that is not before the reset. */
22export function project(s: BurnState, now: number): number | null {
23 const samples = s.samples.filter(x => x.t >= now - WINDOW)
24 const first = samples[0]
25 const last = samples.at(-1)
26 if (!first || !last || samples.length < 2 || last.t - first.t < MIN_SPAN) {
27 return null
28 }
29
30 const meanT = samples.reduce((sum, x) => sum + x.t, 0) / samples.length
31 const meanP = samples.reduce((sum, x) => sum + x.pct, 0) / samples.length
32 const covariance = samples.reduce((sum, x) => sum + (x.t - meanT) * (x.pct - meanP), 0)
33 const variance = samples.reduce((sum, x) => sum + (x.t - meanT) ** 2, 0)
34 const slope = covariance / variance
35 if (!(slope > 0)) {
36 return null
37 }
38
39 const eta = Math.round(now + (100 - last.pct) / slope)
40 if (s.resetsAt && eta >= Date.parse(s.resetsAt)) {
41 return null
42 }
43
44 return eta
45}
46src/handoff.ts 24 lines1export const HANDOFF_PROMPT =
2 'Write a handoff brief so a fresh session with no memory of this conversation can continue the work. ' +
3 'Use markdown with exactly these sections: ## Goal, ## Current state, ## Decisions made, ## Open tasks, ' +
4 '## Key files, ## Next step. Be specific: name files, functions, commands and unresolved questions. ' +
5 'Output only the brief.'
6
7/**
8 * The chat row for a brief, which the chat renders as markdown. The title line comes first because the engine
9 * prefixes the row with the plugin's name, which would otherwise swallow the first heading.
10 */
11export function handoffOutput(brief: string): string {
12 return `Handoff brief\n\n${brief}`
13}
14
15/** The most a Markdown element draws; a longer brief is cut in the pane, while Copy still takes all of it. */
16export const MARKDOWN_MAX = 10_000
17
18const CUT_NOTE = '\n\n_(Cut to fit the pane; Copy takes the whole brief.)_'
19
20/** The brief as the handoff pane draws it, cut to fit the Markdown element's limit. */
21export function paneMarkdown(brief: string): string {
22 return brief.length <= MARKDOWN_MAX ? brief : brief.slice(0, MARKDOWN_MAX - CUT_NOTE.length) + CUT_NOTE
23}
24src/askme.ts 64 lines1/**
2 * /askme: asks Claude to put the questions from its last answer to the person as pop-up choices, each with a
3 * recommendation, instead of leaving them as prose to answer by typing.
4 */
5
6export const ASKME_PROMPT =
7 'Look at your previous response and find every question or decision you left for me. Ask them again with the ' +
8 'AskUserQuestion tool instead of in prose.\n' +
9 '- One question per decision. Make each question self-contained: the pop-up hides your earlier response, so ' +
10 'say in a sentence what it is about.\n' +
11 '- Give 2 to 4 concrete options. Put the one you recommend first and end its label with "(Recommended)". Each ' +
12 "option's description says what choosing it means or costs, in one line.\n" +
13 '- Group plain confirm-or-reverse items four to a multiSelect question: "Which of these should change? Select ' +
14 'none to confirm all."\n' +
15 '- Ask at most 4 questions per call, most important first, and keep calling until every question has an ' +
16 'answer. Follow up on any answer given as "Other" before treating it as decided.\n' +
17 'Then list the decisions in one short summary and carry on with the work they unblock. If your previous ' +
18 'response left no questions, say so in one line and stop.'
19
20/** The prompt for `/askme [extra]`: the extra words, when given, ride along as the person's own note. */
21export function askmeText(args: string): string {
22 const extra = args.trim()
23 return extra === '' ? ASKME_PROMPT : `${ASKME_PROMPT}\n\nAlso: ${extra}`
24}
25
26/** The tool that puts questions to the person as pop-up choices. */
27export const ASK_TOOL = 'AskUserQuestion'
28
29/**
30 * Whether a reply asks the person something: a question mark that ends a sentence, right after a word (closing marks
31 * between are fine), anywhere outside a code block. A bare one (the button's own **?**) or one inside a URL (`?q=`) or
32 * a code block asks nothing; a code span counts as a word, so "Use `x`?" still asks.
33 */
34export function asksQuestions(text: string): boolean {
35 const prose = text.replace(/```[\s\S]*?(```|$)/g, '').replace(/`[^`\n]*`/g, 'code')
36 return /[\p{L}\p{N}][*_"')\]]*\?[*_"')\]]*(\s|$)/u.test(prose)
37}
38
39/** A conversation row as `$.session.messages()` gives it, as much of it as is read here. */
40type AskRow = { role: 'user' | 'assistant'; text: string; toolUses?: readonly { tool: string }[]; toolResults?: readonly unknown[] }
41
42/**
43 * Whether the conversation, as the mod finds it on load, is worth an /askme: since the person's last prompt Claude
44 * never opened the question pop-up, and its last reply asks something.
45 */
46export function isAskableIn(rows: readonly AskRow[]): boolean {
47 let reply: string | null = null
48 for (const row of [...rows].reverse()) {
49 if (row.role === 'user' && row.text.trim() && !row.toolResults?.length) {
50 break
51 }
52 if (row.role === 'assistant') {
53 if (row.toolUses?.some(u => u.tool === ASK_TOOL)) {
54 return false
55 }
56 if (reply === null && row.text.trim()) {
57 reply = row.text
58 }
59 }
60 }
61
62 return reply !== null && asksQuestions(reply)
63}
64src/config.ts 199 lines1/**
2 * aitools.json: the mod's settings as files the person can read and edit.
3 *
4 * `~/.claude/aitools.json` (under CLAUDE_CONFIG_DIR when set) holds the person's own settings: what the bar shows
5 * (the project row among it),
6 * the views, the Background tasks and Branches panes' picks. Everything the mod's own controls change is written here, never to a
7 * project's file. `<repo>/.claude/aitools.json` holds only the `project` section (src/project.tsx reads it).
8 *
9 * Both files are JSON. A write reads the file again first and changes only the keys it sets, so hand edits and keys
10 * the mod does not know are kept.
11 */
12
13import { DEFAULT_HIDE_AFTER, isHideAfter, isShowing } from './agentdock'
14import { DEFAULT_SHOW, DEFAULT_WHERE, isBranchesShow, isBranchesWhere } from './branches'
15import { DEFAULT_SETTINGS, LEGACY_KEYS, normalizeSettings } from './settings'
16import type { AgentShowing, BranchesShow, BranchesWhere, HideAfter, Settings } from '../types'
17
18/** The file's name, in the Claude config folder and in a project's `.claude` folder. */
19export const CONFIG_FILE = 'aitools.json'
20
21/** A JSON object as read from a file: whatever it holds, typed loosely until normalized. */
22export type Json = Record<string, unknown>
23
24/** The person's settings, as the mod uses them. */
25export type Personal = {
26 settings: Settings
27 /** `/aitools off`: the whole bar hidden. */
28 isHidden: boolean
29 /** Task View and Clean View, each on or off on its own. */
30 taskView: boolean
31 cleanView: boolean
32 showing: AgentShowing
33 hideAfter: HideAfter
34 branchesWhere: BranchesWhere
35 branchesShow: BranchesShow
36}
37
38export const PERSONAL_DEFAULTS: Personal = {
39 settings: DEFAULT_SETTINGS,
40 isHidden: false,
41 taskView: false,
42 cleanView: false,
43 showing: 'all',
44 hideAfter: DEFAULT_HIDE_AFTER,
45 branchesWhere: DEFAULT_WHERE,
46 branchesShow: DEFAULT_SHOW,
47}
48
49/** A file's text as JSON: an object, or why it is not one. */
50export function parseConfig(text: string): { value: Json } | { error: string } {
51 let value: unknown
52 try {
53 value = JSON.parse(text)
54 } catch (err) {
55 return { error: err instanceof Error ? err.message : String(err) }
56 }
57 if (value === null || typeof value !== 'object' || Array.isArray(value)) {
58 return { error: 'the file must hold one JSON object' }
59 }
60
61 return { value: value as Json }
62}
63
64/** The text a config is written as: two-space JSON and a final newline. */
65export function configText(raw: Json): string {
66 return `${JSON.stringify(raw, null, 2)}\n`
67}
68
69/** The object under `key`, or an empty one when there is none. */
70export function objectAt(raw: Json, key: string): Json {
71 const value = raw[key]
72 return value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Json) : {}
73}
74
75/** The person's settings in a global file, each one the file leaves out or gets wrong at its default. */
76export function personalFrom(raw: Json): Personal {
77 const bar = objectAt(raw, 'bar')
78 const subagents = objectAt(raw, 'subagents')
79 const branches = objectAt(raw, 'branches')
80 // `view` was one word before the views were apart: `task`, or `clean`, which brought the checklist with it.
81 const legacy = raw.view
82 const view = objectAt(raw, 'view')
83
84 return {
85 settings: normalizeSettings(objectAt(bar, 'show')),
86 isHidden: bar.hidden === true,
87 taskView: legacy === 'task' || legacy === 'clean' || view.tasks === true,
88 cleanView: legacy === 'clean' || view.clean === true,
89 showing: isShowing(subagents.showing) ? subagents.showing : PERSONAL_DEFAULTS.showing,
90 hideAfter: isHideAfter(subagents.hideAfter) ? subagents.hideAfter : PERSONAL_DEFAULTS.hideAfter,
91 branchesWhere: isBranchesWhere(branches.where) ? branches.where : PERSONAL_DEFAULTS.branchesWhere,
92 branchesShow: isBranchesShow(branches.show) ? branches.show : PERSONAL_DEFAULTS.branchesShow,
93 }
94}
95
96/** Where each setting sits in the file. */
97const PATHS: { [K in keyof Personal]: readonly [string, ...string[]] } = {
98 settings: ['bar', 'show'],
99 isHidden: ['bar', 'hidden'],
100 taskView: ['view', 'tasks'],
101 cleanView: ['view', 'clean'],
102 showing: ['subagents', 'showing'],
103 hideAfter: ['subagents', 'hideAfter'],
104 branchesWhere: ['branches', 'where'],
105 branchesShow: ['branches', 'show'],
106}
107
108function setAt(raw: Json, path: readonly string[], value: unknown): Json {
109 const [key, ...rest] = path
110 if (key === undefined) {
111 return raw
112 }
113
114 return { ...raw, [key]: rest.length === 0 ? value : setAt(objectAt(raw, key), rest, value) }
115}
116
117function hasAt(raw: Json, path: readonly string[]): boolean {
118 const [key, ...rest] = path
119 if (key === undefined || !(key in raw)) {
120 return false
121 }
122
123 return rest.length === 0 || hasAt(objectAt(raw, key), rest)
124}
125
126/** The file with `change` written into it; every other key, the person's own included, kept as it was. */
127export function withPersonal(raw: Json, change: Partial<Personal>): Json {
128 let next = raw
129 for (const key of Object.keys(change) as (keyof Personal)[]) {
130 const value = key === 'settings' ? { ...objectAt(objectAt(raw, 'bar'), 'show'), ...change.settings } : change[key]
131 next = setAt(next, PATHS[key], value)
132 }
133
134 return next
135}
136
137/** Settings the mod no longer has: dropped from the file when it is next filled in. */
138const RETIRED_PATHS = [
139 ['subagents', 'team'],
140 ['subagents', 'helpers'],
141] as const
142
143/** The file without the setting at `path`; the file as it was when it has none. */
144function withoutAt(raw: Json, path: readonly string[]): Json {
145 const [key, ...rest] = path
146 if (key === undefined || !hasAt(raw, path)) {
147 return raw
148 }
149 if (rest.length === 0) {
150 const { [key]: _, ...kept } = raw
151 return kept
152 }
153
154 return { ...raw, [key]: withoutAt(objectAt(raw, key), rest) }
155}
156
157/** The settings the mod kept in its store before the file existed, under their old keys. */
158export type StoredSettings = Partial<Record<(typeof STORED_KEYS)[number], unknown>>
159
160/** The old store keys the migration reads, and clears once the file holds them. */
161export const STORED_KEYS = ['settings', 'isHidden', 'viewMode', 'agentTeam', 'agentHelpers', 'agentShowing', 'agentHideAfter'] as const
162
163/**
164 * The file with every setting it lacks filled in: from the store where the mod kept it before, else its default, so
165 * the file shows every setting there is, and with any setting the mod no longer has removed. What the file already
166 * holds wins.
167 */
168export function migrated(raw: Json, stored: StoredSettings): Json {
169 const old = personalFrom({
170 bar: { show: stored.settings, hidden: stored.isHidden },
171 view: stored.viewMode,
172 subagents: { showing: stored.agentShowing, hideAfter: stored.agentHideAfter },
173 })
174 let next = RETIRED_PATHS.reduce(withoutAt, raw)
175 if (typeof next.view === 'string') {
176 // The one-word view of before, as the two it stands for.
177 const was = personalFrom(next)
178 next = { ...next, view: { tasks: was.taskView, clean: was.cleanView } }
179 }
180 for (const key of Object.keys(PATHS) as (keyof Personal)[]) {
181 if (key === 'settings') {
182 // Each display option on its own, so one added in a later version joins a file that has the rest.
183 // An option that took over an older one's job starts from the file's pick for the older one.
184 const show = objectAt(objectAt(next, 'bar'), 'show')
185 const fromFile = normalizeSettings(show)
186 const missing = Object.fromEntries(
187 Object.entries(old.settings)
188 .filter(([k]) => !(k in show))
189 .map(([k, v]) => [k, (LEGACY_KEYS[k as keyof typeof LEGACY_KEYS] ?? '') in show ? fromFile[k as keyof typeof fromFile] : v]),
190 )
191 if (Object.keys(missing).length > 0) next = setAt(next, PATHS.settings, { ...show, ...missing })
192 } else if (!hasAt(next, PATHS[key])) {
193 next = setAt(next, PATHS[key], old[key])
194 }
195 }
196
197 return next
198}
199src/project.tsx 327 lines1/**
2 * The project row: a second line under the bar for the GitHub repository the session works in. On the left the
3 * repository's name (opening it in the browser), how many items are open on its GitHub Project board (opening the
4 * Project pane), how many branches it has besides the base (opening the Branches pane), how many background tasks run
5 * (opening their pane) and the issue the session is about (opening in the browser); on
6 * the right the tools the project's own `.claude/aitools.json` defines (links, servers, commands). Outside a GitHub
7 * repository the row is not drawn.
8 */
9
10import type { Elements } from 'claude-code'
11
12import { tasksLabel } from './agentdock'
13import { objectAt } from './config'
14import type { Json } from './config'
15import { DOING_TEXT, FAINT_TEXT, GREEN, RED, YELLOW } from './palette'
16import type { GithubRepo, ProjectConfig, ProjectTool, ToolListener, ToolRun } from '../types'
17
18/** A GitHub repository from a git remote URL: `https://github.com/o/r(.git)`, `git@github.com:o/r.git`, `ssh://…`. */
19export function parseGithubRemote(url: string): { owner: string; name: string } | null {
20 const match = /^(?:https?:\/\/(?:[^@/]+@)?github\.com\/|git@github\.com:|ssh:\/\/git@github\.com(?::\d+)?\/)([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/.exec(url.trim())
21
22 return match?.[1] && match[2] ? { owner: match[1], name: match[2] } : null
23}
24
25/** Context the engine joins into a prompt's text, which can name unrelated issues and pull requests. */
26const REMINDER = /<system-reminder>[\s\S]*?<\/system-reminder>/g
27/** An issue a prompt names: its URL or `#<number>`; the first one counts. */
28const PROMPT_ISSUE = /\/issues\/(\d+)|(?:^|[^\w&])#(\d+)\b/
29
30/** The issue a prompt names, as vowelit's devserver mod reads it; null when it names none. */
31export function issueInPrompt(text: string): number | null {
32 const match = PROMPT_ISSUE.exec(text.replace(REMINDER, ''))
33 const n = match?.[1] ?? match?.[2]
34
35 return n === undefined ? null : Number(n)
36}
37
38/**
39 * The issue a branch is named for: a number opening the name or a segment after `/`, alone or before a `-`
40 * (`feat/123-login`, `123-fix`, `issue-123`, `gh-123`). A date (`2026-10-10-notes`) or a version (`v2`) is none.
41 */
42export function issueInBranch(branch: string): number | null {
43 for (const segment of branch.split('/')) {
44 const match = /^(?:(?:issue|issues|gh|bug|fix)-)?(\d+)(?:-|$)/i.exec(segment)
45 if (match?.[1] && !/^\d{4}-\d{2}-\d{2}/.test(segment)) {
46 return Number(match[1])
47 }
48 }
49
50 return null
51}
52
53/** The values a tool's command or link may name in braces. */
54export type ToolVars = { checkout: string; main: string; branch: string; repo: string; owner: string; name: string; issue: string; port: string }
55
56/** `text` with each `{name}` it names replaced; a name it does not know is left as written. */
57export function expand(text: string, vars: ToolVars): string {
58 return text.replace(/\{(\w+)\}/g, (whole, key: string) => (key in vars ? vars[key as keyof ToolVars] : whole))
59}
60
61/** A command as the file gives it: an argument list, or a string run by `sh -c`. */
62function argvOf(value: unknown): string[] | null {
63 if (typeof value === 'string' && value.trim()) return ['sh', '-c', value]
64 if (Array.isArray(value) && value.length > 0 && value.every(v => typeof v === 'string')) return value as string[]
65
66 return null
67}
68
69const slug = (text: string) => text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'tool'
70
71/**
72 * The project section of a project's file, its problems listed rather than thrown: a tool it cannot use is left out
73 * with a line saying why, so one mistake does not take the rest of the row down.
74 */
75export function projectFrom(raw: Json): ProjectConfig {
76 const project = objectAt(raw, 'project')
77 const problems: string[] = []
78 const tools: ProjectTool[] = []
79 const ids = new Set<string>()
80 const list = project.tools === undefined ? [] : Array.isArray(project.tools) ? project.tools : null
81 if (list === null) problems.push('project.tools must be a list')
82 for (const [i, entry] of (list ?? []).entries()) {
83 const where = `project.tools[${i}]`
84 const t = entry !== null && typeof entry === 'object' && !Array.isArray(entry) ? (entry as Json) : null
85 const label = typeof t?.label === 'string' && t.label.trim() ? t.label.trim() : null
86 if (t === null || label === null) {
87 problems.push(`${where} needs a "label"`)
88 continue
89 }
90 let id = typeof t.id === 'string' && t.id.trim() ? slug(t.id) : slug(label)
91 while (ids.has(id)) id = `${id}-${i}`
92 ids.add(id)
93 if (t.type === 'link') {
94 if (typeof t.url !== 'string' || !t.url.trim()) {
95 problems.push(`${where} ("${label}") needs a "url"`)
96 continue
97 }
98 tools.push({ type: 'link', id, label, url: t.url.trim() })
99 } else if (t.type === 'command') {
100 const run = argvOf(t.run)
101 if (run === null) {
102 problems.push(`${where} ("${label}") needs a "run" command: a string, or a list of arguments`)
103 continue
104 }
105 tools.push({ type: 'command', id, label, run, ...(typeof t.cwd === 'string' ? { cwd: t.cwd } : {}) })
106 } else if (t.type === 'server') {
107 const start = argvOf(t.start)
108 if (start === null) {
109 problems.push(`${where} ("${label}") needs a "start" command: a string, or a list of arguments`)
110 continue
111 }
112 const stop = t.stop === undefined ? null : argvOf(t.stop)
113 if (t.stop !== undefined && stop === null) problems.push(`${where} ("${label}"): "stop" must be a string or a list of arguments`)
114 const port = typeof t.port === 'number' && Number.isInteger(t.port) && t.port > 0 && t.port < 65536 ? t.port : null
115 if (t.port !== undefined && port === null) problems.push(`${where} ("${label}"): "port" must be a port number`)
116 let ready: string | null = null
117 if (typeof t.ready === 'string') {
118 try {
119 new RegExp(t.ready)
120 ready = t.ready
121 } catch {
122 problems.push(`${where} ("${label}"): "ready" is not a valid regular expression`)
123 }
124 }
125 tools.push({
126 type: 'server',
127 id,
128 label,
129 start,
130 ...(stop === null ? {} : { stop }),
131 ...(port === null ? {} : { port }),
132 ...(ready === null ? {} : { ready }),
133 ...(typeof t.cwd === 'string' ? { cwd: t.cwd } : {}),
134 })
135 } else {
136 problems.push(`${where} ("${label}"): "type" must be "link", "command" or "server"`)
137 }
138 }
139 const board = project.board === false ? false : typeof project.board === 'string' && project.board.trim() ? project.board.trim() : undefined
140 if (project.board !== undefined && board === undefined) problems.push('project.board must be a URL, or false to hide it')
141
142 return {
143 isEnabled: project.enabled !== false,
144 ...(typeof project.name === 'string' && project.name.trim() ? { name: project.name.trim() } : {}),
145 ...(board === undefined ? {} : { board }),
146 tools,
147 problems,
148 }
149}
150
151/** A server's status line: a dot in its state's color and the words, which open its log. */
152export function serverStatus(run: ToolRun | undefined, listener: ToolListener | null | undefined): { dot: string; text: string; color?: string } {
153 const phase = run?.phase ?? 'idle'
154 const branch = run?.branch ?? ''
155 if (phase === 'stopping') return { dot: '○', text: branch ? `Stopping ${branch}…` : 'Stopping…', color: YELLOW }
156 if (phase === 'starting') return { dot: '○', text: branch ? `Starting ${branch}…` : 'Starting…', color: YELLOW }
157 if (phase === 'ready') return { dot: '●', text: branch ? `Running ${branch}` : 'Running', color: GREEN }
158 if (listener) return { dot: '●', text: `Running ${listener.label}`, color: GREEN }
159 if (phase === 'running') return { dot: '●', text: branch ? `Running ${branch}` : 'Running', color: GREEN }
160 if (phase === 'exited') {
161 // Exiting with code 0, or on the stop signal a Stop sends, is a clean end: dim like Stopped, not red.
162 const isClean = /^(code 0|SIGTERM|SIGINT)$/.test(run?.detail ?? '')
163 return { dot: '○', text: `Exited${run?.detail ? ` (${run.detail})` : ''}`, ...(isClean ? {} : { color: RED }) }
164 }
165 if (phase === 'failed') return { dot: '○', text: `Failed${run?.detail ? `: ${run.detail}` : ''}`, color: RED }
166
167 return { dot: '○', text: 'Stopped' }
168}
169
170/** Whether a server counts as up: this session's run, or anything listening on its port. */
171export function isServerUp(run: ToolRun | undefined, listener: ToolListener | null | undefined): boolean {
172 return listener != null || run?.phase === 'starting' || run?.phase === 'ready' || run?.phase === 'running'
173}
174
175/** A command tool's dot: yellow while it runs, green once it passed, red once it failed; none before it ran. */
176export function commandDot(run: ToolRun | undefined): { dot: string; color: string } | null {
177 if (run?.phase === 'running') return { dot: '●', color: YELLOW }
178 if (run?.phase === 'passed') return { dot: '●', color: GREEN }
179 if (run?.phase === 'failed' || run?.phase === 'exited') return { dot: '●', color: RED }
180
181 return null
182}
183
184type El = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
185
186/** Quiet buttons, as the bar's own: dim text and no outline at rest, full strength under the pointer. */
187const QUIET = { plain: true, dimColor: true } as const
188
189/** What the row draws: the repository, its links, and each tool with how it stands. */
190export type RowView = {
191 repo: GithubRepo
192 name: string
193 boardUrl: string | null
194 issue: { number: number; title: string | null } | null
195 tools: readonly ProjectTool[]
196 runs: Readonly<Record<string, ToolRun>>
197 listeners: Readonly<Record<string, ToolListener | null>>
198 /** How many items are open on the board, null before it is read: a button after the name opens the Project pane. */
199 openItems: number | null
200 /** How many branches the repository has besides the base, null before a read: a button opens the Branches pane. */
201 branches: number | null
202 /** How many background tasks are running: a button after the branches' opens their pane while any is. */
203 tasks: number
204 /** Room between neighboring buttons: the desktop's own chrome parts them, the terminal needs spaces. */
205 gap: number
206}
207
208/** The board button's label: `1 project item`, `32 project items`. */
209export function itemsLabel(n: number): string {
210 return `${n} project ${n === 1 ? 'item' : 'items'}`
211}
212
213/** The branches button's label: `1 branch`, `5 branches`. */
214export function branchesLabel(n: number): string {
215 return `${n} ${n === 1 ? 'branch' : 'branches'}`
216}
217
218/** The issue button's label: its number, then its title cut to fit. */
219export function issueLabel(issue: { number: number; title: string | null }): string {
220 const title = issue.title?.trim()
221 if (!title) return `#${issue.number}`
222 const chars = Array.from(title)
223
224 return `#${issue.number} ${chars.length > 32 ? `${chars.slice(0, 31).join('')}…` : title}`
225}
226
227export function renderProjectRow(
228 el: El,
229 view: RowView,
230 on: {
231 open(url: string): void
232 startServer(id: string): void
233 stopServer(id: string): void
234 toggleLog(id: string): void
235 runCommand(id: string): void
236 openTasks(): void
237 openBoard(): void
238 openBranches(): void
239 },
240) {
241 const { Box, Text, Button } = el
242 const { repo, gap } = view
243
244 return (
245 <Box flexDirection="row" justifyContent="space-between" width="100%">
246 <Box key="project-left" flexDirection="row" gap={gap} flexShrink={1} alignItems="center">
247 <Button key="project-name" label={view.name} {...QUIET} onPress={() => on.open(repo.url)} />
248 {view.boardUrl !== null && view.openItems !== null && view.openItems > 0 && (
249 <Button key="project-board" label={itemsLabel(view.openItems)} {...QUIET} onPress={() => on.openBoard()} />
250 )}
251 {view.branches !== null && view.branches > 0 && (
252 <Button key="project-branches" label={branchesLabel(view.branches)} {...QUIET} onPress={() => on.openBranches()} />
253 )}
254 {view.tasks > 0 && <Button key="project-tasks" label={tasksLabel(view.tasks)} {...QUIET} onPress={() => on.openTasks()} />}
255 {view.issue !== null && (
256 <Button
257 key="project-issue"
258 label={issueLabel(view.issue)}
259 {...QUIET}
260 onPress={() => on.open(`${repo.url}/issues/${view.issue?.number}`)}
261 />
262 )}
263 </Box>
264 <Box key="project-tools" flexDirection="row" gap={gap} flexShrink={0} alignItems="center">
265 {view.tools.map(tool => {
266 if (tool.type === 'link') {
267 return <Button key={`tool-${tool.id}`} label={tool.label} {...QUIET} onPress={() => on.open(tool.url)} />
268 }
269 const run = view.runs[tool.id]
270 if (tool.type === 'command') {
271 const dot = commandDot(run)
272 return (
273 <Box key={`tool-${tool.id}`} flexDirection="row" alignItems="center">
274 {dot && <Text color={dot.color}>{`${dot.dot} `}</Text>}
275 <Button
276 key={`tool-${tool.id}-run`}
277 label={run?.phase === 'running' ? `${tool.label}…` : tool.label}
278 {...QUIET}
279 onPress={() => (run?.phase === 'running' ? on.toggleLog(tool.id) : on.runCommand(tool.id))}
280 />
281 </Box>
282 )
283 }
284 const listener = view.listeners[tool.id]
285 const status = serverStatus(run, listener)
286 const isUp = isServerUp(run, listener)
287 const isBusy = run?.phase === 'starting' || run?.phase === 'stopping'
288 return (
289 <Box key={`tool-${tool.id}`} flexDirection="row" gap={gap} alignItems="center">
290 <Box key={`tool-${tool.id}-status`} flexDirection="row" alignItems="center">
291 <Text color={status.color ?? FAINT_TEXT}>{`${status.dot} `}</Text>
292 <Button key={`tool-${tool.id}-log`} label={status.text} {...QUIET} onPress={() => on.toggleLog(tool.id)} />
293 </Box>
294 {isBusy ? (
295 <Text key={`tool-${tool.id}-busy`} color={DOING_TEXT}>{isUp ? `Stop ${tool.label}` : `Start ${tool.label}`}</Text>
296 ) : isUp ? (
297 <Button key={`tool-${tool.id}-stop`} label={`Stop ${tool.label}`} {...QUIET} onPress={() => on.stopServer(tool.id)} />
298 ) : (
299 <Button key={`tool-${tool.id}-start`} label={`Start ${tool.label}`} {...QUIET} onPress={() => on.startServer(tool.id)} />
300 )}
301 </Box>
302 )
303 })}
304 </Box>
305 </Box>
306 )
307}
308
309/** A tool's log pane id. */
310export const logPaneId = (id: string) => `aitools-log-${id}`
311
312/** How many lines a tool's log keeps. */
313export const LOG_KEEP = 2000
314
315const ANSI = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07]*\x07/g
316
317/**
318 * A chunk of output cut into whole lines, colors stripped and a carriage return taken as a redraw of the line: the
319 * lines it finished, and the unfinished rest, which the next chunk continues.
320 */
321export function splitOutput(rest: string, text: string): { lines: string[]; rest: string } {
322 const parts = (rest + text.replace(ANSI, '')).split('\n')
323 const tail = parts.pop() ?? ''
324
325 return { lines: parts.map(part => part.slice(part.lastIndexOf('\r') + 1)), rest: tail }
326}
327