SLOPSHOPPER

aitools

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

newpanebandrowsguardcommand
★ 1v0.9.3no licenseupdated 2026-10-15pkkid/claude-aitools/plugins/aitools
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · aitools
│ ┃ Background tasks ✕ › fix the failing auth test╭───────────────────────────────────────╮ │ ┃ Showing: All │ aitools │ │ ┃ Hide after: Never ⏺ Read(src/auth.ts) │ Handoff brief copied to the clipboard │ │ ┃ ⎿ Read 6 lines ╰───────────────────────────────────────╯ │ ┃ Background tasks will appear here when ⏺ Bash(bun test) │ ┃ started. ⎿ 3 pass, 1 fail │ │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /aitools │ ⎿ aitools: aitools bar hidden. Run /aitools to show it. │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ aitools: Task View, Clean View

Draws

Pane · Background tasks
Showing: All Hide after: Never Background tasks will appear here when started.
Pane · Branches
Branches: Local + remote Show: All Git didn't list any worktrees.
Pane · handoff
Writing the handoff brief…
Pane · aitools-settings
What the AI Tools ribbons show. Changes save right away. Yours, in every project: ~/.claude/aitools.json Not in a GitHub repository Global Settings May be overridden by project settings ● Show title AI Tools at the start of the bar ● Threshold colors Yellow from 70%, red from 90%; a cache a ● Show project row A second row: repo, board, issue and too ○ Statusline file Written each turn, as the CLI's status l ~/.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. 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 each project Its settings show in a GitHub repository.
Pane · project
Reading the board…
Pane · notes
Loading the note…
README

aitools

A one-row bar above the prompt showing how close your Claude subscription is to its limits, and what the work would cost at Anthropic API list prices.

AI Tools    5h 42%, 1h12m    wk 18%, Thu    ctx 31% 62k/200k    tok 1.3M (+48k)    cache 42m, $0.50    thread $3.12 (+$0.41)    month $184.20    limit ~3:40pm   [🛠] [⁝]
SegmentMeaning
ClawdThe mascot, at the start of the bar in the desktop Code tab only (see below)
AI ToolsA dim title naming the bar
5h 42%, 1h12m5-hour usage window: percent used, time until it resets
wk 18%, ThuWeekly window: percent used, the day it resets (hours when under a day)
ctx 31% 62k/200kContext window fill: percent and tokens used / window size
tok 1.3M (+48k)Tokens: this thread's total this month (its helpers' included) and the last turn's, counting input, output and prompt-cache reads and writes
cache 42mHow long the prompt cache should stay warm: Claude Code writes the main conversation's cache with a 1-hour TTL, restarted by every request. Amber under 10 minutes, cache cold once expired. An estimate from request times; the API does not report cache state, and Anthropic may evict early
cache 42m, $0.50What 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.12This session's cost at API prices
(+$0.41)What the last turn cost (last $0.41 when thread cost is hidden)
~2.1% wk (+0.03%)Thread cost %: the thread's and the last turn's estimated share of your weekly allowance (see below)
month $184.20This calendar month's Claude Code usage at API prices, across all sessions on this machine
limit ~3:40pmAt your pace over the last hour, when the 5-hour window would run out; shown only if before it resets

Labels are dim and values a lighter grey; usage and context values turn yellow at 70% and red at 90%. A value not known yet shows -; until the first response of a session the bar shows the figures from your previous session.

Show or hide the bar

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

Settings

Press ⁝ (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.

The settings file

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

Mascot

In the desktop Code tab, Clawd, Claude Code's pixel mascot, stands at the start of the bar (48 × 20 px) and acts out what Claude is doing. Every pose is animated, and a change of pose plays a short transition first: he walks over to his laptop or paper, sits down to sleep, jolts awake.

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

He is SVG, so he never shows in the terminal, and Mascot in the settings pane (on by default) is offered on the desktop only.

Handoff

/handoff asks the current conversation for a brief (goal, current state, decisions, open tasks, key files, next step), prints it in the chat as formatted text, and copies the raw markdown to the clipboard. Paste it into a new session to continue there. The brief is a command output row, so the current session's model reads it too.

The 🛠 button opens a row above the bar with Handoff, Notes, 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).

Keep the cache warm

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.

  • A ping is $.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.
  • No ping while Claude is working (its own requests keep the cache warm), before the session's first request, after compaction until the next request, or when the cache would stay warm past the window's end anyway.
  • A cache that has already lapsed is never pinged: that would pay the full write every hour. Pings start again after your next message.
  • Each ping's readback is checked. If it read nothing, or wrote a tenth of what it read or more, the cache was already gone, and keepwarm stops. A failed ping (an API error, an interruption, an empty reply) stops it too. Either way a dim line in the chat and /keepwarm status say why.
  • The window belongs to this session and is kept across a restart of it, until its time is up.
  • Claude Code must be left running, and the main cache must have the 1-hour lifetime: subscription use has it by default; API-key and cloud-provider sessions need 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.

Ask as choices

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

Notes in the 🛠 menu opens a Notes pane with one text box for a single scratch note. It is kept in .claude/notes.md under the project root, a plain Markdown file you can open anywhere else too, and saved as you type. Copy, at the end of the line above the box, copies the note to the clipboard. Add .claude/notes.md to the project's .gitignore if it should stay out of git.

Click in the box to type. Enter starts a new line, the arrows, Home and End move the cursor (Ctrl+A and Ctrl+E too), Page Up and Page Down move ten rows, and Backspace and Delete remove. Escape hands the keys back to the prompt. There is no selection or undo. Pasting works in the terminal but not in the desktop app, where a mod cannot read the clipboard; paste into .claude/notes.md in another editor and reopen the pane instead. The pane stays open until you close it, and comes back after the mod reloads. Where a surface cannot draw the box (VS Code, the mobile app), the pane says where the note is kept instead.

Task View and Clean View

Two views from the 🛠 menu, 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).

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

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.

Background tasks

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:

  • Subagent: a subagent Claude starts with its Agent tool (foreground or background).
  • Workflow: an agent of a workflow Claude starts, named after the workflow (review-changes · agent 3, since a workflow's agents have no names of their own).
  • Monitor: a monitor watching a command's output or a socket: its description, then what it watches.

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.

  • A task's end is what Claude Code's own notice of it says (completed, failed, killed), whether the notice arrives as a prompt of its own or inside a running turn, the main chat's or the subagent's that started it. A subagent whose end was missed is settled when Claude next replies; a monitor whose closing notice never comes ends a minute past its deadline.
  • Only while the pane is open, every subagent Claude starts with its Agent tool has its prompt ask it to report how far along it is through the mod's agent_progress tool (mcp__aitools__agent_progress); once it does, its line shows its percent (its own estimate) and a bar. Workflow agents, monitors, and subagents started while the pane was closed are not asked, so they show no bar.
  • Showing: All (the default) lists every line; Subagents, Workflows and Monitors each list only that type. With nothing to list, the pane says what will appear there, e.g. Monitors will appear here when started.
  • Hide after: how long a finished line stays: Never (the default), 1h, 15m, 5m or Immediate. Either way the pane keeps at most the newest 50 finished lines.
  • The Showing and Hide after picks are saved.
  • A workflow's agents are told apart from the engine's own background forks (compaction, memory) only by whether a workflow has been started this session; after one, such a fork may briefly show as one of its agents.
  • The pane itself starts closed in a new session.

Branches

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.

  • Branches filters by where a branch lives: Local + remote (the default), Local (a branch or worktree on your computer), Remote (a branch on the remote, origin).
  • Show filters by how it stands: All (the default), **A
Source 27 files
hooks/register.tsx 3019 lines
1import { 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 lines
1import 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}
407
src/bar.tsx 360 lines
1import 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}
360
src/board.tsx 388 lines
1/**
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}
388
src/pricing.ts 100 lines
1// 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}
100
src/checklist.tsx 265 lines
1import 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}
265
src/palette.ts 76 lines
1/**
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
76
src/burnrate.ts 46 lines
1const MIN = 60_000
2const WINDOW = 60 * MIN
3const MIN_SPAN = 10 * MIN
4
5export type Sample = { t: number; pct: number }
6
7export type BurnState = { resetsAt: string | null; samples: Sample[] }
8
9export const EMPTY_BURN: BurnState = { resetsAt: null, samples: [] }
10
11export function addSample(s: BurnState, t: number, pct: number, resetsAt?: string): BurnState {
12  const window = resetsAt ?? null
13  const samples = window === s.resetsAt ? s.samples : []
14  if (samples.at(-1)?.pct === pct) {
15    return { resetsAt: window, samples }
16  }
17
18  return { resetsAt: window, samples: [...samples, { t, pct }].filter(x => x.t >= t - WINDOW) }
19}
20
21/** When the 5-hour window hits 100% at the recent pace, or null when that is not before the reset. */
22export function project(s: BurnState, now: number): number | null {
23  const samples = s.samples.filter(x => x.t >= now - WINDOW)
24  const first = samples[0]
25  const last = samples.at(-1)
26  if (!first || !last || samples.length < 2 || last.t - first.t < MIN_SPAN) {
27    return null
28  }
29
30  const meanT = samples.reduce((sum, x) => sum + x.t, 0) / samples.length
31  const meanP = samples.reduce((sum, x) => sum + x.pct, 0) / samples.length
32  const covariance = samples.reduce((sum, x) => sum + (x.t - meanT) * (x.pct - meanP), 0)
33  const variance = samples.reduce((sum, x) => sum + (x.t - meanT) ** 2, 0)
34  const slope = covariance / variance
35  if (!(slope > 0)) {
36    return null
37  }
38
39  const eta = Math.round(now + (100 - last.pct) / slope)
40  if (s.resetsAt && eta >= Date.parse(s.resetsAt)) {
41    return null
42  }
43
44  return eta
45}
46
src/handoff.ts 24 lines
1export const HANDOFF_PROMPT =
2  'Write a handoff brief so a fresh session with no memory of this conversation can continue the work. ' +
3  'Use markdown with exactly these sections: ## Goal, ## Current state, ## Decisions made, ## Open tasks, ' +
4  '## Key files, ## Next step. Be specific: name files, functions, commands and unresolved questions. ' +
5  'Output only the brief.'
6
7/**
8 * The chat row for a brief, which the chat renders as markdown. The title line comes first because the engine
9 * prefixes the row with the plugin's name, which would otherwise swallow the first heading.
10 */
11export function handoffOutput(brief: string): string {
12  return `Handoff brief\n\n${brief}`
13}
14
15/** The most a Markdown element draws; a longer brief is cut in the pane, while Copy still takes all of it. */
16export const MARKDOWN_MAX = 10_000
17
18const CUT_NOTE = '\n\n_(Cut to fit the pane; Copy takes the whole brief.)_'
19
20/** The brief as the handoff pane draws it, cut to fit the Markdown element's limit. */
21export function paneMarkdown(brief: string): string {
22  return brief.length <= MARKDOWN_MAX ? brief : brief.slice(0, MARKDOWN_MAX - CUT_NOTE.length) + CUT_NOTE
23}
24
src/askme.ts 64 lines
1/**
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}
64
src/config.ts 199 lines
1/**
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}
199
src/project.tsx 327 lines
1/**
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