A pane beside the conversation for the work behind it: running shells and agents, the model and a clickable effort meter, and step-by-step progress of any job…

A pane beside the conversation in Claude Code for the work behind it: the shells and agents that run, the model, an effort meter you can press, and the steps of each job as a tree.
Type /deck, or press its mark, ◨, under the prompt:
<img src="docs/deck.png" width="604" alt="Deck's pane: the model with its effort meter, the context's fill, the session's cost and limits; two shells that run, one opened to its command with a stop button; an agent with the shell it started and the run it opened under it; a run as a tree with a finished branch folded; and the two commands that lately ended, one failed">
claude plugin marketplace add barisdemirhan/claude-mods
claude plugin install deck@claude-mods
Restart Claude Code, then run /deck.
The same two steps work from inside a session with /plugin marketplace add barisdemirhan/claude-mods and /plugin install deck@claude-mods.
claude-mods is one marketplace for all of these mods, so its first line is needed once for the lot.
claude-deckNothing has to change. This repository is a marketplace of its own too, and deck@claude-deck goes on getting updates. Keep one of the two installs, not both: with both on, every hook runs twice.
| Command | What it does |
|---|---|
/deck | Opens the pane, or closes the open one. /deck open only opens it. It asks for 52 columns beside the conversation; a width you drag it to stands. Opened by you, with a command or a press, it is drawn at any width: beside the conversation in the fullscreen layout from 110 columns, else above the prompt. Where Claude Code still keeps it waiting undrawn (a surface that draws no panes), the answer or a toast says why |
/deck watch | With GitHub checks on, follows the checks of the branch you are on. /deck watch 12 follows a pull request, and a workflow run's id, a branch, a commit or a page's address on GitHub works the same. /deck unwatch stops following; the runs stay where they stood. See GitHub checks |
/deck clear | Takes what is over out of the pane: the ended shells and agents, and the runs that finished or stopped. What still runs stays |
/deck row | Keeps the label as text on the hint line, or brings its row back. The row's × does the first. Every open session follows within two seconds |
/deck close | Takes the deck away in every open session within two seconds: the label, the pane, the reading of shell calls, what it follows on GitHub and the effort it set. What was running ends where it stood, since the deck sees no end while it is closed. /deck exit and /deck quit do the same; /deck brings it back |
Under the prompt the deck keeps one label: the model, its effort, how many shells and agents run, and the job under way with its steps done.
<img src="docs/label.png" width="604" alt="The conversation with the pane closed: under a group of three shell commands, a row for each with its mark and time; under the prompt, the deck's label with its mark, the model, the effort in its level's color, the count of what runs, the run under way and a close mark">
Where there is a pointer, the terminal's fullscreen layout or the desktop app, the label is a row of its own right under the hint line, and it begins with the deck's mark, ◨: a press on it opens or closes the pane, and so does a press on the run at the row's end. The model's name between them is plain text. Its effort is drawn in its level's color, and a press on the level's name steps it up, as on the pane's meter. What runs has the color of a running row, a failed step's ✗ is red, the rest is dim. × closes the row: the label is then text on the hint line. Where another mod has already drawn a row of its own there, the label joins that row at its end, so the two take one row between them. The row fits what the screen leaves it beside the docked pane and another mod's row: the run's title is cut first, then the model's name goes, then the run, so × stays on the screen. A shell Claude Code asks you about is not counted as running. The model follows /model within two seconds; its effort shows again with its first request. On the terminal's main screen it is text at the end of the hint line. The Hint label setting keeps it text everywhere, or takes it off.
| Part | What it shows |
|---|---|
| The first rows | The main thread's model, its effort meter, and how full the context window is. Under them what the session cost and how full each rate limit is ($1.24 · 5h 34% · 7d 12%); above the prompt, where the pane has few rows, these share the first row while it has room |
SHELLS and AGENTS | Each shell command and each subagent that runs now, in a section of its own, with how long it has run. Each row starts with ▸ closed or ▾ open, before its state mark. A title too long for its row is cut with …. A shell is named by what its call says it does, or by the first line of its command where the call says nothing; a subagent by its type and task, as general-purpose(Ship the release). At its row's end a subagent shows its model and its effort, Sonnet ▰▰▰▱▱: the model's first word as its start named it, then the effort's bar in the level's color once its first request carried one. A narrow pane keeps the name and leaves out the bar. What a running agent started, its own shells and agents and the run it opened, is drawn under its row. A shell Claude Code asks you about shows ? and waits, with no clock, until it runs: its time starts when it does, two seconds before Claude Code draws its ctrl+b hint under it |
| The runs | Each job's steps as a tree, between the two lists. See below |
RECENT | The last eight that ended: ✓ done, ✗ failed, ■ stopped, · ended with no word on how. A foreground command that went well in under three seconds is left out. Above the prompt the newest three are listed and the rest counted, +5 more |
A press on a row's mark or title opens its detail under it, and another closes it. If the title was cut, its full text comes first, wrapped over as many lines as it needs; a title that fits is not repeated. Under that is a shell's command, its first line, wrapped and bounded to eight lines' worth of characters with a final … where it is cut, or an agent's model and effort. An open title is no longer dim. Where the job runs in the background, a line under the detail has a ■ stop button, which asks Claude Code to stop that task as its own TaskStop tool does. × clear on the pane's last line does what /deck clear does. What is over also leaves the pane by itself after half an hour.
A shell that Claude runs in the background, or that a timeout or ctrl+b moves there, stays under SHELLS until Claude Code reports its end. That report waits for the tool call Claude is in, so a background shell's time can read longer than it ran.
Where Claude Code folds a run of tool calls into one line of the conversation, the deck adds a row under it for each shell command of the group:
Ran 3 shell commands
✓ Check the version 2s
✗ Verify the profile 8s
⏵ Restart the daemon 14s
Each row is that call's own: matched by the call's id, in the order Claude made them, with the state the transcript gives it. A command that went to the background keeps ⏵ and its time until it ends, its time running whether the pane is open or not. A title is cut to the conversation's width beside a docked pane, and to 56 cells at most. A press on a title opens the pane with that command's row open. Past four commands the rest are counted, +2 more. Claude Code's own line stays as it is, other tools get no row, and a group you expand with ctrl+o is left alone. The Transcript rows setting turns them off.
A run is one job's steps, with how many are done and how long each took. The deck keeps the last six. When the subagent that opened a run ends, the run's clock stops and a step it left running goes back to waiting. When an agent opens a new plan, the one it had under way stops the same way: the agent moved on, and nothing would end it.
A loop shows one run at a time: when an agent opens a plan, the task list it was following leaves the pane, and its later tasks open none while the plan is under way. /clear takes every run away with the conversation.
The run under way is open, and the others are one row each. Inside a run, a branch is open while it is under way and folds by itself before it starts and once it is over. A press on a row's mark folds or unfolds it: ▾ open, ▸ folded. A step that failed is a red ✗, and so is every branch above it, so a folded run still shows it. A run a subagent opened carries the agent's name: Ship the release · general-purpose.
A step whose title does not fit has its own ▸ / ▾ mark beside its state. A press on that mark or the cut title opens the full title wrapped under it; a step that fits stays one line.
Only the steps at the ends of the tree have a state. A branch's state, its count and its time come from the steps under it.
With Tool for Claude on, Claude can call two tools:
plan takes a job's title and its steps as indented text, one step a line, and opens a run. Each line gets an id from its place: 1, 1.2, 1.2.1.step moves one step: start, done or fail. Starting a step ends any step still running before it in the plan, in the same branch or an earlier one, so moving on is one call. The first step starts with the plan, and a subagent's last step is done by itself when the agent answers, so a job of three stages costs an agent four calls: one to load the tools, the plan, and two moves. The note below also tells agents to skip the tree for a short job. The deck keeps the times; Claude never sends one. An agent moves its own plan; another's only by naming its run.Every agent of the session gets them with that one setting: the main thread and each subagent alike, of any type, with no line added to an agent's definition. So that an agent uses them without being asked, the same setting adds one note, the same words in two places: as a section of the main thread's system prompt, and at the end of the task each subagent is given.
The person follows progress in a pane called Deck, which shows them the title and steps of a plan and the description of each shell command you run. They read these to understand what is happening: write them in the language the person writes in. If you have the tool mcp__deck__plan and this job will take more than a couple of minutes or more than three stages, call it once as you begin with three to six steps (load it and mcp__deck__step with ToolSearch if their schemas are not loaded); its first step starts by itself, so call mcp__deck__step only as you move to the next one. Skip it for a short job: each call costs the person a round trip.
The note also asks for the words you will read in the pane, a plan's title and steps and each shell command's description, in the language you write in. A tool's description alone does not do this: where many tools are installed, Claude Code lists most by name only, and an agent reads a description only after it loads the tool. You can still ask, "plan this in the deck". The one agent that cannot is one whose definition lists its tools and leaves these two out: Claude Code refuses it any other tool. Claude Code asks your permission for them as for any tool, and their descriptions take a little of every prompt's context, which is why they are off by default.
With GitHub checks on, the deck follows checks on GitHub and shows them as a run: the CI of a pull request, a deploy or any other workflow run, the checks of a branch or a commit.
▾ PR #12 · Fix the wallet ▰▰▰▰▰▱▱▱ 3/5 4m 10s
✓ lint 41s
✓ unit 2m 03s
⏵ e2e 3m 20s
✗ deploy/preview 12s
○ smoke
| You give | It follows |
|---|---|
12, #12, or a pull request's address | That pull request's checks: its check runs, and the statuses other services set on its last commit |
| A workflow run's id or address | That run's jobs, each with its steps under it |
| A branch, a tag, a commit, or the address of one | The checks of that commit; for a branch, of its newest one |
| Nothing | The branch the session is on |
A number of eight digits or more is read as a workflow run's id, a shorter one as a pull request's. A check that was skipped, cancelled or timed out says so beside its name; a skipped one counts as passed.
The deck asks GitHub every 30 seconds while something is followed, four things at most. A pull request's or a commit's checks are over once two polls in a row find each one over, since a later workflow may still add its own; a workflow run is over when GitHub says so. A watch is given up when no check shows up in ten minutes, when no check moves for half an hour while none runs (a job that waits on a runner or an approval may wait for good; one that runs holds the watch however long it takes), after two hours in all, or when GitHub does not answer five times in a row: its run stops where it stands, a toast says why, and Claude is told where it asked to be woken. One question to GitHub may take 20 seconds. A toast tells you of a failed check and of the end, as for any run.
How it asks. With the gh command installed and signed in, the deck runs gh api for each question, so it holds no token and a company's own GitHub host works. Where there is no gh, or nobody is signed in to it, it asks api.github.com directly: with the token in GH_TOKEN or GITHUB_TOKEN where one is set, and with none for a public repository, then every two and a half minutes, as GitHub answers a nameless caller 60 requests an hour. Every question is a read. A host other than github.com needs gh, and gh runs only where Claude Code runs commands for a mod, which is the terminal.
For Claude. The same setting lists one tool, watch, with a note that tells every agent of the session it is there, added as the note of Tool for Claude is:
When you wait on GitHub checks (the CI of a pull request, a deploy or any other workflow run, the checks of a branch you pushed), call mcp__deck__watch once in place of polling in a shell (load it with ToolSearch if its schema is not loaded): the person follows each check in the Deck, and with wake you get a message when they are over.
watch takes what to follow, as /deck watch does, and wake. With wake, the deck submits one prompt of its own when the checks are over or the watch is given up, which starts a turn: how many checks passed and failed, the failed ones by name, and the page's address. So Claude can push, call watch, end its turn, and merge when the word comes, with no shell left polling. Without wake you see the checks and Claude is told nothing.
▰▰▰▱▱ high is the effort the main thread's requests go out with, drawn in its level's color: low dim, medium green, high yellow, xhigh orange, max red. Press the level's name and it goes one step up: low, medium, high, xhigh, max, then low again. The name's button is as wide at every level as the longest name, so a pointer that stays where it pressed goes all the way round. From the next request on, the deck sends that level in place of Claude Code's own, on the main thread only; a subagent keeps its own. The mark beside the meter is ⟳ while the deck sets the effort and ↑ while Claude Code does.
It lasts for the session, until one of these hands the effort back to Claude Code: /effort with another level, a step that lands on Claude Code's own level, or /deck close. A model that takes no effort setting shows no meter.
In Claude Code's /config menu, under the plugin's name:
| Setting | Default | What it does |
|---|---|---|
| Hint label | button | button: a row under the hint line with parts you can press, where there is a pointer, and text elsewhere. text: always at the end of the hint line. off: no label |
| Tool for Claude | off | Lists the plan and step tools for Claude. See above |
| GitHub checks | off | Follows checks on GitHub as a run: /deck watch, and the watch tool for Claude. See above |
| Transcript rows | on | The rows under a group of tool calls in the conversation. See above |
| Toasts | on | A toast when a background job fails or ends after half a minute, when a step fails and when a run finishes |
/deck does not show up after installing, the switch may still be off for you.shell or agent. The label's row and the transcript's rows fit the screen too, as told above.The mod registers one slash command, adds one label under the prompt, adds rows under the conversation's lines for groups of tool calls, and draws its pane when you open it. Out of the box it reads no files, writes none, runs no processes and makes no network request; with GitHub checks on it runs gh and git, or asks GitHub directly, as told below. Out of the box it changes one thing of Claude's work, and only after you ask: the effort of the main thread's model requests, after a press on the meter. A press on a row's ■ stop asks Claude Code to stop that one background task. With Tool for Claude on it changes one more: it adds the note quoted under that setting to the main thread's system prompt and to the end of each subagent's task. GitHub checks adds its own note the same way, and for a watch Claude asked to be woken for, the deck submits one prompt of its own when the checks are over. It never changes the prompt you type, a tool call or a tool's result.
What it reads. More than the other mods of this marketplace, which read no argument of a tool call:
Bash call: its description, cut to 80 characters, as the row's title, and the first line of its command, cut to 200, as the row's detail (and its title where the call has no description). Both are kept in memory until the session ends, eight newer rows push the row out, or half an hour passes. Of the call's result: whether it failed, whether it was interrupted, and the id of the background task it became. Not its output.description and first line of command as above. Of the group's other calls, the tool's name only, to pass them by.TaskStop call: the id of the task that was stopped.TaskCreate call: the new task's id and its subject, cut to 80 characters and kept in memory as a step's title. Of each TaskUpdate call: the task's id, its new status and, when it changes, its subject. Not a task's description.origin remote, for the repository's host and name, and for a watch with no target the name of the branch you are on. Of GitHub's answers: a pull request's title; a workflow run's name, title and state; each check's, job's and step's name, state and times. The names are cut to 80 characters and kept in memory as a run. With no gh, the value of GH_TOKEN or GITHUB_TOKEN, to send it to GitHub with each question.It reads nothing of a prompt's text, of any other tool's call, or of an answer.
What it sends. Out of the box, nothing: it makes no network request. It has no server, no account and no analytics, and nothing goes to the author of this mod.
With GitHub checks on, and only while something is watched, it asks GitHub, read only, every 30 seconds. What goes out is the question: the repository's owner and name, and the pull request's number, the workflow run's id, or the branch's or commit's name. With gh, the deck runs gh api and gh makes the request, to the host of your remote or of the address you gave, with the sign-in gh holds. Without gh, the deck itself requests https://api.github.com, and no other host, with GH_TOKEN or GITHUB_TOKEN as the request's authorization where one is set. /deck unwatch, /deck close, the checks' end or the session's end stops it; with the setting off it never starts. GitHub's own policy covers what it keeps of a request.
What reaches Claude. What /deck answers is a row of the conversation, as any command's output is: one fixed sentence saying what the command did. No title of a shell, an agent or a step is in it. Out of the box the mod gives Claude no tool. With Tool for Claude on, it lists two, and what they answer goes into the conversation the same way: plan answers the steps Claude itself sent, each with its id, and step the step's id and how many are done.
With GitHub checks on, what /deck watch answers names what is shown, with the pull request's or the workflow run's title, how many checks are over, and the page's address; an error of gh or of GitHub is answered as it came, cut to its first line. The watch tool answers the same to Claude. For a watch with wake, the prompt the deck submits carries the run's title, how many checks passed and failed, the failed checks' names and the page's address.
What it keeps. On disk, in the plugin's own Claude Code store, one JSON file under ~/.claude/plugins/store/: whether you closed the deck with /deck close, and whether you closed the label's row. Everything else, the rows' titles and times, the runs and their steps, what you folded, the model and the effort you set, is in the session's memory and gone with it.
Sound. None.
Files and processes. It reads no files and writes none. Out of the box it runs no processes. With GitHub checks on it runs two commands, each by its arguments with no shell: gh api --hostname <host> <path> for each question to GitHub, and git rev-parse --abbrev-ref HEAD on
hooks/register.tsx 1453 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Job, JobStatus, Meter, Run, Watch } from '../types'
5
6import {
7 GITHUB,
8 MAX_WATCHES,
9 POLL_ANON_MS,
10 POLL_MS,
11 checksOf,
12 givenUp,
13 homeOf,
14 isSame,
15 pathsOf,
16 polled,
17 pullPath,
18 pullTitle,
19 startText,
20 summaryOf,
21 targetOf,
22 watched,
23} from './checks'
24import type { Found } from './checks'
25import {
26 LEVELS,
27 NO_METER,
28 canStep,
29 effortColor,
30 effortOf,
31 effortText,
32 held,
33 isSameMeter,
34 modelName,
35 seen,
36 stepped,
37} from './meter'
38import { beside, besideCells, fittedLabel, groupTree, labelRow, paneTree } from './pane'
39import type { CallRow, LabelView } from './pane'
40import {
41 RUN_TITLE_CELLS,
42 activeOf,
43 isLive,
44 keptFolds,
45 newsOf,
46 opened,
47 stopped,
48 stoppedAll,
49 swept,
50 leavesOf,
51 planOf,
52 planText,
53 planned,
54 replanned,
55 rowsOf,
56 runCount,
57 runOf,
58 runText,
59 statusOf as statusOfSteps,
60 stepped as stepTurned,
61 taskCreated,
62 taskUpdated,
63} from './runs'
64import { NOTE, NOTE_SECTION, TOOLS, WATCH_NOTE, WATCH_SECTION, WATCH_TOOL, WORDS } from './tools'
65import { isRecord, toText } from './values'
66import { GLYPHS, jobSpan, labelText, spanText } from './view'
67import {
68 NO_WORK,
69 agentTitle,
70 asked as askedAbout,
71 backgrounded,
72 begun,
73 cleared,
74 dropped,
75 ended,
76 endedAll,
77 reported,
78 revived,
79 settled,
80 shellDetail,
81 shellTitle,
82 started,
83 statusOf,
84 timed,
85 tuned,
86} from './work'
87
88const PANE = 'deck'
89// The size asked for: beside the conversation 52 cells across, above the
90// prompt 14 rows. A size the person dragged it to stands over both.
91const PANE_OPEN = { id: PANE, title: 'Deck', columns: 52, rows: 14 } as const
92/** The store's one key: `/deck close` for every session. */
93const CLOSED = 'closed'
94/** The store's other key: the label's row closed with its `×`, for every session. */
95const ROW_CLOSED = 'row-closed'
96const TICK_MS = 1000
97/** The store is read for another session's `/deck close` every this many ticks. */
98const FOLLOW_TICKS = 2
99const CLOSE_WORDS = ['close', 'exit', 'quit']
100const USAGE =
101 'Usage: /deck (opens or closes the pane) · /deck open · /deck clear (takes what is over out of the pane) · /deck row (the label as a row, or as text) · /deck watch [a pull request, a workflow run, a branch or a commit] (follows its GitHub checks) · /deck unwatch · /deck close (takes the deck away in every session)'
102const CHECKS_OFF =
103 'GitHub checks are off. Turn on "GitHub checks" in /config, under the plugin\'s name, to follow them in the deck.'
104/** How long one question to GitHub may take. */
105const ASK_MS = 20 * 1000
106/** `gh` ends with this where nobody is signed in to the host. */
107const GH_NO_AUTH = 4
108const ERROR_CHARS = 160
109/** What is over leaves the pane by itself after this long. */
110const STALE_MS = 30 * 60 * 1000
111const SWEEP_TICKS = 60
112/** A background job's end is told only when it ran this long, or failed. */
113const TOLD_MS = 30 * 1000
114const work = atom({ plugin: 'deck', key: 'work' } as const, NO_WORK)
115const meter = atom({ plugin: 'deck', key: 'meter' } as const, NO_METER)
116const now = atom({ plugin: 'deck', key: 'now' } as const, 0)
117const switches = atom(
118 { plugin: 'deck', key: 'switches' } as const,
119 { isClosed: false, isRowClosed: false },
120)
121const NO_RUNS: Run[] = []
122const NO_FOLDS: Record<string, boolean> = {}
123const runs = atom({ plugin: 'deck', key: 'runs' } as const, NO_RUNS)
124const folds = atom({ plugin: 'deck', key: 'folds' } as const, NO_FOLDS)
125const NO_WATCHES: Watch[] = []
126const watches = atom({ plugin: 'deck', key: 'watches' } as const, NO_WATCHES)
127const NO_SPANS: Record<string, number> = {}
128const spans = atom({ plugin: 'deck', key: 'spans' } as const, NO_SPANS)
129const docked = atom({ plugin: 'deck', key: 'docked' } as const, 0)
130/** A docked pane's frame around its body, and the cell between it and the conversation. */
131const DOCK_FRAME_CELLS = 3
132/** A shell Claude Code draws its ctrl+b hint under has run this long. */
133const HINT_MS = 2000
134/** How many shell commands' lengths are kept for the transcript's rows. */
135const SPANS_KEPT = 60
136
137type Label = 'button' | 'text' | 'off'
138
139const labelOf = (options: Readonly<Record<string, unknown>>): Label =>
140 (['button', 'text', 'off'] as const).find((place) => place === options.hintLabel) ?? 'button'
141
142const isClosed = async ($: EngineInterface): Promise<boolean> =>
143 (await read($, switches)).isClosed
144
145/** True while the pane is drawn. One that waits undrawn, opened where it had no room, is not up. */
146const isPaneUp = async ($: EngineInterface): Promise<boolean> =>
147 (await $.ui.panes().catch(() => [])).some((pane) => pane.id === PANE && pane.isPlaced)
148
149const PANE_OPENED = 'Deck pane opened.'
150const PANE_CLOSED = 'Deck pane closed.'
151
152/**
153 * Opens the pane. Resolves what to tell the person: that it is open, or why
154 * Claude Code keeps it waiting undrawn.
155 */
156const paneOpened = async ($: EngineInterface): Promise<string> => {
157 const answer = await $.ui.open(PANE_OPEN)
158
159 return answer.isPlaced ? PANE_OPENED : `Deck's pane waits: ${answer.reason}`
160}
161
162/** The meter with the effort handed back to Claude Code. */
163const unheld = (kept: Meter): Meter => held(kept, '')
164
165/**
166 * What closing takes away in this session, whichever session closed it: the
167 * pane, the effort the deck set and what it follows on GitHub. What runs
168 * ends where it stands: the deck reads nothing while it is closed, so a job
169 * or a run left running would run on in the pane when it came back.
170 */
171const shutDown = async ($: EngineInterface): Promise<void> => {
172 await $.ui.close({ id: PANE }).catch(() => undefined)
173 await update($, meter, unheld)
174 await update($, watches, () => [])
175 const at = await $.clock.now()
176 await update($, work, (kept) => endedAll(kept, at))
177 await update($, runs, (kept) => stoppedAll(kept, at))
178}
179
180/**
181 * Keeps `/deck close` or its undoing for every session and takes it up here
182 * at once.
183 */
184const closedAs = async ($: EngineInterface, isOff: boolean): Promise<void> => {
185 await switched($, CLOSED, isOff)
186 await update($, switches, (kept) => ({ ...kept, isClosed: isOff }))
187
188 if (isOff) {
189 await shutDown($)
190 }
191}
192
193/**
194 * Whether a switch of every session's is on. Read every two seconds in every
195 * session, so the store is listed first and a key read only where it is set:
196 * one read of the store's file where there were two. A key an older version
197 * left `false` is taken away on the way.
198 */
199const isSet = async ($: EngineInterface, keys: readonly string[], key: string): Promise<boolean> => {
200 if (!keys.includes(key)) {
201 return false
202 }
203
204 const isOn = (await $.store.get(key)) === true
205
206 if (!isOn) {
207 await $.store.delete(key).catch(() => undefined)
208 }
209
210 return isOn
211}
212
213/** Sets a switch of every session's: kept while on, taken away when off. */
214const switched = async ($: EngineInterface, key: string, isOn: boolean): Promise<void> => {
215 await (isOn ? $.store.set(key, true) : $.store.delete(key))
216}
217
218/** Takes up a `/deck close` another session made, or its undoing. */
219const followed = async ($: EngineInterface): Promise<void> => {
220 const keys = await $.store.keys()
221 const isOff = await isSet($, keys, CLOSED)
222 const isRowOff = await isSet($, keys, ROW_CLOSED)
223 const before = await read($, switches)
224
225 if (isOff === before.isClosed && isRowOff === before.isRowClosed) {
226 return
227 }
228
229 await update($, switches, () => ({ isClosed: isOff, isRowClosed: isRowOff }))
230
231 if (isOff && !before.isClosed) {
232 await shutDown($)
233 }
234}
235
236/**
237 * The label's row closed or opened again, for every session: closed, the
238 * label is text at the end of the hint line.
239 */
240const rowClosedAs = async ($: EngineInterface, isOff: boolean): Promise<void> => {
241 await switched($, ROW_CLOSED, isOff)
242 await update($, switches, (kept) => ({ ...kept, isRowClosed: isOff }))
243}
244
245/** Opens the pane, or closes the open one. Resolves what to tell the person. */
246const toggled = async ($: EngineInterface): Promise<string> => {
247 if (await isPaneUp($)) {
248 await $.ui.close({ id: PANE })
249
250 return PANE_CLOSED
251 }
252
253 return paneOpened($)
254}
255
256/**
257 * A press that opens or closes the pane says nothing but where the pane
258 * waits, so a press that drew nothing is not silent.
259 *
260 * A Button hands the promise back to the press: Claude Code draws a pane at
261 * any width only while the person's press is answered, and counts the
262 * press as answered until its handler's promise settles. A press that
263 * returned at once and opened the pane after an await (`toggled` asks
264 * whether it is up first) made an open of the deck's own, which waits
265 * undrawn below 144 columns.
266 */
267const pressed = async ($: EngineInterface, said: Promise<string>): Promise<void> => {
268 const text = await said
269
270 if (text !== PANE_OPENED && text !== PANE_CLOSED) {
271 $.ui.toast(text)
272 }
273}
274
275/** A press on a transcript row: the pane opens with that command's row open. */
276const shown = async ($: EngineInterface, id: string): Promise<void> => {
277 const opening = pressed($, paneOpened($))
278 await update($, folds, (kept) => ({ ...kept, [`job:${id}`]: true }))
279 await opening
280}
281
282/** What Claude Code measured of the session: the context's fill, the cost, the rate limits. */
283type Measure = {
284 context: { percent?: number }
285 cost?: { usd: number }
286 rateLimits: readonly { kind: string; percentUsed: number }[]
287}
288
289/** Takes up the session's figures as Claude Code reports them. */
290const measured = async ($: EngineInterface, usage: Measure): Promise<void> => {
291 const before = await read($, meter)
292 const after = {
293 ...before,
294 context: usage.context.percent ?? before.context,
295 cost: usage.cost?.usd ?? -1,
296 limits: usage.rateLimits.map((limit) => ({ kind: limit.kind, percent: limit.percentUsed })),
297 }
298
299 if (!isSameMeter(before, after)) {
300 await update($, meter, (kept) => ({
301 ...kept,
302 context: after.context,
303 cost: after.cost,
304 limits: after.limits,
305 }))
306 }
307}
308
309/**
310 * The main thread's model as `/model` left it, so the label follows a change
311 * at once and not with the next request. A model that takes another name
312 * starts with its effort unknown: the old one's level is not carried over.
313 */
314const named = async ($: EngineInterface): Promise<void> => {
315 const model = await $.session.model().catch(() => '')
316 const before = await read($, meter)
317
318 if (model !== '' && modelName(model) !== modelName(before.model)) {
319 await update($, meter, (kept) => ({ ...kept, model, effort: '', isSeen: false, wanted: '', over: '' }))
320 }
321}
322
323/** The folds of what is no longer shown go, so a run that takes a freed id starts with none. */
324const foldsPruned = async ($: EngineInterface): Promise<void> => {
325 const shown = await read($, runs)
326 const jobs = await read($, work)
327 const ids = [...jobs.running, ...jobs.recent].map((job) => job.id)
328 const before = await read($, folds)
329
330 if (Object.keys(keptFolds(before, shown, ids)).length !== Object.keys(before).length) {
331 await update($, folds, (kept) => keptFolds(kept, shown, ids))
332 }
333}
334
335/** `/deck clear` and the pane's button: what is over leaves the pane. */
336const sweptAway = async ($: EngineInterface, before?: number): Promise<void> => {
337 const jobs = await read($, work)
338 const all = await read($, runs)
339
340 if (cleared(jobs, before).recent.length !== jobs.recent.length) {
341 await update($, work, (kept) => cleared(kept, before))
342 }
343
344 if (swept(all, before).length !== all.length) {
345 await update($, runs, (kept) => swept(kept, before))
346 }
347
348 await foldsPruned($)
349}
350
351/** The name a run a subagent opened carries: the agent's own, else its type. */
352const ownerOf = async ($: EngineInterface, agentId: string | undefined): Promise<string> => {
353 if (agentId === undefined) {
354 return ''
355 }
356
357 const agent = (await $.agent.list().catch(() => [])).find((one) => one.id === agentId)
358
359 return agent?.name ?? agent?.type ?? 'agent'
360}
361
362/** The runs open first, the newest touched at the top, then the finished. */
363const ordered = (all: readonly Run[]): Run[] =>
364 [...all].sort(
365 (one, other) =>
366 Number(!isLive(one)) - Number(!isLive(other)) ||
367 other.touchedAt - one.touchedAt,
368 )
369
370const STATUS_BY_REASON: Readonly<Record<string, JobStatus>> = {
371 answer: 'done',
372 aborted: 'killed',
373 refusal: 'failed',
374 error: 'failed',
375}
376
377/**
378 * What this session's module holds between events, lost at a reload: the
379 * tick's count, a count for calls with no id, and how each background task
380 * ended by its notification's own row, which is read while that row is
381 * drawn, where nothing may be written, and taken up by the next tick.
382 */
383type Session = {
384 ticks: number
385 minted: number
386 lengths: Map<string, { status: string; ms: number }>
387 /** When Claude Code drew its ctrl+b hint under a shell, by its call's id: that shell runs. */
388 hints: Map<string, number>
389 /** The cells the pane took from the screen as it was last drawn docked; 0 drawn inline. */
390 dockCells: number
391 hasTool: boolean
392 hasToasts: boolean
393 hasRows: boolean
394 hasChecks: boolean
395 /** How GitHub is asked: by `gh`, or directly where the first try found no `gh` signed in. */
396 via: 'untried' | 'gh' | 'http'
397 /** True where GitHub is asked directly with no token, and answers few requests an hour. */
398 isAnon: boolean
399 /** When the watches were last polled, and whether a poll is under way. */
400 polledAt: number
401 isPolling: boolean
402}
403
404/** A toast, where the person left them on. */
405const told = ($: EngineInterface, session: Session, text: string): void => {
406 if (session.hasToasts) {
407 $.ui.toast(text)
408 }
409}
410
411/** Changes the runs, and tells the person of a run that finished or a step that failed. */
412const moved = async (
413 $: EngineInterface,
414 session: Session,
415 change: (kept: Run[]) => Run[],
416): Promise<void> => {
417 const before = await read($, runs)
418 const after = await update($, runs, change)
419
420 for (const line of newsOf(before, after)) {
421 told($, session, line)
422 }
423}
424
425/** The first free id for a new run: `r1`, `r2`. */
426const freeId = (all: readonly Run[], at: number): string => {
427 const taken = all.map((one) => one.id)
428 const free = Array.from({ length: taken.length + 1 }, (_, index) => `r${index + 1}`)
429
430 return free.find((id) => !taken.includes(id)) ?? `r${at}`
431}
432
433/** What GitHub answered to one path of its API, or why there is no answer. */
434type Answer = { data: unknown; error?: undefined } | { error: string; data?: undefined }
435
436const parsed = (text: string): Answer => {
437 try {
438 return { data: JSON.parse(text) as unknown }
439 } catch {
440 return { error: 'GitHub answered something that is not JSON.' }
441 }
442}
443
444const errorOf = (text: string): string =>
445 (text.trim().split('\n').find((line) => line.trim() !== '') ?? '').slice(0, ERROR_CHARS)
446
447/**
448 * Asks GitHub's API for one path, read only. By `gh api` where the machine
449 * has `gh` signed in, so the mod holds no token. Else directly, and only
450 * `github.com`: with the token `GH_TOKEN` or `GITHUB_TOKEN` names where one
451 * is set, with none for a public repository.
452 */
453const asked = async ($: EngineInterface, session: Session, host: string, path: string): Promise<Answer> => {
454 if (session.via !== 'http') {
455 const ran = await $.process
456 .run(['gh', 'api', '--hostname', host, path], { timeoutMs: ASK_MS })
457 .catch(() => undefined)
458 const isMissing = ran === undefined || ran.exitCode === GH_NO_AUTH
459
460 if (ran !== undefined && ran.exitCode === 0) {
461 session.via = 'gh'
462
463 return parsed(ran.stdout)
464 }
465
466 if (!isMissing || session.via === 'gh') {
467 return { error: errorOf(ran?.stderr ?? '') || 'gh did not answer.' }
468 }
469
470 session.via = 'http'
471 }
472
473 if (host !== GITHUB) {
474 return { error: `Checks on ${host} need the gh command, signed in to it.` }
475 }
476
477 const token = (await $.env.get('GH_TOKEN')) ?? (await $.env.get('GITHUB_TOKEN')) ?? ''
478 session.isAnon = token === ''
479 const answer = await $.http
480 .fetch(`https://api.github.com/${path}`, {
481 headers: {
482 accept: 'application/vnd.github+json',
483 ...(token === '' ? {} : { authorization: `Bearer ${token}` }),
484 },
485 })
486 .catch(() => undefined)
487
488 if (answer === undefined) {
489 return { error: 'GitHub could not be reached.' }
490 }
491
492 if (!answer.ok) {
493 return {
494 error: `GitHub answered ${answer.status}.${token === '' ? ' With no gh signed in and no GH_TOKEN, only a public repository answers.' : ''}`,
495 }
496 }
497
498 return parsed(answer.text)
499}
500
501/** One poll of what a watch follows: its checks, or why GitHub gave none. */
502const looked = async (
503 $: EngineInterface,
504 session: Session,
505 watch: Pick<Watch, 'kind' | 'host' | 'repo' | 'target'>,
506): Promise<Found | { error: string }> => {
507 const [first, second] = pathsOf(watch)
508 const one = await asked($, session, watch.host, first)
509
510 if (one.error !== undefined) {
511 return { error: one.error }
512 }
513
514 const other = await asked($, session, watch.host, second)
515
516 return other.error === undefined ? checksOf(watch.kind, one.data, other.data) : { error: other.error }
517}
518
519/**
520 * Starts following what the words name, for `/deck watch` and the watch
521 * tool: asks GitHub once, shows the answer as a run, and keeps asking while
522 * a check is left. Resolves what to answer, and whether nothing was shown.
523 */
524const watching = async (
525 $: EngineInterface,
526 session: Session,
527 words: string,
528 wake: boolean,
529): Promise<{ text: string; isRefused: boolean }> => {
530 const home = homeOf((await $.session.repo().catch(() => null))?.remote)
531 // With no words, the branch the session is on.
532 const branch =
533 words.trim() === ''
534 ? ((await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD']).catch(() => undefined))?.stdout ?? '')
535 : ''
536 const target = targetOf(words, home, branch)
537
538 if ('error' in target) {
539 return { text: target.error, isRefused: true }
540 }
541
542 const all = await read($, watches)
543 const twin = all.find((one) => isSame(one, target))
544
545 if (twin !== undefined) {
546 await update($, watches, (kept) =>
547 kept.map((one) => (one.run === twin.run ? { ...one, wake: one.wake || wake } : one)),
548 )
549
550 return {
551 text: `It is shown in the Deck already.${wake ? ' A message will tell you when its checks are over.' : ''}`,
552 isRefused: false,
553 }
554 }
555
556 if (all.length >= MAX_WATCHES) {
557 return { text: `The Deck follows ${MAX_WATCHES} at a time, and it does now.`, isRefused: true }
558 }
559
560 const found = await looked($, session, target)
561
562 if ('error' in found) {
563 return { text: `Nothing is shown: ${found.error}`, isRefused: true }
564 }
565
566 const pull = target.pull > 0 ? await asked($, session, target.host, pullPath(target)) : undefined
567 const at = await $.clock.now()
568 await foldsPruned($)
569 const id = freeId(await read($, runs), at)
570 const title = pull?.data === undefined ? target.title : pullTitle(target, pull.data)
571 const run = watched(runOf(id, title, 'checks', `watch:${id}`, '', [], at), found, at)
572 const next = polled(
573 { run: id, kind: target.kind, host: target.host, repo: target.repo, target: target.target, url: target.url, wake, startedAt: at, settled: 0, failures: 0 },
574 found,
575 )
576 await update($, runs, (kept) => opened(kept, run))
577
578 if (next === undefined) {
579 return { text: summaryOf(run, target.url), isRefused: false }
580 }
581
582 session.polledAt = at
583 await update($, watches, (kept) => [...kept, next])
584
585 return { text: startText(run, target.url, wake), isRefused: false }
586}
587
588/** Every watch ends here: its run stops where it stands. */
589const unwatched = async ($: EngineInterface): Promise<number> => {
590 const all = await read($, watches)
591 const at = await $.clock.now()
592 await update($, watches, () => [])
593 await update($, runs, (kept) =>
594 kept.map((run) =>
595 all.some((watch) => watch.run === run.id) && isLive(run) ? { ...run, stoppedAt: at, touchedAt: at } : run,
596 ),
597 )
598
599 return all.length
600}
601
602/**
603 * One poll of every watch. A watch whose row was cleared away ends; one
604 * whose checks are over ends, and tells Claude where Claude asked; one that
605 * cannot go on is given up, its run stopped where it stands.
606 */
607const polledAll = async ($: EngineInterface, session: Session): Promise<void> => {
608 for (const watch of await read($, watches)) {
609 const run = (await read($, runs)).find((one) => one.id === watch.run)
610 const found = run === undefined ? undefined : await looked($, session, watch)
611 const at = await $.clock.now()
612
613 if (run === undefined || found === undefined) {
614 await update($, watches, (kept) => kept.filter((one) => one.run !== watch.run))
615 continue
616 }
617
618 const isAnswered = !('error' in found)
619 const current = isAnswered ? watched(run, found, at) : run
620 const next = isAnswered ? polled(watch, found) : { ...watch, failures: watch.failures + 1 }
621 const why = next === undefined ? undefined : givenUp(next, current, at)
622
623 if (isAnswered) {
624 await moved($, session, (kept) => kept.map((one) => (one.id === watch.run ? watched(one, found, at) : one)))
625 }
626
627 if (next !== undefined && why === undefined) {
628 await update($, watches, (kept) => kept.map((one) => (one.run === watch.run ? { ...next, wake: one.wake } : one)))
629 continue
630 }
631
632 const wake = (await read($, watches)).find((one) => one.run === watch.run)?.wake ?? watch.wake
633 await update($, watches, (kept) => kept.filter((one) => one.run !== watch.run))
634
635 if (why !== undefined) {
636 await update($, runs, (kept) =>
637 kept.map((one) => (one.id === watch.run && isLive(one) ? { ...one, stoppedAt: at, touchedAt: at } : one)),
638 )
639 told($, session, `${GLYPHS.killed} ${current.title}: ${why}`)
640 }
641
642 // A deck closed meanwhile wakes nobody.
643 if (wake && !(await isClosed($))) {
644 await $.prompt.submit({ text: summaryOf(current, watch.url, why) }).catch(() => undefined)
645 }
646 }
647}
648
649/**
650 * A press on an opened row's stop button: asks Claude Code to stop that
651 * background task, as its own TaskStop tool does, in the person's name.
652 */
653const halted = async ($: EngineInterface, job: Job): Promise<void> => {
654 const answer = await $.tool
655 .call({
656 tool: 'TaskStop',
657 task_id: job.taskId,
658 consent: `The user pressed "stop" on "${job.title}" in the Deck pane.`,
659 })
660 .catch(() => undefined)
661
662 if (answer !== undefined && answer.deny === undefined && answer.isError !== true) {
663 const at = await $.clock.now()
664 await update($, work, (kept) => reported(kept, { taskId: job.taskId, status: 'killed' }, at))
665 }
666}
667
668/**
669 * Once a second: every other time, another session's `/deck close`; then
670 * what the notification rows said; then the clock for the open pane, while
671 * something runs.
672 */
673const ticked = async ($: EngineInterface, session: Session): Promise<void> => {
674 session.ticks += 1
675
676 if (session.ticks % FOLLOW_TICKS === 0) {
677 await followed($)
678
679 if (!(await isClosed($))) {
680 await named($)
681 }
682 }
683
684 if (session.lengths.size > 0) {
685 const at = await $.clock.now()
686 const rows = [...session.lengths]
687 session.lengths.clear()
688
689 // A background job that ran long, or failed, is told as it ends.
690 for (const job of (await read($, work)).running) {
691 const row = rows.find(([taskId]) => taskId === job.taskId)
692 const status = row === undefined ? undefined : statusOf(row[1].status)
693 const isWorth = status !== undefined && (status !== 'done' || at - job.startedAt >= TOLD_MS)
694
695 if (isWorth && job.kind === 'shell') {
696 told($, session, `${GLYPHS[status]} ${job.title} · ${jobSpan({ ...job, endedAt: at }, at)}`)
697 }
698 }
699
700 await update($, work, (kept) =>
701 rows.reduce(
702 (left, [taskId, row]) =>
703 timed(
704 reported(left, { taskId, status: statusOf(row.status) }, at),
705 taskId,
706 row.ms,
707 ),
708 kept,
709 ),
710 )
711 }
712
713 if (session.ticks % SWEEP_TICKS === 0) {
714 await sweptAway($, (await $.clock.now()) - STALE_MS)
715 }
716
717 // A shell Claude Code drew its ctrl+b hint under runs: one the person was
718 // asked about started its clock that long before.
719 if (session.hints.size > 0) {
720 const hints = [...session.hints]
721 session.hints.clear()
722 await update($, work, (kept) => hints.reduce((left, [id, at]) => begun(left, id, at - HINT_MS), kept))
723 }
724
725 const jobs = await read($, work)
726 const isBusy = jobs.running.length > 0 || (await read($, runs)).some(isLive)
727 const isUp = await isPaneUp($)
728
729 // The clock moves the open pane's times, and the transcript's rows of a
730 // shell that runs, which show while the pane is closed.
731 if ((isBusy && isUp) || (session.hasRows && jobs.running.some((job) => job.kind === 'shell'))) {
732 const at = await $.clock.now()
733 await update($, now, () => at)
734 }
735
736 // What the docked pane takes from the screen: the label and the transcript's
737 // rows fit what is left.
738 const cells = isUp ? session.dockCells : 0
739
740 if (cells !== (await read($, docked))) {
741 await update($, docked, () => cells)
742 }
743
744 // Last, so a slow answer of GitHub holds nothing above: what is watched is
745 // polled, one poll at a time, and nothing while the deck is closed.
746 if (
747 session.hasChecks &&
748 !session.isPolling &&
749 (await read($, watches)).length > 0 &&
750 !(await isClosed($))
751 ) {
752 const at = await $.clock.now()
753
754 if (at - session.polledAt >= (session.isAnon ? POLL_ANON_MS : POLL_MS)) {
755 session.polledAt = at
756 session.isPolling = true
757 await polledAll($, session).finally(() => {
758 session.isPolling = false
759 })
760 }
761 }
762}
763
764export const register: Register = (on, options) => {
765 const place = labelOf(options)
766 const session: Session = {
767 ticks: 0,
768 minted: 0,
769 lengths: new Map(),
770 hints: new Map(),
771 dockCells: 0,
772 hasTool: options.modelTool === true,
773 hasToasts: options.toasts !== false,
774 hasRows: options.transcriptRows !== false,
775 hasChecks: options.github === true,
776 via: 'untried',
777 isAnon: false,
778 polledAt: 0,
779 isPolling: false,
780 }
781 // What tells an agent of the mod's tools, by the settings that are on.
782 const notes = [
783 ...(session.hasTool ? [{ id: NOTE_SECTION, text: NOTE }] : []),
784 ...(session.hasChecks ? [{ id: WATCH_SECTION, text: WATCH_NOTE }] : []),
785 ]
786
787 on('session.start', async ($, e, next) => {
788 await $.command.register({
789 name: 'deck',
790 description: 'A pane for the work behind the conversation: shells, agents, the model and its effort',
791 argumentHint: '[open|clear|row|watch|unwatch|close]',
792 })
793 await followed($)
794
795 if (session.hasTool) {
796 for (const tool of TOOLS) {
797 await $.tool.register(tool)
798 }
799 }
800
801 if (session.hasChecks) {
802 await $.tool.register(WATCH_TOOL)
803 }
804
805 const usage = await $.session.usage().catch(() => undefined)
806
807 if (usage !== undefined) {
808 await measured($, usage)
809 }
810
811 await named($)
812
813 $.clock.every(TICK_MS, () => {
814 void ticked($, session).catch(() => undefined)
815 })
816
817 return next(e)
818 })
819
820 // Claude Code's own measure of the session, as it moves: after each turn of
821 // the main thread, and when a rate limit moves a point. Read, and passed on.
822 on('session.measure', async ($, e, next) => {
823 if (!(await isClosed($))) {
824 await measured($, e)
825 }
826
827 return next(e)
828 })
829
830 // `/clear` ends the conversation the rows belong to: what is over and every
831 // run go with it, and what still runs stays.
832 on('session.end', async ($, e, next) => {
833 if (e.reason === 'clear') {
834 await update($, work, (kept) => cleared(kept))
835 await update($, runs, () => [])
836 await update($, folds, () => ({}))
837 await update($, watches, () => [])
838 }
839
840 return next(e)
841 })
842
843 on('command.run', { command: 'deck' }, async ($, e) => {
844 const [word = '', ...rest] = e.args.trim().split(/\s+/)
845 const first = word.toLowerCase()
846
847 // `/deck watch`, with what to follow or with nothing for the branch the
848 // session is on. Its answer names what is shown.
849 if (first === 'watch' && rest.length <= 1) {
850 if (!session.hasChecks) {
851 return { text: CHECKS_OFF }
852 }
853
854 if (await isClosed($)) {
855 return { text: 'Deck is closed. /deck brings it back.' }
856 }
857
858 const answer = await watching($, session, rest[0] ?? '', false)
859
860 if (answer.isRefused) {
861 return { text: answer.text }
862 }
863
864 const opened = await paneOpened($)
865
866 return { text: opened === PANE_OPENED ? answer.text : `${answer.text} ${opened}` }
867 }
868
869 if (rest.length > 0) {
870 return { text: USAGE }
871 }
872
873 if (first === 'unwatch') {
874 const count = session.hasChecks ? await unwatched($) : 0
875
876 return {
877 text: count === 0 ? 'Deck follows nothing on GitHub.' : 'Deck stopped following GitHub: the runs stay where they stood.',
878 }
879 }
880
881 if (CLOSE_WORDS.includes(first)) {
882 await closedAs($, true)
883
884 return {
885 text: 'Deck is closed in every session: its label and its pane are away, it reads nothing and it leaves the effort to Claude Code. /deck brings it back.',
886 }
887 }
888
889 if (first === 'row') {
890 const isOff = !(await read($, switches)).isRowClosed
891 await rowClosedAs($, isOff)
892
893 return {
894 text: isOff
895 ? 'Deck keeps its label as text on the hint line. /deck row brings its row back.'
896 : 'Deck draws its label on a row under the hint line, where there is a pointer.',
897 }
898 }
899
900 if (first === 'clear') {
901 await sweptAway($)
902
903 return { text: 'Deck is cleared: what is over is out of the pane. What still runs stays.' }
904 }
905
906 if (first !== '' && first !== 'open') {
907 return { text: USAGE }
908 }
909
910 if (await isClosed($)) {
911 await closedAs($, false)
912 const opened = await paneOpened($)
913
914 return { text: opened === PANE_OPENED ? 'Deck is back, and its pane is open.' : `Deck is back. ${opened}` }
915 }
916
917 return { text: first === 'open' ? await paneOpened($) : await toggled($) }
918 })
919
920 // A shell command, from its start to its end. Of the call it reads what it
921 // says it does (`description`), or the command where it says nothing, and
922 // keeps that line in memory for the row; of the result, whether it failed
923 // and whether it went to the background. The call goes on as it came.
924 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
925 if (e.tool !== 'Bash' || (await isClosed($))) {
926 return next(e)
927 }
928
929 session.minted += 1
930 const id = e.tool_use_id ?? `deck-${session.minted}`
931 const startedAt = await $.clock.now()
932 await update($, work, (kept) =>
933 started(kept, {
934 id,
935 kind: 'shell',
936 title: shellTitle(e.description, e.command),
937 startedAt,
938 endedAt: 0,
939 status: 'running',
940 taskId: '',
941 detail: shellDetail(e.command),
942 model: '',
943 effort: '',
944 owner: e.agentId ?? '',
945 isAsking: false,
946 }),
947 )
948
949 const answer = await next(e).catch(async (failure: unknown) => {
950 const at = await $.clock.now()
951 await update($, work, (kept) => ended(kept, id, 'failed', at))
952
953 throw failure
954 })
955 const at = await $.clock.now()
956 // The clock starts once the person let it run, where they were asked.
957 const job = (await read($, work)).running.find((one) => one.id === id)
958 const from = job === undefined ? startedAt : job.isAsking ? at : job.startedAt
959
960 if (answer.deny !== undefined) {
961 await update($, work, (kept) => dropped(kept, id))
962 } else if (answer.isError === true) {
963 await update($, work, (kept) => ended(kept, id, 'failed', at))
964 } else if (answer.result.backgroundTaskId !== undefined) {
965 const taskId = answer.result.backgroundTaskId
966 await update($, work, (kept) => backgrounded(kept, id, taskId, at))
967 } else {
968 const status = answer.result.interrupted ? 'killed' : 'done'
969 await update($, work, (kept) => ended(kept, id, status, at))
970 }
971
972 if (session.hasRows) {
973 await update($, spans, (kept) =>
974 Object.fromEntries([...Object.entries(kept), [id, at - from]].slice(-SPANS_KEPT)),
975 )
976 }
977
978 return answer
979 })
980
981 // Claude Code asks the person whether a shell may run. The dialog names no
982 // call, so the shell is found by its command's first line, the one its row
983 // keeps, among those its loop runs; nothing else of the dialog is read.
984 // The row waits with no clock until the shell runs.
985 on('classic.PermissionRequest', async ($, e, next) => {
986 if (e.tool_name === 'Bash' && !(await isClosed($))) {
987 const input = isRecord(e.tool_input) ? e.tool_input : {}
988 const line = shellDetail(toText(input.command))
989 const owner = e.agent_id ?? ''
990 const job = (await read($, work)).running.findLast(
991 (one) => one.kind === 'shell' && !one.isAsking && one.taskId === '' && one.owner === owner && one.detail === line,
992 )
993
994 if (job !== undefined) {
995 await update($, work, (kept) => askedAbout(kept, job.id))
996 }
997 }
998
999 return next(e)
1000 })
1001
1002 // Claude Code draws its ctrl+b hint under a shell once it has run two
1003 // seconds: a shell the person was asked about runs since then. Only the
1004 // call's id is read, while the row is drawn, and taken up by the next tick.
1005 on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
1006 if (e.props.kind === 'background_hint' && !session.hints.has(e.props.tool_use_id)) {
1007 session.hints.set(e.props.tool_use_id, await $.clock.now())
1008 }
1009
1010 return next(e)
1011 })
1012
1013 // A background task stopped by Claude or the person: only the id of the
1014 // task that was stopped is read.
1015 on('tool.call', { tool: 'TaskStop' }, async ($, e, next) => {
1016 const answer = await next(e)
1017
1018 if (
1019 e.tool === 'TaskStop' &&
1020 answer.deny === undefined &&
1021 answer.isError !== true &&
1022 !(await isClosed($))
1023 ) {
1024 const taskId = toText(answer.result.task_id)
1025 const at = await $.clock.now()
1026 await update($, work, (kept) => reported(kept, { taskId, status: 'killed' }, at))
1027 }
1028
1029 return answer
1030 })
1031
1032 // Claude Code's own task list, watched: a task's id and subject as it is
1033 // made, and its new state as it changes. Both calls go on as they came.
1034 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
1035 const answer = await next(e)
1036
1037 if (
1038 e.tool === 'TaskCreate' &&
1039 answer.deny === undefined &&
1040 answer.isError !== true &&
1041 !(await isClosed($))
1042 ) {
1043 const { id, subject } = answer.result.task
1044 const loop = e.agentId ?? ''
1045 const owner = await ownerOf($, e.agentId)
1046 const at = await $.clock.now()
1047 await moved($, session, (kept) =>
1048 taskCreated(kept, loop, owner, id, subject, at, () => `tasks-${at}`),
1049 )
1050 }
1051
1052 return answer
1053 })
1054
1055 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
1056 const answer = await next(e)
1057
1058 if (
1059 e.tool === 'TaskUpdate' &&
1060 answer.deny === undefined &&
1061 answer.isError !== true &&
1062 answer.result.success &&
1063 !(await isClosed($))
1064 ) {
1065 const loop = e.agentId ?? ''
1066 const change = { status: e.status, subject: e.subject }
1067 const at = await $.clock.now()
1068 await moved($, session, (kept) => taskUpdated(kept, loop, e.taskId, change, at))
1069 }
1070
1071 return answer
1072 })
1073
1074 // The mod's own two tools, listed only with Tool for Claude on: a plan as
1075 // indented text opens a run, and a step changes one leaf of it.
1076 if (session.hasTool) {
1077 on('tool.call', { tool: 'mcp__deck__plan' }, async ($, e) => {
1078 if (await isClosed($)) {
1079 return { result: 'The person closed the deck: no plan is shown. Go on without it.' }
1080 }
1081
1082 const steps = planned(toText(e.steps))
1083
1084 if (steps.length === 0) {
1085 return { deny: 'The plan has no steps. Give one step a line.' }
1086 }
1087
1088 const at = await $.clock.now()
1089 await foldsPruned($)
1090 const made = runOf(
1091 freeId(await read($, runs), at),
1092 toText(e.title),
1093 'plan',
1094 e.agentId ?? '',
1095 await ownerOf($, e.agentId),
1096 steps,
1097 at,
1098 )
1099 // The first step starts with the plan, which saves the agent a call.
1100 const first = leavesOf(made)[0]?.id ?? ''
1101 const run = stepTurned(made, first, 'start', at).run ?? made
1102 await update($, runs, (kept) => replanned(kept, run))
1103
1104 return {
1105 result: `Run ${run.id} is shown, and step ${first} is started. Call step as you move to the next one.\n${planText(run)}`,
1106 }
1107 })
1108
1109 on('tool.call', { tool: 'mcp__deck__step' }, async ($, e) => {
1110 if (await isClosed($)) {
1111 return { result: 'The person closed the deck: no plan is shown. Go on without it.' }
1112 }
1113
1114 const word = WORDS.find((one) => one === toText(e.state).trim().toLowerCase())
1115 const run = planOf(await read($, runs), e.agentId ?? '', toText(e.run).trim())
1116
1117 if (word === undefined) {
1118 return { deny: `The state is one of: ${WORDS.join(', ')}.` }
1119 }
1120
1121 if (run === undefined) {
1122 return { deny: 'No plan of yours is open. Call plan first, or name its run.' }
1123 }
1124
1125 const id = toText(e.id).trim()
1126 const at = await $.clock.now()
1127 const turned = stepTurned(run, id, word, at)
1128
1129 if (turned.error !== undefined) {
1130 return { deny: turned.error }
1131 }
1132
1133 await moved($, session, (kept) =>
1134 kept.map((one) => {
1135 const again = one.id === run.id ? stepTurned(one, id, word, at) : undefined
1136
1137 return again?.run ?? one
1138 }),
1139 )
1140
1141 const leaves = leavesOf(turned.run)
1142
1143 return {
1144 result: `${id} ${word} (${leaves.filter((leaf) => leaf.status === 'done').length}/${leaves.length} done)`,
1145 }
1146 })
1147 }
1148
1149 // With GitHub checks on, one more tool: what it is given is followed on
1150 // GitHub and shown as a run.
1151 if (session.hasChecks) {
1152 on('tool.call', { tool: 'mcp__deck__watch' }, async ($, e) => {
1153 if (await isClosed($)) {
1154 return { result: 'The person closed the deck: nothing is shown. Wait for the checks yourself.' }
1155 }
1156
1157 const answer = await watching($, session, toText(e.target), e.wake === true)
1158
1159 return answer.isRefused ? { deny: answer.text } : { result: answer.text }
1160 })
1161 }
1162
1163 // With Tool for Claude or GitHub checks on, the main thread's system prompt
1164 // gains a section for each saying its tools are there; every other section
1165 // stays as it is.
1166 if (notes.length > 0) {
1167 on('prompt.compose', async ($, e, next) => {
1168 const answer = await next(e)
1169
1170 return (await isClosed($))
1171 ? answer
1172 : {
1173 sections: [
1174 ...answer.sections.filter((section) => !notes.some((note) => note.id === section.id)),
1175 ...notes.map((note) => ({ ...note, scope: 'session' as const })),
1176 ],
1177 }
1178 })
1179 }
1180
1181 // A subagent as it starts: its type and the few words its call names the
1182 // task with. Its task goes on as it came, but for the line below.
1183 on('agent.spawn', async ($, e, next) => {
1184 // With Tool for Claude or GitHub checks on, a line each at the end of the
1185 // subagent's task says the tools are there. A fork has the main thread's
1186 // prompt, which says so.
1187 const isTold = notes.length > 0 && !e.fork && !(await isClosed($))
1188 const said = notes.map((note) => note.text).join('\n\n')
1189 const answer = await next(isTold ? { ...e, prompt: `${e.prompt}\n\n${said}` } : e)
1190
1191 if (answer.agentId !== undefined && !(await isClosed($))) {
1192 const id = answer.agentId
1193 const startedAt = await $.clock.now()
1194 await update($, work, (kept) =>
1195 started(kept, {
1196 id,
1197 kind: 'agent',
1198 title: agentTitle(e.subagentType, e.description),
1199 startedAt,
1200 endedAt: 0,hooks/checks.ts 362 lines1// GitHub checks as a run: what a watch follows, the API's answers as checks,
2// and the checks as a run's steps. Plain functions over plain values; the
3// hooks module asks GitHub and calls them with what it answered.
4
5import type { Run, Step, StepStatus, Watch } from '../types'
6
7import { titleOf } from './runs'
8import { isRecord, toText } from './values'
9
10/** How often GitHub is asked while something is watched. */
11export const POLL_MS = 30 * 1000
12/** The same with no token: GitHub answers 60 requests an hour to a nameless caller. */
13export const POLL_ANON_MS = 150 * 1000
14/** A commit's checks are over once this many polls in a row found every one over: a later workflow may still add its own. */
15export const SETTLED_POLLS = 2
16/** A watch whose checks never showed up is given up after this long. */
17export const EMPTY_MS = 10 * 60 * 1000
18/** A watch none of whose checks moved for this long is given up: a job that waits on a runner or an approval may wait for good. */
19export const STALL_MS = 30 * 60 * 1000
20/** Any watch is given up after this long. */
21export const GIVE_UP_MS = 2 * 60 * 60 * 1000
22/** A watch is given up after this many polls in a row that GitHub did not answer. */
23export const MAX_FAILURES = 5
24export const MAX_WATCHES = 4
25
26/** The one host asked without `gh`: a token of the environment goes nowhere else. */
27export const GITHUB = 'github.com'
28
29/** A repository on a GitHub host: `owner/name`. */
30export type Home = { host: string; repo: string }
31
32/**
33 * The repository a git remote names: `https://github.com/o/r.git`,
34 * `git@github.com:o/r.git`, `ssh://git@github.com/o/r`.
35 */
36export const homeOf = (remote: string | null | undefined): Home | undefined => {
37 const found = /^(?:[a-z+]+:\/\/)?(?:[^@/]+@)?([^/:]+)(?::\d+)?[/:]([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/i.exec(
38 (remote ?? '').trim(),
39 )
40
41 return found === null ? undefined : { host: found[1] ?? '', repo: `${found[2]}/${found[3]}` }
42}
43
44/** What to watch, before GitHub was asked: where it is and how its row is named. */
45export type Target = Pick<Watch, 'kind' | 'host' | 'repo' | 'target' | 'url'> & {
46 title: string
47 /** The pull request's number, where it is one. */
48 pull: number
49}
50
51const PAGE = /^https?:\/\/([^/]+)\/([\w.-]+)\/([\w.-]+)\/(pull|actions\/runs|commit|tree)\/([^?#]+)/i
52/** A number this long is a workflow run's id; a shorter one is a pull request's. */
53const RUN_DIGITS = 8
54
55const pullOf = (home: Home, pull: number): Target => ({
56 kind: 'ref',
57 ...home,
58 target: `pull/${pull}/head`,
59 url: `https://${home.host}/${home.repo}/pull/${pull}`,
60 title: `PR #${pull}`,
61 pull,
62})
63
64const jobsOf = (home: Home, id: string): Target => ({
65 kind: 'run',
66 ...home,
67 target: id,
68 url: `https://${home.host}/${home.repo}/actions/runs/${id}`,
69 title: `Run ${id}`,
70 pull: 0,
71})
72
73const refOf = (home: Home, ref: string): Target => ({
74 kind: 'ref',
75 ...home,
76 target: ref,
77 url: `https://${home.host}/${home.repo}/commits/${ref}`,
78 title: `Checks · ${ref}`,
79 pull: 0,
80})
81
82/**
83 * What the words name: a page's address on GitHub (a pull request, a
84 * workflow run, a commit, a branch), `#12` or `12` for a pull request, a
85 * long number for a workflow run, anything else for a branch, a tag or a
86 * commit; nothing for the branch the session is on. Resolves why not, where
87 * the words name nothing that can be asked for.
88 */
89export const targetOf = (
90 words: string,
91 home: Home | undefined,
92 branch: string,
93): Target | { error: string } => {
94 const text = words.trim()
95 const page = PAGE.exec(text)
96
97 if (page !== null) {
98 const there = { host: page[1] ?? '', repo: `${page[2]}/${page[3]}` }
99 const rest = (page[5] ?? '').replace(/\/+$/, '')
100 const first = rest.split('/')[0] ?? ''
101
102 if (page[4] === 'pull') {
103 return /^\d+$/.test(first) ? pullOf(there, Number(first)) : { error: 'That address names no pull request.' }
104 }
105
106 if (page[4] === 'actions/runs') {
107 return /^\d+$/.test(first) ? jobsOf(there, first) : { error: 'That address names no workflow run.' }
108 }
109
110 return refOf(there, page[4] === 'commit' ? first : rest)
111 }
112
113 if (/^https?:\/\//i.test(text)) {
114 return { error: 'That address is no pull request, workflow run, commit or branch on GitHub.' }
115 }
116
117 if (home === undefined) {
118 return { error: 'This folder has no GitHub remote. Give the address of a pull request or a workflow run.' }
119 }
120
121 const number = /^#?(\d+)$/.exec(text)?.[1]
122
123 if (number !== undefined) {
124 return number.length >= RUN_DIGITS && !text.startsWith('#')
125 ? jobsOf(home, number)
126 : pullOf(home, Number(number))
127 }
128
129 const ref = text === '' ? branch.trim() : text
130
131 if (ref === '' || ref === 'HEAD' || /\s/.test(ref)) {
132 return { error: 'Name what to watch: a pull request, a workflow run, a branch or a commit.' }
133 }
134
135 return refOf(home, ref)
136}
137
138/** True where both follow the same thing. */
139export const isSame = (one: Pick<Watch, 'kind' | 'host' | 'repo' | 'target'>, other: typeof one): boolean =>
140 one.kind === other.kind && one.host === other.host && one.repo === other.repo && one.target === other.target
141
142/**
143 * The API's paths a poll asks, in the order `checksOf` takes their answers:
144 * a commit's check runs and its statuses, or a workflow run and its jobs.
145 */
146export const pathsOf = (watch: Pick<Watch, 'kind' | 'repo' | 'target'>): [string, string] =>
147 watch.kind === 'run'
148 ? [
149 `repos/${watch.repo}/actions/runs/${watch.target}`,
150 `repos/${watch.repo}/actions/runs/${watch.target}/jobs?per_page=100`,
151 ]
152 : [
153 `repos/${watch.repo}/commits/${encodeURIComponent(watch.target)}/check-runs?per_page=100`,
154 `repos/${watch.repo}/commits/${encodeURIComponent(watch.target)}/status?per_page=100`,
155 ]
156
157/** The path that names a pull request: its title is the row's. */
158export const pullPath = (target: Pick<Target, 'repo' | 'pull'>): string =>
159 `repos/${target.repo}/pulls/${target.pull}`
160
161/** `PR #12 · Fix the wallet`, from the pull request as the API answers it. */
162export const pullTitle = (target: Target, answer: unknown): string => {
163 const title = isRecord(answer) ? toText(answer.title) : ''
164
165 return title === '' ? target.title : `${target.title} · ${title}`
166}
167
168/** One check, or one job of a workflow run with its steps. */
169export type Check = {
170 title: string
171 status: StepStatus
172 startedAt: number
173 endedAt: number
174 steps: Check[]
175}
176
177const timeOf = (value: unknown): number => Date.parse(toText(value)) || 0
178
179const listOf = (value: unknown): Readonly<Record<string, unknown>>[] =>
180 Array.isArray(value) ? value.filter(isRecord) : []
181
182/** A check run's, a job's or a job's step's state, from its `status` and `conclusion`. */
183const stateOf = (status: string, conclusion: string): StepStatus => {
184 if (status !== 'completed') {
185 return status === 'in_progress' ? 'running' : 'pending'
186 }
187
188 return ['success', 'neutral', 'skipped'].includes(conclusion) ? 'done' : 'failed'
189}
190
191/** An end that is neither a pass nor a plain failure is said beside the name. */
192const namedOf = (name: string, status: string, conclusion: string): string =>
193 status === 'completed' && !['success', 'failure', ''].includes(conclusion)
194 ? `${name} (${conclusion.replace(/_/g, ' ')})`
195 : name
196
197const checkOf = (one: Readonly<Record<string, unknown>>): Check => {
198 const status = toText(one.status)
199 const conclusion = toText(one.conclusion)
200 const state = stateOf(status, conclusion)
201
202 return {
203 title: namedOf(toText(one.name) || 'check', status, conclusion),
204 status: state,
205 startedAt: state === 'pending' ? 0 : timeOf(one.started_at),
206 endedAt: status === 'completed' ? timeOf(one.completed_at) : 0,
207 steps: listOf(one.steps).map(checkOf),
208 }
209}
210
211const STATES: Readonly<Record<string, StepStatus>> = {
212 success: 'done',
213 failure: 'failed',
214 error: 'failed',
215}
216
217/** What a poll found: the checks, whether GitHub says no more will move, and the row's name where the answer has one. */
218export type Found = { checks: Check[]; isOver: boolean; title: string }
219
220/**
221 * A poll's two answers as checks. For a commit: its check runs, then the
222 * statuses other services set on it; over when there is one and each is.
223 * For a workflow run: its jobs, each with its steps; over when the run is.
224 */
225export const checksOf = (kind: Watch['kind'], first: unknown, second: unknown): Found => {
226 if (kind === 'run') {
227 const run = isRecord(first) ? first : {}
228 const name = toText(run.name)
229 const what = toText(run.display_title)
230
231 return {
232 checks: listOf(isRecord(second) ? second.jobs : undefined).map(checkOf),
233 isOver: toText(run.status) === 'completed',
234 title: name === '' || name === what ? what : `${name} · ${what}`,
235 }
236 }
237
238 const runs = listOf(isRecord(first) ? first.check_runs : undefined).map(checkOf)
239 const statuses = listOf(isRecord(second) ? second.statuses : undefined).map((one): Check => {
240 const status = STATES[toText(one.state)] ?? 'running'
241
242 return {
243 title: toText(one.context) || 'status',
244 status,
245 startedAt: timeOf(one.created_at),
246 endedAt: status === 'running' ? 0 : timeOf(one.updated_at),
247 steps: [],
248 }
249 })
250 // A job's steps are not a commit's checks.
251 const checks = [...runs, ...statuses].map((check) => ({ ...check, steps: [] }))
252
253 return { checks, isOver: checks.length > 0 && checks.every(isEnded), title: '' }
254}
255
256const isEnded = (check: Check): boolean => check.status === 'done' || check.status === 'failed'
257
258/** The row a watch shows before GitHub lists a check. */
259const WAITING = 'Waiting for checks'
260
261const stepsOf = (checks: readonly Check[], parent = ''): Step[] =>
262 checks.flatMap((check, index) => {
263 const id = parent === '' ? String(index + 1) : `${parent}.${index + 1}`
264 const { steps, ...own } = check
265
266 return [{ ...own, id, title: titleOf(check.title) || 'check' }, ...stepsOf(steps, id)]
267 })
268
269/**
270 * The run with what a poll found as its steps: a check a step, a job's steps
271 * under the job. It counts as touched only when something moved.
272 */
273export const watched = (run: Run, found: Found, at: number): Run => {
274 const steps =
275 found.checks.length === 0
276 ? [{ id: '1', title: WAITING, status: 'pending' as const, startedAt: 0, endedAt: 0 }]
277 : stepsOf(found.checks)
278 const title = found.title === '' ? run.title : titleOf(found.title)
279
280 return JSON.stringify([steps, title]) === JSON.stringify([run.steps, run.title])
281 ? run
282 : { ...run, steps, title, touchedAt: at }
283}
284
285/** True while the run shows no check yet. */
286export const isWaiting = (run: Run): boolean => run.steps.length === 1 && run.steps[0]?.title === WAITING
287
288/**
289 * The watch after a poll, or nothing once it is over: a workflow run when
290 * GitHub says so, a commit's checks when enough polls in a row found each
291 * one over.
292 */
293export const polled = (watch: Watch, found: Found): Watch | undefined => {
294 const settled = found.isOver ? watch.settled + 1 : 0
295 const isDone = found.isOver && (watch.kind === 'run' || settled >= SETTLED_POLLS)
296
297 return isDone ? undefined : { ...watch, settled, failures: 0 }
298}
299
300/** Why a watch is given up at this time, or nothing while it goes on. */
301export const givenUp = (watch: Watch, run: Run, at: number): string | undefined => {
302 if (watch.failures >= MAX_FAILURES) {
303 return 'GitHub did not answer'
304 }
305
306 if (at - watch.startedAt >= GIVE_UP_MS) {
307 return 'still not over after two hours'
308 }
309
310 if (isWaiting(run)) {
311 return at - watch.startedAt >= EMPTY_MS ? 'no check showed up' : undefined
312 }
313
314 // A check that runs holds the watch however long it takes, up to the two
315 // hours; a stall is checks that wait and none that runs.
316 const isRunning = run.steps.some((step) => step.status === 'running')
317
318 return !isRunning && at - Math.max(run.touchedAt, watch.startedAt) >= STALL_MS
319 ? 'no check moved for half an hour'
320 : undefined
321}
322
323const MAX_NAMED = 8
324
325/**
326 * What Claude is told when the checks it asked to be woken for are over:
327 * how many passed and failed, the failed ones by name, and the page.
328 */
329export const summaryOf = (run: Run, url: string, reason = ''): string => {
330 const leaves = run.steps.filter((step) => !run.steps.some((one) => one.id.startsWith(`${step.id}.`)))
331 const failed = leaves.filter((leaf) => leaf.status === 'failed')
332 const passed = leaves.filter((leaf) => leaf.status === 'done').length
333 const head =
334 reason === ''
335 ? `The GitHub checks of "${run.title}" are over: ${passed} passed, ${failed.length} failed.`
336 : `The Deck stopped watching the GitHub checks of "${run.title}": ${reason}. ${passed} passed and ${failed.length} failed so far.`
337
338 return [
339 head,
340 ...failed.slice(0, MAX_NAMED).map((leaf) => `✗ ${leaf.title}`),
341 ...(failed.length > MAX_NAMED ? [`and ${failed.length - MAX_NAMED} more`] : []),
342 url,
343 ].join('\n')
344}
345
346/** What starting a watch answers: where it stands, and whether word will come. */
347export const startText = (run: Run, url: string, wake: boolean): string => {
348 const leaves = isWaiting(run)
349 ? []
350 : run.steps.filter((step) => !run.steps.some((one) => one.id.startsWith(`${step.id}.`)))
351 const over = leaves.filter((leaf) => leaf.status === 'done' || leaf.status === 'failed').length
352 const stand = leaves.length === 0 ? 'no check is listed yet' : `${over} of ${leaves.length} checks are over`
353
354 return [
355 `"${run.title}" is shown in the Deck: ${stand}.`,
356 wake
357 ? 'A message will tell you when they are over. Do not poll for them: end your turn, or go on with other work.'
358 : 'The person sees them move. You are not told when they are over.',
359 url,
360 ].join(' ')
361}
362hooks/meter.ts 160 lines1// The model's name and the effort meter, as the label and the pane show them.
2
3import type { Meter } from '../types'
4
5export const LEVELS = ['low', 'medium', 'high', 'xhigh', 'max']
6
7/**
8 * The longest level's name, in cells. A button that names the level is drawn
9 * this wide at every level: one that shrank from `xhigh` to `max` would leave
10 * the pointer that pressed it on the cells after it, and the next press on
11 * nothing.
12 */
13export const LEVEL_CELLS = Math.max(...LEVELS.map((level) => level.length))
14
15export const NO_METER: Meter = {
16 model: '',
17 effort: '',
18 isSeen: false,
19 wanted: '',
20 over: '',
21 context: -1,
22 cost: -1,
23 limits: [],
24}
25
26const FULL = '▰'
27const EMPTY = '▱'
28
29/** `filled` of `cells` cells, as `▰▰▰▱▱`. */
30export const barOf = (filled: number, cells: number): string => {
31 const count = Math.max(0, Math.min(cells, Math.round(filled)))
32
33 return FULL.repeat(count) + EMPTY.repeat(cells - count)
34}
35
36/**
37 * A model id as a name: `claude-fable-5-1` is `Fable 5.1`,
38 * `claude-haiku-4-5-20251001` is `Haiku 4.5`, `sonnet[1m]` is `Sonnet 1M`.
39 */
40export const modelName = (id: string): string => {
41 const [base = '', window = ''] = id.trim().replace(/\]$/, '').split('[')
42 const parts = base
43 .replace(/^.*\//, '')
44 .replace(/^(us|eu|apac|global)\./, '')
45 .replace(/^anthropic\./, '')
46 .replace(/^claude-/, '')
47 .replace(/-v\d+(:\d+)?$/, '')
48 .split(/[-_@:]/)
49 .filter((part) => part !== '' && !/^\d{6,}$/.test(part))
50 const words = parts.filter((part) => !/^\d+$/.test(part))
51 const numbers = parts.filter((part) => /^\d+$/.test(part))
52 const name = [
53 words.map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`).join(' '),
54 numbers.join('.'),
55 window.toUpperCase(),
56 ]
57
58 return name.filter((part) => part !== '').join(' ')
59}
60
61/** A level's color, cooler to warmer as it rises. */
62export const levelColor = (level: string): string | undefined =>
63 [undefined, 'success', 'warning', 'claude', 'error'][LEVELS.indexOf(level)]
64
65/** A level's bar, `▰▰▰▱▱`; empty for a budget in tokens or no effort. */
66export const levelBar = (level: string): string =>
67 LEVELS.includes(level) ? barOf(LEVELS.indexOf(level) + 1, LEVELS.length) : ''
68
69/** The effort in force: the click's while one stands, else Claude Code's own. */
70export const effortOf = (meter: Meter): string => meter.wanted || meter.effort
71
72/** True while the deck sets the effort, not Claude Code. */
73export const isHeld = (meter: Meter): boolean => meter.wanted !== ''
74
75/** True where a click can change the effort: a model that takes one, seen at work. */
76export const canStep = (meter: Meter): boolean => meter.isSeen && meter.effort !== ''
77
78/** `▰▰▰▱▱ high`; a budget in tokens reads as its number; empty with no effort. */
79export const effortText = (meter: Meter): string => {
80 const effort = effortOf(meter)
81 const level = LEVELS.indexOf(effort)
82
83 if (effort === '') {
84 return ''
85 }
86
87 return level < 0 ? `effort ${effort}` : `${levelBar(effort)} ${effort}`
88}
89
90/**
91 * The effort's color, cooler to warmer as the level rises: low is dim, then
92 * green, yellow, orange and red for max. A budget in tokens has none.
93 */
94export const effortColor = (meter: Meter): string | undefined => levelColor(effortOf(meter))
95
96/** `ctx ▰▰▱▱▱▱▱▱ 23%`; empty before the window's fill is known. */
97export const contextText = (meter: Meter): string =>
98 meter.context < 0 ? '' : `ctx ${barOf((meter.context / 100) * 8, 8)} ${Math.round(meter.context)}%`
99
100const WINDOWS: Readonly<Record<string, string>> = {
101 five_hour: '5h',
102 seven_day: '7d',
103 spend_limit: 'spend',
104}
105
106/** `$1.24 · 5h 34% · 7d 12%`: the session's cost and each limit's fill; empty with neither. */
107export const usageText = (meter: Meter): string =>
108 [
109 meter.cost < 0 ? '' : `$${meter.cost.toFixed(2)}`,
110 ...meter.limits.map(
111 (limit) => `${WINDOWS[limit.kind] ?? limit.kind} ${Math.round(limit.percent)}%`,
112 ),
113 ]
114 .filter((part) => part !== '')
115 .join(' · ')
116
117/** True once a limit is nearly used up. */
118export const isTight = (meter: Meter): boolean => meter.limits.some((limit) => limit.percent >= 80)
119
120/**
121 * The meter set to `level` by the person. Claude Code's own level hands the
122 * effort back to it, as does no level at all.
123 */
124export const held = (meter: Meter, level: string): Meter =>
125 level === '' || level === meter.effort
126 ? { ...meter, wanted: '', over: '' }
127 : { ...meter, wanted: level, over: meter.effort }
128
129/** One step up from the effort in force, and from `max` back to `low`. */
130export const stepped = (meter: Meter): Meter =>
131 canStep(meter)
132 ? held(meter, LEVELS[(LEVELS.indexOf(effortOf(meter)) + 1) % LEVELS.length] ?? '')
133 : meter
134
135/**
136 * The meter after a request of the main thread that named this model and
137 * effort. An effort other than the one the click was made over is `/effort`,
138 * or another model: the click's level is dropped.
139 */
140export const seen = (meter: Meter, model: string, effort: string): Meter => {
141 const isChanged = meter.wanted !== '' && meter.over !== '' && effort !== meter.over
142 const wanted = isChanged || effort === '' || meter.wanted === effort ? '' : meter.wanted
143
144 return { ...meter, model, effort, isSeen: true, wanted, over: wanted === '' ? '' : effort }
145}
146
147export const isSameMeter = (one: Meter, other: Meter): boolean =>
148 one.model === other.model &&
149 one.effort === other.effort &&
150 one.isSeen === other.isSeen &&
151 one.wanted === other.wanted &&
152 one.over === other.over &&
153 one.context === other.context &&
154 one.cost === other.cost &&
155 one.limits.length === other.limits.length &&
156 one.limits.every(
157 (limit, index) =>
158 limit.kind === other.limits[index]?.kind && limit.percent === other.limits[index]?.percent,
159 )
160hooks/pane.tsx 807 lines1// The pane's drawing, and the label's row under the hint line. Nothing here
2// calls Claude Code: the hooks module hands in the surface's elements, what
3// to show and what a press does.
4
5import type {
6 BoxProps,
7 ButtonProps,
8 ElementConstructor,
9 RenderElement,
10 RenderNode,
11 TextProps,
12} from 'claude-code'
13
14import type { Job, Meter, Work } from '../types'
15
16import {
17 LEVEL_CELLS,
18 canStep,
19 contextText,
20 effortColor,
21 effortOf,
22 effortText,
23 isHeld,
24 isTight,
25 levelBar,
26 levelColor,
27 modelName,
28 usageText,
29} from './meter'
30import type { Row } from './runs'
31import { GLYPHS, jobSpan } from './view'
32
33export type Kit = {
34 Box: ElementConstructor<BoxProps>
35 Text: ElementConstructor<TextProps>
36 Button: ElementConstructor<ButtonProps>
37}
38
39export type PaneView = {
40 meter: Meter
41 work: Work
42 now: number
43 /** Cells across the pane's body. */
44 columns: number
45 /** True where the pane sits above the prompt, a few rows tall, and not beside the conversation. */
46 isInline: boolean
47 /** A press on the effort meter. */
48 onEffort: () => void
49 /** Each run as rows, the newest touched first, but for those drawn under their agent. */
50 runs: readonly (readonly Row[])[]
51 /** The runs a running agent opened, by the agent's id: drawn under its row. */
52 nested: Readonly<Record<string, readonly (readonly Row[])[]>>
53 /** A press on a run's, a branch's or a cut step's fold mark. */
54 onFold: (row: Row) => void
55 /** The rows the person opened, by `job:<id>` or `step:<key>`. */
56 open: Readonly<Record<string, boolean>>
57 /** A press on a shell's or an agent's mark or title: opens and closes its detail. */
58 onJob: (job: Job) => void
59 /** A press on an opened row's stop button. */
60 onStop: (job: Job) => void
61 /** A press on the clear button. */
62 onClear: () => void
63}
64
65const COLORS: Readonly<Record<Job['status'], string | undefined>> = {
66 running: 'claude',
67 done: 'success',
68 failed: 'error',
69 killed: 'warning',
70 ended: undefined,
71}
72
73/** The pane's body is drawn one cell in from each side. */
74const PAD = 1
75/** A shell Claude Code asks the person about: its mark, and what stands in for its clock. */
76const ASKING = '?'
77const ASKING_SPAN = 'waits'
78/** How many ended jobs the pane lists above the prompt, where it has a few rows. */
79const RECENT_INLINE = 3
80const KIND_CELLS = 7
81const SPAN_CELLS = 8
82/** A pane narrower than this leaves out a run's bar, and one narrower than the other a row's kind: the title needs the cells. */
83const BAR_FROM = 44
84const KIND_FROM = 36
85
86const rule = ({ Text }: Kit, columns: number): RenderElement => (
87 <Text dimColor wrap="truncate">
88 {'─'.repeat(Math.max(1, columns))}
89 </Text>
90)
91
92/** A shell's command, at most eight lines' worth of cells, or an agent's model and effort. */
93const detailOf = (job: Job, cells: number): string =>
94 job.kind === 'shell'
95 ? fitted(`$ ${job.detail}`, Math.max(1, cells) * 8)
96 : [modelName(job.model), job.effort === '' ? '' : `${job.effort} effort`]
97 .filter((part) => part !== '')
98 .join(' · ') || 'agent'
99
100/**
101 * An agent's model and effort at the end of its row, `Sonnet ▰▰▰▱▱`: the
102 * model's first word, then its effort's bar in the level's color. The bar is
103 * left out where the pane is too narrow for one, and both where it is too
104 * narrow for a kind. The opened row has the full name and the level.
105 */
106const tuneOf = (job: Job, columns: number): { name: string; bar: string; cells: number } => {
107 const name = job.kind === 'agent' && columns >= KIND_FROM ? (modelName(job.model).split(' ')[0] ?? '') : ''
108 const bar = name !== '' && columns >= BAR_FROM ? levelBar(job.effort) : ''
109
110 return { name, bar, cells: name === '' ? 0 : 1 + name.length + (bar === '' ? 0 : 1 + bar.length) }
111}
112
113/** A title cut to the cells it has, so a row stays one line: a button's label does not cut itself. */
114const fitted = (title: string, cells: number): string => {
115 const letters = [...title]
116
117 return letters.length > cells ? `${letters.slice(0, Math.max(0, cells - 1)).join('')}…` : title
118}
119
120/**
121 * `▸ ⏵ Typecheck, test and lint shell 1m 12s`
122 *
123 * The mark and title are buttons: a press opens the detail under it, where a
124 * job that runs in the background has a stop button. Under a running agent's
125 * row come the shells and agents it started and the run it opened. Where the
126 * section's name says the kind, the kind's column is left out; an agent's
127 * model and effort take its place in every section.
128 */
129const jobRow = (
130 kit: Kit,
131 job: Job,
132 view: PaneView,
133 indent = 0,
134 wantsKind = true,
135): RenderElement => {
136 const { Box, Text, Button } = kit
137 const tune = tuneOf(job, view.columns)
138 // An agent's model stands in for its kind, which it says as well.
139 const hasKind = tune.cells === 0 && wantsKind && view.columns >= KIND_FROM
140 // The row's cells less its fold and state marks, its indent and its columns at the right.
141 const room =
142 view.columns - 2 * PAD - 4 - 2 * indent - SPAN_CELLS - (hasKind ? KIND_CELLS : tune.cells) - 1
143 const title = fitted(job.title, room)
144 // What the agent of this row started and still runs is drawn under it.
145 const own =
146 job.kind === 'agent' && job.status === 'running'
147 ? view.work.running.filter((one) => one.owner === job.id)
148 : []
149 const isOpen = view.open[`job:${job.id}`] === true
150 const canStop = job.status === 'running' && job.taskId !== ''
151
152 return (
153 <Box flexDirection="column">
154 <Box>
155 <Box width={2 * indent} flexShrink={0} />
156 <Box width={2} flexShrink={0}>
157 <Button
158 key={`job-mark:${job.id}`}
159 plain
160 label={isOpen ? '▾' : '▸'}
161 onPress={() => {
162 view.onJob(job)
163 }}
164 />
165 </Box>
166 <Box width={2} flexShrink={0}>
167 <Text color={job.isAsking ? 'warning' : COLORS[job.status]} dimColor={job.status === 'ended'}>
168 {job.isAsking ? ASKING : GLYPHS[job.status]}
169 </Text>
170 </Box>
171 <Box flexGrow={1} flexShrink={1}>
172 <Button
173 key={`job:${job.id}`}
174 plain
175 dimColor={!isOpen && job.status !== 'running' && job.status !== 'failed'}
176 label={title}
177 onPress={() => {
178 view.onJob(job)
179 }}
180 />
181 </Box>
182 {hasKind && (
183 <Box width={KIND_CELLS} flexShrink={0} justifyContent="flex-end">
184 <Text dimColor>{job.kind}</Text>
185 </Box>
186 )}
187 {tune.cells > 0 && (
188 <Box width={tune.cells} flexShrink={0} justifyContent="flex-end">
189 <Text dimColor>{tune.name}</Text>
190 {tune.bar !== '' && <Text> </Text>}
191 {tune.bar !== '' && (
192 <Text color={levelColor(job.effort)} dimColor={job.status !== 'running' || levelColor(job.effort) === undefined}>
193 {tune.bar}
194 </Text>
195 )}
196 </Box>
197 )}
198 <Box width={SPAN_CELLS} flexShrink={0} justifyContent="flex-end">
199 <Text dimColor={job.status !== 'running' || job.isAsking}>
200 {job.isAsking ? ASKING_SPAN : jobSpan(job, view.now)}
201 </Text>
202 </Box>
203 </Box>
204 {isOpen && (
205 <Box flexDirection="column" paddingLeft={4 + 2 * indent}>
206 {title !== job.title && <Text wrap="wrap">{job.title}</Text>}
207 <Text dimColor wrap="wrap">
208 {detailOf(job, view.columns - 2 * PAD - 4 - 2 * indent)}
209 </Text>
210 {canStop && (
211 <Box>
212 <Button
213 key={`stop:${job.id}`}
214 plain
215 label="■ stop"
216 onPress={() => {
217 view.onStop(job)
218 }}
219 />
220 </Box>
221 )}
222 </Box>
223 )}
224 {own.map((one) => jobRow(kit, one, view, indent + 1, wantsKind))}
225 {job.status === 'running' &&
226 (view.nested[job.id] ?? []).flatMap((rows) =>
227 rows.map((row) => stepRow(kit, row, view, indent + 1)),
228 )}
229 </Box>
230 )
231}
232
233/**
234 * The model, its effort meter (a button where a press can change it) and the
235 * context's fill; under them the session's cost and its limits. Above the
236 * prompt, where the pane has a few rows, the cost and the limits share the
237 * first row while it has room.
238 */
239const head = (kit: Kit, view: PaneView): RenderElement => {
240 const { Box, Text, Button } = kit
241 const { meter } = view
242 const effort = effortText(meter)
243 const context = contextText(meter)
244 const usage = usageText(meter)
245 const spent = (
246 <Text color={isTight(meter) ? 'warning' : undefined} dimColor={!isTight(meter)} wrap="truncate-end">
247 {usage}
248 </Text>
249 )
250
251 return (
252 <Box flexDirection="column">
253 <Box columnGap={2} flexWrap="wrap" justifyContent="space-between">
254 <Box columnGap={2}>
255 <Text bold>{modelName(meter.model) || 'Claude'}</Text>
256 {canStep(meter) ? (
257 <Box columnGap={1}>
258 <Text color={effortColor(meter)} dimColor={effortOf(meter) === 'low'}>
259 {effort.split(' ')[0]}
260 </Text>
261 <Button
262 key="effort"
263 plain
264 label={`${effortOf(meter)} ${isHeld(meter) ? '⟳' : '↑'}`.padEnd(LEVEL_CELLS + 2)}
265 onPress={view.onEffort}
266 />
267 </Box>
268 ) : (
269 <Text dimColor>
270 {effort || (meter.isSeen ? 'no effort setting' : 'effort shows with the first request')}
271 </Text>
272 )}
273 </Box>
274 {view.isInline && usage !== '' && spent}
275 {context !== '' && <Text dimColor={meter.context < 80}>{context}</Text>}
276 </Box>
277 {!view.isInline && usage !== '' && spent}
278 </Box>
279 )
280}
281
282const section = (
283 kit: Kit,
284 title: string,
285 aside: RenderElement,
286 jobs: readonly Job[],
287 view: PaneView,
288 hasKind = true,
289): RenderElement => {
290 const { Box, Text } = kit
291
292 return (
293 <Box flexDirection="column">
294 <Box justifyContent="space-between">
295 <Text bold dimColor>
296 {title}
297 </Text>
298 {aside}
299 </Box>
300 {jobs.map((job) => jobRow(kit, job, view, 0, hasKind))}
301 </Box>
302 )
303}
304
305const STEP_GLYPHS: Readonly<Record<Row['status'], string>> = {
306 pending: '○',
307 running: GLYPHS.running,
308 done: GLYPHS.done,
309 failed: GLYPHS.failed,
310}
311
312const STEP_COLORS: Readonly<Record<Row['status'], string | undefined>> = {
313 pending: undefined,
314 running: COLORS.running,
315 done: COLORS.done,
316 failed: COLORS.failed,
317}
318
319const COUNT_CELLS = 6
320const METER_CELLS = 9
321
322/**
323 * One row of a run. A run's and a branch's mark is a button that folds and
324 * unfolds it: `▾` open, `▸` folded while under way, and its state's own mark
325 * when folded before its start or after its end. A leaf whose title is cut
326 * opens to the full title; a leaf that fits keeps its state mark alone.
327 *
328 * `▾ Auth renewal ▰▰▰▰▰▱▱▱ 5/8 12m 40s`
329 * ` ✓ Move the old table 1m 30s`
330 */
331const stepRow = (kit: Kit, row: Row, view: PaneView, indent = 0): RenderElement => {
332 const { Box, Text, Button } = kit
333 const isQuiet = row.status === 'done' || row.status === 'pending'
334 const folded = row.status === 'running' || row.kind === 'run' ? '▸' : STEP_GLYPHS[row.status]
335 // A run folded away still says how it stands, beside its mark.
336 const state = row.kind === 'run' && (row.status === 'done' || row.status === 'failed')
337 const hasMeter = row.meter !== '' && view.columns >= BAR_FROM
338 const inset = 2 * (row.depth + indent)
339 const room =
340 view.columns - 2 * PAD - inset - 2 - (state ? 2 : 0) - COUNT_CELLS -
341 SPAN_CELLS - (hasMeter ? METER_CELLS : 0) - 1
342 const isCut = row.kind === 'leaf' && fitted(row.title, room) !== row.title
343 const key = `step:${row.key}`
344 const isOpen = isCut && view.open[key] === true
345 const onPress = () => {
346 view.onFold({ ...row, key, isOpen })
347 }
348 const mark =
349 isCut ? (
350 <Button key={`step-mark:${row.key}`} plain label={isOpen ? '▾' : '▸'} onPress={onPress} />
351 ) : row.kind === 'leaf' ? (
352 <Text color={STEP_COLORS[row.status]} dimColor={row.status === 'pending'}>
353 {STEP_GLYPHS[row.status]}
354 </Text>
355 ) : (
356 <Button
357 key={`fold:${row.key}`}
358 plain
359 label={row.isOpen ? '▾' : folded}
360 onPress={() => {
361 view.onFold(row)
362 }}
363 />
364 )
365
366 return (
367 <Box flexDirection="column">
368 <Box>
369 <Box width={inset} flexShrink={0} />
370 <Box width={2} flexShrink={0}>
371 {mark}
372 </Box>
373 {(state || isCut) && (
374 <Box width={2} flexShrink={0}>
375 <Text color={STEP_COLORS[row.status]} dimColor={row.status === 'pending'}>
376 {STEP_GLYPHS[row.status]}
377 </Text>
378 </Box>
379 )}
380 <Box flexGrow={1} flexShrink={1}>
381 {isCut ? (
382 <Button
383 key={key}
384 plain
385 dimColor={!isOpen && isQuiet}
386 label={fitted(row.title, room - 2)}
387 onPress={onPress}
388 />
389 ) : (
390 <Text
391 bold={row.kind === 'run'}
392 color={row.status === 'failed' ? STEP_COLORS.failed : undefined}
393 dimColor={row.kind !== 'run' && isQuiet}
394 wrap="truncate-end"
395 >
396 {row.title}
397 </Text>
398 )}
399 </Box>
400 {hasMeter && (
401 <Box width={METER_CELLS} flexShrink={0} justifyContent="flex-end">
402 <Text color={STEP_COLORS[row.status]} dimColor={row.status === 'pending'}>
403 {row.meter}
404 </Text>
405 </Box>
406 )}
407 <Box width={COUNT_CELLS} flexShrink={0} justifyContent="flex-end">
408 <Text dimColor={row.kind !== 'run'}>{row.count}</Text>
409 </Box>
410 <Box width={SPAN_CELLS} flexShrink={0} justifyContent="flex-end">
411 <Text dimColor={row.status !== 'running'}>{row.span}</Text>
412 </Box>
413 </Box>
414 {isOpen && (
415 <Box paddingLeft={inset + 4}>
416 <Text wrap="wrap">{row.title}</Text>
417 </Box>
418 )}
419 </Box>
420 )
421}
422
423/**
424 * The runs as sections: an open run is a section of its own, and the folded
425 * ones that follow each other share one.
426 */
427const runSections = (kit: Kit, view: PaneView): RenderElement[] => {
428 const { Box } = kit
429 const groups = view.runs.reduce<Row[][]>((left, rows) => {
430 const last = left.at(-1)
431 const isFolded = rows.length === 1
432
433 return isFolded && last !== undefined && last.every((row) => row.kind === 'run' && !row.isOpen)
434 ? [...left.slice(0, -1), [...last, ...rows]]
435 : [...left, [...rows]]
436 }, [])
437
438 return groups.map((rows) => (
439 <Box flexDirection="column">{rows.map((row) => stepRow(kit, row, view))}</Box>
440 ))
441}
442
443/**
444 * The pane: the model and its meters, then the shells that run, then the
445 * agents that run with what each started, then the runs, then what lately
446 * ended. Where something is over, a last
447 * line has the button that clears it away.
448 */
449export const paneTree = (kit: Kit, view: PaneView): RenderElement => {
450 const { Box, Text, Button } = kit
451 const { work } = view
452 const columns = Math.max(1, view.columns - 2 * PAD)
453 const line = rule(kit, columns)
454 const more = runSections(kit, view)
455 const isIdle = work.running.length === 0 && work.recent.length === 0 && more.length === 0
456 const isOver = (rows: readonly Row[]): boolean =>
457 rows[0]?.status === 'done' || rows[0]?.status === 'failed' || rows[0]?.isStopped === true
458 const canClear = work.recent.length > 0 || view.runs.some(isOver)
459 // What runs, in two sections: the shells nobody's agent started, then each
460 // agent with what it started under it.
461 const isTop = (job: Job): boolean => !work.running.some((one) => one.id === job.owner)
462 const shells = work.running.filter((job) => job.kind === 'shell' && isTop(job))
463 const agents = work.running.filter((job) => job.kind === 'agent' && isTop(job))
464 const blank = <Text> </Text>
465 // Above the prompt the newest few ended jobs are listed, and the rest counted.
466 const recent = view.isInline ? work.recent.slice(0, RECENT_INLINE) : work.recent
467 const hidden = work.recent.length - recent.length
468 const clear = (
469 <Button key="clear" plain dimColor label="× clear" onPress={view.onClear} />
470 )
471
472 return (
473 <Box flexDirection="column" paddingX={PAD}>
474 {head(kit, view)}
475 {line}
476 {work.running.length === 0 && (
477 <Text dimColor>{isIdle ? 'Nothing runs yet.' : 'Nothing runs right now.'}</Text>
478 )}
479 {shells.length > 0 && section(kit, `SHELLS · ${shells.length}`, blank, shells, view, false)}
480 {shells.length > 0 && agents.length > 0 && line}
481 {agents.length > 0 && section(kit, `AGENTS · ${agents.length}`, blank, agents, view, false)}
482 {isIdle && (
483 <Text dimColor>Shells, agents and task lists Claude starts show here while they run.</Text>
484 )}
485 {more.flatMap((part) => [line, part])}
486 {work.recent.length > 0 && line}
487 {work.recent.length > 0 &&
488 section(kit, 'RECENT', hidden > 0 ? <Text dimColor>{`+${hidden} more`}</Text> : blank, recent, view)}
489 {canClear && <Box justifyContent="flex-end">{clear}</Box>}
490 </Box>
491 )
492}
493
494/** The deck's mark on its label's row: a pane docked at the right. */
495const MARK = '◨'
496
497/** What the label's row says, part by part. */
498export type LabelView = {
499 /** The model's name; empty before it is known. */
500 head: string
501 /** Its effort's bar, `▰▰▰▱▱`, the level's name and its color; the bar is empty with no effort. */
502 bar: string
503 level: string
504 effortColor: string | undefined
505 /** True where a press can change the effort. */
506 canStep: boolean
507 /** A press on the effort's level: one step up, as on the pane's meter. */
508 onEffort: () => void
509 /** How many shells and agents run. */
510 running: number
511 /** The run under way, `Auth renewal`, and its count, `5/8`; both empty with none. */
512 runTitle: string
513 runCount: string
514 /** How many cells of the run's title show. */
515 runCells: number
516 /** True when a step of that run failed. */
517 isFailed: boolean
518 /**
519 * A press on the deck's mark, or on the run: opens or closes the pane. Its
520 * promise goes back to the press, so the pane it opens is the person's ask.
521 */
522 onPress: () => Promise<void>
523 /** A press on the row's `×`: the label goes back to the hint line as text. */
524 onClose: () => void
525}
526
527/**
528 * The label as a row of its own, where there is a pointer. It begins with
529 * the deck's mark, a button that opens or closes the pane, as the run at its
530 * end does. Between them the model's name is text, and its effort's level is
531 * a button that steps it up as the pane's meter does, as wide at every level
532 * as the longest level's name. The effort's bar has its level's color, what
533 * runs the color of a running row, a failure is red.
534 */
535export const labelRow = (kit: Kit, view: LabelView): RenderElement => {
536 const { Box, Text, Button } = kit
537 const dot = <Text dimColor>·</Text>
538 const run = runLabel(view)
539
540 return (
541 <Box columnGap={1}>
542 <Button key="deck" plain label={MARK} onPress={view.onPress} />
543 {view.head !== '' && <Text>{view.head}</Text>}
544 {view.bar !== '' && (
545 <Text color={view.effortColor} dimColor={view.effortColor === undefined}>
546 {view.bar}
547 </Text>
548 )}
549 {view.bar !== '' &&
550 (view.canStep ? (
551 <Button
552 key="deck-effort"
553 plain
554 label={view.level.padEnd(LEVEL_CELLS)}
555 onPress={view.onEffort}
556 />
557 ) : (
558 <Text dimColor>{view.level}</Text>
559 ))}
560 {view.running > 0 && dot}
561 {view.running > 0 && <Text color={COLORS.running}>{`${GLYPHS.running} ${view.running}`}</Text>}
562 {run !== '' && dot}
563 {run !== '' && view.isFailed && <Text color={COLORS.failed}>{GLYPHS.failed}</Text>}
564 {run !== '' && <Button key="deck-run" plain dimColor label={run} onPress={view.onPress} />}
565 <Button key="deck-row-close" plain dimColor label="×" onPress={view.onClose} />
566 </Box>
567 )
568}
569
570/** The run as the label shows it: `Auth renewal 5/8`, its title cut to its cells. */
571const runLabel = (view: LabelView): string =>
572 view.runTitle === '' ? '' : `${fitted(view.runTitle, view.runCells)} ${view.runCount}`
573
574const widthOf = (text: string): number => [...text].length
575
576/** The cells the label's row takes, as `labelRow` draws it: its parts, a cell between each. */
577export const labelCells = (view: LabelView): number => {
578 const run = runLabel(view)
579 const parts = [
580 1,
581 widthOf(view.head),
582 widthOf(view.bar),
583 view.bar === '' ? 0 : view.canStep ? LEVEL_CELLS : widthOf(view.level),
584 view.running > 0 ? 1 : 0,
585 view.running > 0 ? widthOf(`${GLYPHS.running} ${view.running}`) : 0,
586 run === '' ? 0 : 1,
587 run !== '' && view.isFailed ? 1 : 0,
588 widthOf(run),
589 1,
590 ].filter((cells) => cells > 0)
591
592 return parts.reduce((sum, cells) => sum + cells, 0) + parts.length - 1
593}
594
595/** The fewest cells of a run's title the label keeps before it leaves the run out. */
596const RUN_MIN_CELLS = 6
597
598/**
599 * The label fitted to `room` cells, so its run and its `×` are not pushed
600 * past the screen's edge: the run's title is cut first, then the model's
601 * name goes, then the run. What still does not fit is drawn as it is.
602 */
603export const fittedLabel = (view: LabelView, room: number): LabelView => {
604 const over = labelCells(view) - room
605
606 if (over <= 0) {
607 return view
608 }
609
610 const shown = Math.min(view.runCells, widthOf(view.runTitle))
611
612 if (view.runTitle !== '' && shown - over >= RUN_MIN_CELLS) {
613 return { ...view, runCells: shown - over }
614 }
615
616 if (view.head !== '') {
617 return fittedLabel({ ...view, head: '' }, room)
618 }
619
620 return view.runTitle === '' ? view : fittedLabel({ ...view, runTitle: '', runCount: '' }, room)
621}
622
623/** The cells a drawn row takes, near enough: its text, and its gaps where it is a row. */
624const cellsOf = (node: RenderNode): number => {
625 if (typeof node === 'string') {
626 return widthOf(node)
627 }
628
629 if (node.type === 'Button') {
630 return widthOf(node.props.label)
631 }
632
633 if (node.type === 'Text') {
634 return (node.children ?? []).map(cellsOf).reduce((sum: number, cells: number) => sum + cells, 0)
635 }
636
637 if (node.type !== 'Box') {
638 return 0
639 }
640
641 const kids: number[] = (node.children ?? []).map(cellsOf)
642
643 if (node.props?.flexDirection === 'column') {
644 return Math.max(0, ...kids)
645 }
646
647 const gap = typeof node.props?.columnGap === 'number' ? node.props.columnGap : 0
648
649 return kids.reduce((sum, cells) => sum + cells, 0) + gap * Math.max(0, kids.length - 1)
650}
651
652/**
653 * The cells of the row another mod drew under the hint line, which the
654 * label joins as `beside` puts it, with its gap; 0 where the label gets a
655 * row of its own.
656 */
657export const besideCells = (tree: RenderNode): number => {
658 if (typeof tree === 'string' || tree.type !== 'Box' || tree.children === undefined) {
659 return 0
660 }
661
662 const [first, second] = tree.children
663 const isLine = typeof first === 'string' || first?.type !== 'Box'
664
665 if (
666 tree.props?.flexDirection !== 'column' ||
667 !isLine ||
668 second === undefined ||
669 typeof second === 'string' ||
670 second.type !== 'Box' ||
671 second.props?.flexDirection === 'column' ||
672 second.children === undefined
673 ) {
674 return 0
675 }
676
677 const gap = typeof second.props?.columnGap === 'number' ? second.props.columnGap : 0
678
679 return cellsOf(second) + gap
680}
681
682/**
683 * `tree` with `row` right under the hint line. The engine draws its line
684 * over a tree's first row, and another mod's drawing may hold the line's
685 * place first with rows of its own after it: `row` goes in right after that
686 * place, so what the others drew stays where it was.
687 */
688export const under = (tree: RenderNode, row: RenderElement): RenderElement => {
689 if (typeof tree === 'string' || tree.type !== 'Box' || tree.children === undefined) {
690 return { type: 'Box', props: { flexDirection: 'column' }, children: [tree, row] }
691 }
692
693 const [first, ...rest] = tree.children
694 const isLine = typeof first === 'string' || first?.type !== 'Box'
695
696 if (first === undefined) {
697 return { ...tree, children: [row] }
698 }
699
700 if (tree.props?.flexDirection === 'column' && isLine) {
701 return { ...tree, children: [first, row, ...rest] }
702 }
703
704 return { ...tree, children: [under(first, row), ...rest] }
705}
706
707/**
708 * `tree` with the label's `row`. Where another mod has already drawn a row
709 * of its own right under the hint line, the label joins that row at its end,
710 * so two mods take one row between them; else it gets a row of its own.
711 */
712export const beside = (tree: RenderNode, row: RenderElement): RenderElement => {
713 if (typeof tree === 'string' || tree.type !== 'Box' || tree.children === undefined) {
714 return under(tree, row)
715 }
716
717 const [first, second, ...rest] = tree.children
718 const isLine = typeof first === 'string' || first?.type !== 'Box'
719
720 if (
721 tree.props?.flexDirection !== 'column' ||
722 !isLine ||
723 first === undefined ||
724 second === undefined ||
725 typeof second === 'string' ||
726 second.type !== 'Box' ||
727 second.props?.flexDirection === 'column' ||
728 second.children === undefined
729 ) {
730 return under(tree, row)
731 }
732
733 return { ...tree, children: [first, { ...second, children: [...second.children, row] }, ...rest] }
734}
735
736/** One shell command of a transcript's group of tool calls, as its row says it. */
737export type CallRow = {
738 /** The call's id. */
739 id: string
740 status: Job['status']
741 /** True while Claude Code asks the person whether it may run. */
742 isAsking: boolean
743 title: string
744 /** `8s`; empty where it is not known, or under a second. */
745 span: string
746}
747
748/** How many calls of a group get a row; the rest are counted. */
749const GROUP_ROWS = 4
750const GROUP_TITLE_CELLS = 56
751/** A group row's cells but its title: its indent, mark, gaps and the longest time, `14m 03s`. */
752const GROUP_FIXED_CELLS = 13
753const GROUP_MIN_CELLS = 12
754
755/**
756 * `line`, the transcript's own row for a group of tool calls, with a row
757 * under it for each shell command of the group: its mark, what it does and
758 * how long it ran. A press on a title opens the pane at that command; its
759 * promise goes back to the press, so the pane it opens is the person's ask.
760 *
761 * Ran 3 shell commands
762 * ✓ Check the version 2s
763 * ✗ Verify the profile 8s
764 *
765 * A title is cut to what the conversation's `columns` leave it, and to 56
766 * cells at most.
767 */
768export const groupTree = (
769 kit: Kit,
770 line: RenderElement,
771 rows: readonly CallRow[],
772 onPress: (row: CallRow) => Promise<void>,
773 columns = Number.POSITIVE_INFINITY,
774): RenderElement => {
775 const { Box, Text, Button } = kit
776 const more = rows.length - GROUP_ROWS
777 const cells = Math.max(GROUP_MIN_CELLS, Math.min(GROUP_TITLE_CELLS, columns - GROUP_FIXED_CELLS))
778
779 return (
780 <Box flexDirection="column">
781 {line}
782 {rows.slice(0, GROUP_ROWS).map((row) => (
783 <Box columnGap={2} paddingLeft={2}>
784 <Box columnGap={1}>
785 <Text color={row.isAsking ? 'warning' : COLORS[row.status]} dimColor={row.status === 'ended'}>
786 {row.isAsking ? ASKING : GLYPHS[row.status]}
787 </Text>
788 <Button
789 key={`call:${row.id}`}
790 plain
791 dimColor={row.status === 'done'}
792 label={fitted(row.title, cells)}
793 onPress={() => onPress(row)}
794 />
795 </Box>
796 {row.span !== '' && <Text dimColor>{row.span}</Text>}
797 </Box>
798 ))}
799 {more > 0 && (
800 <Box paddingLeft={2}>
801 <Text dimColor>{`+${more} more`}</Text>
802 </Box>
803 )}
804 </Box>
805 )
806}
807hooks/runs.ts 525 lines1// Runs: step-by-step progress of a job, as a tree of any depth. A run's
2// steps are one flat list in planned order; a step's id is its place in the
3// tree (`1`, `1.2`, `1.2.1`), so its parent and its children are told from
4// the id alone. Only leaves hold a state: a parent's comes from its leaves.
5
6import type { Run, RunFeed, Step, StepStatus } from '../types'
7
8import { barOf } from './meter'
9import { spanText } from './view'
10
11/** How many runs are kept; the oldest finished one goes first. */
12const KEPT = 6
13const MAX_STEPS = 200
14const MAX_DEPTH = 6
15const TITLE_CHARS = 80
16const METER_CELLS = 8
17
18const cut = (text: string, chars: number): string => {
19 const line = [...text.trim().replace(/\s+/g, ' ')]
20
21 return line.length > chars ? `${line.slice(0, chars - 1).join('')}…` : line.join('')
22}
23
24export const titleOf = (text: string): string => cut(text, TITLE_CHARS)
25
26const isUnder = (id: string, parent: string): boolean => id.startsWith(`${parent}.`)
27
28export const isLeaf = (run: Run, id: string): boolean =>
29 !run.steps.some((step) => isUnder(step.id, id))
30
31/** The leaves a step stands for: itself, or every leaf beneath it. */
32export const leavesOf = (run: Run, id?: string): Step[] =>
33 run.steps.filter(
34 (step) => isLeaf(run, step.id) && (id === undefined || step.id === id || isUnder(step.id, id)),
35 )
36
37const isOver = (step: Step): boolean => step.status === 'done' || step.status === 'failed'
38
39/**
40 * A branch's state from its leaves: failed once one failed, done when all
41 * are, running while one runs or some are over and some are not.
42 */
43export const statusOf = (leaves: readonly Step[]): StepStatus => {
44 if (leaves.some((leaf) => leaf.status === 'failed')) {
45 return 'failed'
46 }
47
48 if (leaves.length > 0 && leaves.every((leaf) => leaf.status === 'done')) {
49 return 'done'
50 }
51
52 return leaves.some((leaf) => leaf.status !== 'pending') ? 'running' : 'pending'
53}
54
55/** True once no leaf is left to run. */
56export const isFinished = (leaves: readonly Step[]): boolean =>
57 leaves.length > 0 && leaves.every(isOver)
58
59const doneOf = (leaves: readonly Step[]): number =>
60 leaves.filter((leaf) => leaf.status === 'done').length
61
62/** From the first start among the leaves to the last end, or to now while one is not over. */
63const spanOf = (leaves: readonly Step[], now: number): string => {
64 const starts = leaves.filter((leaf) => leaf.startedAt > 0).map((leaf) => leaf.startedAt)
65
66 if (starts.length === 0) {
67 return ''
68 }
69
70 const end = isFinished(leaves) ? Math.max(...leaves.map((leaf) => leaf.endedAt)) : now
71 const ms = Math.max(0, end - Math.min(...starts))
72
73 // A step over within the second, or one whose times nobody saw, shows none.
74 return isFinished(leaves) && ms < 1000 ? '' : spanText(ms)
75}
76
77/**
78 * A plan as indented text, one step a line, as steps. A line indented
79 * deeper than the one before it is its child; a bullet or a number before
80 * the title is dropped. Empty when the text holds no step.
81 */
82export const planned = (text: string): Step[] => {
83 const lines = text
84 .split('\n')
85 .map((line) => line.replace(/\t/g, ' '))
86 .filter((line) => line.trim() !== '')
87 .slice(0, MAX_STEPS)
88 // The indent of each open level, and how many steps each has so far.
89 const indents: number[] = []
90 const counts: number[] = []
91
92 return lines.map((line) => {
93 const indent = line.length - line.trimStart().length
94
95 while (indents.length > 1 && indent < (indents.at(-1) ?? 0)) {
96 indents.pop()
97 counts.pop()
98 }
99
100 if (indents.length === 0 || (indent > (indents.at(-1) ?? 0) && indents.length < MAX_DEPTH)) {
101 indents.push(indent)
102 counts.push(0)
103 }
104
105 counts[counts.length - 1] = (counts.at(-1) ?? 0) + 1
106
107 return {
108 id: counts.join('.'),
109 title: titleOf(line.trim().replace(/^([-*•]|\d+[.)])\s+/, '')) || 'step',
110 status: 'pending',
111 startedAt: 0,
112 endedAt: 0,
113 }
114 })
115}
116
117/** The runs with one more, the oldest finished ones dropped past what is kept. */
118export const opened = (runs: readonly Run[], run: Run): Run[] => {
119 const all = [...runs.filter((one) => one.id !== run.id), run]
120 const spare = all.length - KEPT
121 const old = all
122 .filter((one) => one.id !== run.id && isFinished(leavesOf(one)))
123 .slice(0, Math.max(0, spare))
124 .map((one) => one.id)
125
126 return all.filter((one) => !old.includes(one.id)).slice(-KEPT)
127}
128
129export const runOf = (
130 id: string,
131 title: string,
132 feed: RunFeed,
133 loop: string,
134 owner: string,
135 steps: Step[],
136 at: number,
137): Run => ({
138 id,
139 title: titleOf(title) || 'Run',
140 feed,
141 loop,
142 owner,
143 steps,
144 touchedAt: at,
145 stoppedAt: 0,
146})
147
148const replaced = (runs: readonly Run[], run: Run): Run[] =>
149 runs.map((one) => (one.id === run.id ? run : one))
150
151export type StepWord = 'start' | 'done' | 'fail'
152
153const turned = (step: Step, word: StepWord, at: number): Step => {
154 if (word === 'start') {
155 return { ...step, status: 'running', startedAt: step.startedAt || at, endedAt: 0 }
156 }
157
158 return {
159 ...step,
160 status: word === 'done' ? 'done' : 'failed',
161 startedAt: step.startedAt || at,
162 endedAt: at,
163 }
164}
165
166const parentOf = (id: string): string => id.split('.').slice(0, -1).join('.')
167
168/**
169 * One leaf of a run starts, is done or fails. A start ends every leaf still
170 * running before it in the plan, in its own branch or an earlier one, so
171 * moving on is one call. Resolves the run, or why nothing changed.
172 */
173export const stepped = (
174 run: Run,
175 id: string,
176 word: StepWord,
177 at: number,
178): { run: Run; error?: undefined } | { error: string; run?: undefined } => {
179 const step = run.steps.find((one) => one.id === id)
180
181 if (step === undefined) {
182 return { error: `No step ${id}. The steps: ${leavesOf(run).map((leaf) => leaf.id).join(', ')}.` }
183 }
184
185 if (!isLeaf(run, id)) {
186 return {
187 error: `${id} has steps of its own and follows them. Update one of: ${leavesOf(run, id).map((leaf) => leaf.id).join(', ')}.`,
188 }
189 }
190
191 const place = run.steps.indexOf(step)
192 const steps = run.steps.map((one, index) => {
193 if (one.id === id) {
194 return turned(one, word, at)
195 }
196
197 const isBefore =
198 word === 'start' && index < place && one.status === 'running' && isLeaf(run, one.id)
199
200 return isBefore ? turned(one, 'done', at) : one
201 })
202
203 return { run: { ...run, steps, touchedAt: at, stoppedAt: 0 } }
204}
205
206/**
207 * The runs with a loop's new plan. The task list the loop was following goes:
208 * the plan tells the same job with its levels. A plan of the loop's that is
209 * still under way stops where it stands: the loop has moved on to another
210 * job, and nothing would end the old one.
211 */
212export const replanned = (runs: readonly Run[], run: Run): Run[] =>
213 opened(
214 runs
215 .filter((one) => !(one.feed === 'tasks' && one.loop === run.loop && isLive(one)))
216 .map((one) => (one.feed === 'plan' && one.loop === run.loop && isLive(one) ? halted(one, run.touchedAt) : one)),
217 run,
218 )
219
220/**
221 * The plan a loop's step is for: the one named, else the loop's newest
222 * unfinished plan, else its newest. Never another loop's by itself: a
223 * subagent with no plan of its own would move the main thread's.
224 */
225export const planOf = (runs: readonly Run[], loop: string, id: string): Run | undefined => {
226 const plans = runs.filter((run) => run.feed === 'plan')
227
228 if (id !== '') {
229 return plans.find((run) => run.id === id)
230 }
231
232 const own = plans.filter((run) => run.loop === loop)
233
234 return own.findLast((run) => !isFinished(leavesOf(run))) ?? own.at(-1)
235}
236
237/**
238 * A task Claude Code's own list gained. The loop's list is one flat run; a
239 * task made after every earlier one is over begins a new run.
240 */
241export const taskCreated = (
242 runs: readonly Run[],
243 loop: string,
244 owner: string,
245 taskId: string,
246 subject: string,
247 at: number,
248 mint: () => string,
249): Run[] => {
250 const step: Step = { id: taskId, title: titleOf(subject) || 'task', status: 'pending', startedAt: 0, endedAt: 0 }
251 const list = runs.findLast((run) => run.feed === 'tasks' && run.loop === loop)
252
253 // A loop that follows a plan shows that plan: its task list would be the
254 // same job a second time.
255 if (runs.some((run) => run.feed === 'plan' && run.loop === loop && isLive(run))) {
256 return [...runs]
257 }
258
259 if (list === undefined || isFinished(leavesOf(list))) {
260 return opened(runs, runOf(mint(), 'Tasks', 'tasks', loop, owner, [step], at))
261 }
262
263 return replaced(runs, {
264 ...list,
265 steps: [...list.steps.filter((one) => one.id !== taskId), step],
266 touchedAt: at,
267 })
268}
269
270/** A task of the loop's list changed: its state, its title, or it was deleted. */
271export const taskUpdated = (
272 runs: readonly Run[],
273 loop: string,
274 taskId: string,
275 change: { status?: string; subject?: string },
276 at: number,
277): Run[] => {
278 const list = runs.findLast(
279 (run) => run.feed === 'tasks' && run.loop === loop && run.steps.some((step) => step.id === taskId),
280 )
281
282 if (list === undefined) {
283 return [...runs]
284 }
285
286 if (change.status === 'deleted') {
287 const steps = list.steps.filter((step) => step.id !== taskId)
288
289 return steps.length === 0
290 ? runs.filter((run) => run.id !== list.id)
291 : replaced(runs, { ...list, steps, touchedAt: at })
292 }
293
294 const steps = list.steps.map((step) => {
295 if (step.id !== taskId) {
296 return step
297 }
298
299 const named = change.subject === undefined ? step : { ...step, title: titleOf(change.subject) || step.title }
300
301 if (change.status === 'in_progress') {
302 return turned(named, 'start', at)
303 }
304
305 if (change.status === 'completed') {
306 return turned(named, 'done', at)
307 }
308
309 return change.status === 'pending'
310 ? { ...named, status: 'pending' as const, startedAt: 0, endedAt: 0 }
311 : named
312 })
313
314 return replaced(runs, { ...list, steps, touchedAt: at })
315}
316
317/** True while a run can still move: steps are left, and its agent has not ended. */
318export const isLive = (run: Run): boolean => run.stoppedAt === 0 && !isFinished(leavesOf(run))
319
320/**
321 * The agent of this loop ended: each run it left unfinished stops there.
322 * When it ended with its answer, the step it left running is done, so an
323 * agent need not spend a call on its last step; when it was cut short, that
324 * step goes back to waiting, since nobody will end it.
325 */
326export const stopped = (
327 runs: readonly Run[],
328 loop: string,
329 at: number,
330 isAnswered = false,
331): Run[] => runs.map((run) => (run.loop !== loop || !isLive(run) ? run : halted(run, at, isAnswered)))
332
333/** One live run stopped where it stands, as `stopped` stops a loop's. */
334const halted = (run: Run, at: number, isAnswered = false): Run => {
335 const steps = run.steps.map((step) =>
336 step.status === 'running' && isLeaf(run, step.id)
337 ? { ...step, status: isAnswered ? ('done' as const) : ('pending' as const), endedAt: at }
338 : step,
339 )
340 const left = { ...run, steps, touchedAt: at }
341
342 return isFinished(leavesOf(left)) ? left : { ...left, stoppedAt: at }
343}
344
345/**
346 * Every run still under way stops where it stands: the deck was closed and
347 * hears no step from now on. A step a loop calls later moves its run again.
348 */
349export const stoppedAll = (runs: readonly Run[], at: number): Run[] =>
350 runs.map((run) => (isLive(run) ? halted(run, at) : run))
351
352/** The run a fold's key belongs to: `r1` of `r1`, `r1/2.1` and `step:r1/2.1`. */
353const runOfFold = (key: string): string => key.replace(/^step:/, '').split('/')[0] ?? ''
354
355/**
356 * The folds of what is still shown: a job's by its id, a run's, a branch's
357 * and a step's by their run. A run that takes a freed id starts with none.
358 */
359export const keptFolds = (
360 folds: Readonly<Record<string, boolean>>,
361 runs: readonly Run[],
362 jobIds: readonly string[],
363): Record<string, boolean> =>
364 Object.fromEntries(
365 Object.entries(folds).filter(([key]) =>
366 key.startsWith('job:') ? jobIds.includes(key.slice('job:'.length)) : runs.some((run) => run.id === runOfFold(key)),
367 ),
368 )
369
370/** The runs that can still move: what a clear leaves, or those touched since a time. */
371export const swept = (runs: readonly Run[], before = Number.POSITIVE_INFINITY): Run[] =>
372 runs.filter((run) => isLive(run) || Math.max(run.touchedAt, run.stoppedAt) >= before)
373
374/**
375 * What to tell the person of a change of the runs: a run that finished, or a
376 * step that failed, a line each.
377 */
378export const newsOf = (before: readonly Run[], after: readonly Run[]): string[] =>
379 after.flatMap((run) => {
380 const old = before.find((one) => one.id === run.id)
381 const leaves = leavesOf(run)
382 const failed = leaves.filter((leaf) => leaf.status === 'failed')
383 const wasFailed = old === undefined ? 0 : leavesOf(old).filter((leaf) => leaf.status === 'failed').length
384 const isNew = old === undefined || !isFinished(leavesOf(old))
385
386 if (failed.length > wasFailed) {
387 return [`✗ ${cut(run.title, 32)}: ${cut(failed.at(-1)?.title ?? '', 40)} failed`]
388 }
389
390 return isFinished(leaves) && isNew && old !== undefined
391 ? [`✓ ${cut(run.title, 40)} ${doneOf(leaves)}/${leaves.length}`]
392 : []
393 })
394
395/** The run the label and the open section follow: the newest touched one that can still move. */
396export const activeOf = (runs: readonly Run[]): Run | undefined =>
397 runs
398 .filter(isLive)
399 .reduce<Run | undefined>(
400 (best, run) => (best === undefined || run.touchedAt >= best.touchedAt ? run : best),
401 undefined,
402 )
403
404/** How many of a run's title the label shows at most. */
405export const RUN_TITLE_CELLS = 24
406
407/** `5/8`: a run's leaves done of all. */
408export const runCount = (run: Run): string => {
409 const leaves = leavesOf(run)
410
411 return `${doneOf(leaves)}/${leaves.length}`
412}
413
414/** `Auth renewal 5/8`, for the label: the title cut to `chars`. */
415export const runText = (run: Run, chars = RUN_TITLE_CELLS): string => `${cut(run.title, chars)} ${runCount(run)}`
416
417/** One drawn row of a run: the run itself, a branch or a leaf. */
418export type Row = {
419 /** `run/step`, or the run's id for its own row: the fold's key for a row that opens. */
420 key: string
421 kind: 'run' | 'branch' | 'leaf'
422 depth: number
423 status: StepStatus
424 title: string
425 /** `▰▰▰▰▰▱▱▱`, on a run's row. */
426 meter: string
427 /** `5/8`: leaves done of all, on a run's or a branch's row. */
428 count: string
429 span: string
430 isOpen: boolean
431 /** On a run's row: its agent ended with steps left. */
432 isStopped: boolean
433}
434
435const depthOf = (id: string): number => id.split('.').length
436
437/**
438 * A run as rows, top to bottom, with what is folded left out. A branch is
439 * open by itself while it is under way, and folded before it starts and
440 * once it is over; the person's own fold stands over both.
441 */
442export const rowsOf = (
443 run: Run,
444 folds: Readonly<Record<string, boolean>>,
445 now: number,
446 isActive: boolean,
447): Row[] => {
448 const all = leavesOf(run)
449 // A stopped run's clocks read the moment it stopped.
450 const at = run.stoppedAt || now
451 const isRunOpen = folds[run.id] ?? isActive
452 const top: Row = {
453 key: run.id,
454 kind: 'run',
455 depth: 0,
456 status: statusOf(all),
457 title: run.owner === '' ? run.title : `${run.title} · ${run.owner}`,
458 meter: barOf((doneOf(all) / Math.max(1, all.length)) * METER_CELLS, METER_CELLS),
459 count: `${doneOf(all)}/${all.length}`,
460 span: spanOf(all, at),
461 isOpen: isRunOpen,
462 isStopped: run.stoppedAt > 0,
463 }
464
465 if (!isRunOpen) {
466 return [top]
467 }
468
469 const open = new Map<string, boolean>()
470 const rows = run.steps.flatMap((step): Row[] => {
471 const parent = parentOf(step.id)
472
473 if (parent !== '' && open.get(parent) !== true) {
474 open.set(step.id, false)
475
476 return []
477 }
478
479 const leaves = leavesOf(run, step.id)
480 const key = `${run.id}/${step.id}`
481
482 if (isLeaf(run, step.id)) {
483 return [
484 {
485 key,
486 kind: 'leaf',
487 depth: depthOf(step.id),
488 status: step.status,
489 title: step.title,
490 meter: '',
491 count: '',
492 span: spanOf([step], at),
493 isOpen: false,
494 isStopped: false,
495 },
496 ]
497 }
498
499 const status = statusOf(leaves)
500 const isOpen = folds[key] ?? (!isFinished(leaves) && status !== 'pending')
501 open.set(step.id, isOpen)
502
503 return [
504 {
505 key,
506 kind: 'branch',
507 depth: depthOf(step.id),
508 status,
509 title: step.title,
510 meter: '',
511 count: `${doneOf(leaves)}/${leaves.length}`,
512 span: spanOf(leaves, at),
513 isOpen,
514 isStopped: false,
515 },
516 ]
517 })
518
519 return [top, ...rows]
520}
521
522/** The plan as the tool answers it: each step's id and title, a line each. */
523export const planText = (run: Run): string =>
524 run.steps.map((step) => `${step.id} ${step.title}`).join('\n')
525hooks/tools.ts 81 lines1// The tools the model can call, for a person who turned them on. Here
2// only as the model reads them: the hooks module serves them. Their words
3// are in every request's context, so they are kept short.
4
5import type { ToolSpec } from 'claude-code'
6
7/**
8 * What tells an agent the tools are there. Where many tools are listed, the
9 * model sees these two by name only and reads their descriptions only once
10 * it loads them, so the descriptions alone bring no agent to call them. With
11 * the setting on, this goes into the main thread's system prompt and at the
12 * end of each subagent's task.
13 */
14export const NOTE =
15 'The person follows progress in a pane called Deck, which shows them the title and steps of a plan and the description of each shell command you run. They read these to understand what is happening: write them in the language the person writes in. If you have the tool mcp__deck__plan and this job will take more than a couple of minutes or more than three stages, call it once as you begin with three to six steps (load it and mcp__deck__step with ToolSearch if their schemas are not loaded); its first step starts by itself, so call mcp__deck__step only as you move to the next one. Skip it for a short job: each call costs the person a round trip.'
16
17/** The note's id among the system prompt's sections. */
18export const NOTE_SECTION = 'deck:plan'
19
20export const WORDS = ['start', 'done', 'fail'] as const
21
22export const TOOLS: readonly ToolSpec[] = [
23 {
24 name: 'plan',
25 description:
26 "Shows your job's steps to the person as a live progress tree. Call it once, as you begin a job of several stages, whichever agent you are; skip it for a short job. The first step starts by itself. Answers each step's id (1, 1.2, 1.2.1).",
27 inputSchema: {
28 type: 'object',
29 properties: {
30 title: { type: 'string', description: 'The job, in a few words' },
31 steps: {
32 type: 'string',
33 description: 'One step a line; indent a step under its parent with two spaces',
34 },
35 },
36 required: ['title', 'steps'],
37 },
38 },
39 {
40 name: 'step',
41 description:
42 'Updates one leaf step of the plan. start also ends any step still running before it in the plan, so moving on needs only start. The last step of a subagent is done by itself when it answers; the main thread marks its last step done. Parents follow their steps; times are kept for you.',
43 inputSchema: {
44 type: 'object',
45 properties: {
46 id: { type: 'string', description: 'The leaf step, as plan answered it: 1.2.1' },
47 state: { type: 'string', enum: WORDS },
48 run: { type: 'string', description: 'Only with several plans open: the run plan answered' },
49 },
50 required: ['id', 'state'],
51 },
52 },
53]
54
55/**
56 * What tells an agent the watch tool is there, with the GitHub checks
57 * setting on: beside the note above, or alone.
58 */
59export const WATCH_NOTE =
60 'When you wait on GitHub checks (the CI of a pull request, a deploy or any other workflow run, the checks of a branch you pushed), call mcp__deck__watch once in place of polling in a shell (load it with ToolSearch if its schema is not loaded): the person follows each check in the Deck, and with wake you get a message when they are over.'
61
62/** The watch note's id among the system prompt's sections. */
63export const WATCH_SECTION = 'deck:watch'
64
65export const WATCH_TOOL: ToolSpec = {
66 name: 'watch',
67 description:
68 "Shows GitHub checks to the person in the Deck as they run: a pull request's CI, a workflow run's jobs (a deploy), a branch's or a commit's checks. Call it once after you push, open a pull request or start a deploy, in place of polling. With wake, a message tells you when they are over, so you can end your turn.",
69 inputSchema: {
70 type: 'object',
71 properties: {
72 target: {
73 type: 'string',
74 description:
75 'A pull request (12, #12 or its address), a workflow run (its id or address), a branch or a commit. Leave out for the branch you are on',
76 },
77 wake: { type: 'boolean', description: 'True to be told when the checks are over' },
78 },
79 },
80}
81hooks/values.ts 8 lines1// Readers for values that come from outside the module: the store's JSON,
2// a setting, an event's loosely typed field.
3
4export const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
5 typeof value === 'object' && value !== null && !Array.isArray(value)
6
7export const toText = (value: unknown): string => (typeof value === 'string' ? value : '')
8hooks/view.ts 52 lines1// What the label and the pane say, as text.
2
3import type { Job, Meter, Work } from '../types'
4
5import { effortText, modelName } from './meter'
6
7const two = (count: number): string => String(count).padStart(2, '0')
8
9/** `48s`, `1m 12s`, `14m 03s`, `1h 04m`. */
10export const spanText = (ms: number): string => {
11 const seconds = Math.max(0, Math.floor(ms / 1000))
12 const minutes = Math.floor(seconds / 60)
13
14 if (minutes === 0) {
15 return `${seconds}s`
16 }
17
18 return minutes < 60
19 ? `${minutes}m ${two(seconds % 60)}s`
20 : `${Math.floor(minutes / 60)}h ${two(minutes % 60)}m`
21}
22
23/** How long the job has run, or ran. */
24export const jobSpan = (job: Job, now: number): string =>
25 spanText((job.endedAt === 0 ? Math.max(now, job.startedAt) : job.endedAt) - job.startedAt)
26
27export const GLYPHS: Readonly<Record<Job['status'], string>> = {
28 running: '⏵',
29 done: '✓',
30 failed: '✗',
31 killed: '■',
32 ended: '·',
33}
34
35/**
36 * The hint line's label: `Fable 5.1 ▰▰▰▱▱ high · ⏵ 3`. The model and its
37 * effort once known, then how many shells and agents run, then what the
38 * caller adds (a run's progress).
39 */
40export const labelText = (meter: Meter, work: Work, more: readonly string[] = []): string => {
41 const model = [modelName(meter.model), effortText(meter)].filter((part) => part !== '')
42 // A shell Claude Code asks the person about is not counted as running.
43 const running = work.running.filter((job) => !job.isAsking).length
44 const parts = [
45 model.join(' '),
46 running > 0 ? `${GLYPHS.running} ${running}` : '',
47 ...more,
48 ].filter((part) => part !== '')
49
50 return parts.length === 0 ? '✻ deck' : parts.join(' · ')
51}
52hooks/work.ts 187 lines1// The shells and agents the deck lists: what runs, and what lately ended.
2// Plain functions over plain values; the hooks module calls them with what
3// the events said.
4
5import type { Job, JobStatus, Work } from '../types'
6
7export const NO_WORK: Work = { running: [], recent: [] }
8
9/** How many ended jobs are kept. */
10const KEPT = 8
11/** A foreground command that went well is kept only when it ran this long. */
12const WORTH_MS = 3000
13const TITLE_CHARS = 80
14const DETAIL_CHARS = 200
15
16const STATUSES: Readonly<Record<string, JobStatus>> = {
17 completed: 'done',
18 failed: 'failed',
19 killed: 'killed',
20}
21
22/** A notification's word for how its task ended, as a status. */
23export const statusOf = (word: string): JobStatus => STATUSES[word] ?? 'ended'
24
25/** One printable line of at most `chars`. */
26const lineOf = (text: string, chars = TITLE_CHARS): string => {
27 const line = [...(text.trim().split('\n')[0] ?? '').replace(/\s+/g, ' ')]
28
29 return line.length > chars ? `${line.slice(0, chars - 1).join('')}…` : line.join('')
30}
31
32/** A shell's command as its opened row shows it: the first line. */
33export const shellDetail = (command: string): string => lineOf(command, DETAIL_CHARS)
34
35/** A shell's row: what the call said it does, or the command where it said nothing. */
36export const shellTitle = (description: string | undefined, command: string): string =>
37 lineOf(description ?? '') || lineOf(command) || 'shell'
38
39/** An agent's row, as Claude Code names a subagent: `type(task)`. */
40export const agentTitle = (type: string, description: string): string => {
41 const task = lineOf(description)
42
43 return task === '' ? type : `${type}(${task})`
44}
45
46export const started = (work: Work, job: Job): Work => ({
47 running: [...work.running.filter((one) => one.id !== job.id), job],
48 recent: work.recent.filter((one) => one.id !== job.id),
49})
50
51/** The job went to the background under this task id, and runs on. */
52export const backgrounded = (work: Work, id: string, taskId: string, at: number): Work => ({
53 ...work,
54 running: work.running.map((job) =>
55 job.id === id ? { ...job, taskId, ...(job.isAsking ? { isAsking: false, startedAt: at } : {}) } : job,
56 ),
57})
58
59/** Claude Code asks the person whether this shell may run. */
60export const asked = (work: Work, id: string): Work => ({
61 ...work,
62 running: work.running.map((job) => (job.id === id ? { ...job, isAsking: true } : job)),
63})
64
65/**
66 * The shell the person was asked about runs since `at`: its clock starts
67 * there. One nobody asked about keeps the start its call had.
68 */
69export const begun = (work: Work, id: string, at: number): Work => ({
70 ...work,
71 running: work.running.map((job) =>
72 job.id === id && job.isAsking ? { ...job, isAsking: false, startedAt: Math.max(job.startedAt, at) } : job,
73 ),
74})
75
76const isWorthKeeping = (job: Job): boolean =>
77 job.status !== 'done' ||
78 job.kind === 'agent' ||
79 job.taskId !== '' ||
80 job.endedAt - job.startedAt >= WORTH_MS
81
82const closed = (work: Work, job: Job, status: JobStatus, at: number): Work => {
83 // One that ended while the person was still asked ran under the two seconds
84 // that show it began, or never ran: the wait is not its time.
85 const from = job.isAsking ? Math.max(at, job.startedAt) : job.startedAt
86 const ended = { ...job, status, startedAt: from, endedAt: Math.max(at, from), isAsking: false }
87 const running = work.running.filter((one) => one.id !== job.id)
88
89 return isWorthKeeping(ended)
90 ? { running, recent: [ended, ...work.recent].slice(0, KEPT) }
91 : { running, recent: work.recent }
92}
93
94/** The running job of this id ended, newest first among the recent. */
95export const ended = (work: Work, id: string, status: JobStatus, at: number): Work => {
96 const job = work.running.find((one) => one.id === id)
97
98 return job === undefined ? work : closed(work, job, status, at)
99}
100
101/** The job never ran (its call was refused): it leaves no row. */
102export const dropped = (work: Work, id: string): Work => ({
103 ...work,
104 running: work.running.filter((job) => job.id !== id),
105})
106
107/** A background task's end, as Claude Code reports it: which task, and how it ended. */
108export type Notice = { taskId: string; status: JobStatus }
109
110/**
111 * A background task was reported ended. One that still runs ends now; one
112 * closed before with no word on how takes the status it is given here.
113 */
114export const reported = (work: Work, notice: Notice, at: number): Work => {
115 const job = work.running.find((one) => one.taskId === notice.taskId)
116
117 if (job !== undefined) {
118 return closed(work, job, notice.status, at)
119 }
120
121 return {
122 ...work,
123 recent: work.recent.map((one) =>
124 one.taskId === notice.taskId && one.status === 'ended'
125 ? { ...one, status: notice.status }
126 : one,
127 ),
128 }
129}
130
131/**
132 * Closes the background shells Claude Code no longer lists as in flight:
133 * they ended with no notification this session saw.
134 */
135export const settled = (work: Work, alive: readonly string[], at: number): Work =>
136 work.running
137 .filter((job) => job.kind === 'shell' && job.taskId !== '' && !alive.includes(job.taskId))
138 .reduce((left, job) => closed(left, job, 'ended', at), work)
139
140/**
141 * Every job still running ends here with no status: the deck was closed and
142 * sees no end from now on, so none runs on in the pane when it comes back.
143 */
144export const endedAll = (work: Work, at: number): Work =>
145 work.running.reduce((left, job) => closed(left, job, 'ended', at), work)
146
147/** An agent that ended is at work again (a message woke it): its row runs on. */
148export const revived = (work: Work, id: string): Work => {
149 const job = work.recent.find((one) => one.id === id && one.kind === 'agent')
150
151 return job === undefined
152 ? work
153 : {
154 running: [...work.running, { ...job, status: 'running', endedAt: 0 }],
155 recent: work.recent.filter((one) => one.id !== id),
156 }
157}
158
159/**
160 * The task ran this long by its own notification, which is delivered late
161 * while a turn runs. A notification that names no length changes nothing.
162 */
163export const timed = (work: Work, taskId: string, ms: number): Work =>
164 ms < 0
165 ? work
166 : {
167 ...work,
168 recent: work.recent.map((job) =>
169 job.taskId === taskId ? { ...job, endedAt: job.startedAt + ms } : job,
170 ),
171 }
172
173/** An agent's model or effort became known. */
174export const tuned = (work: Work, id: string, patch: { model?: string; effort?: string }): Work => ({
175 ...work,
176 running: work.running.map((job) => (job.id === id ? { ...job, ...patch } : job)),
177})
178
179/** The ended rows taken away: all of them, or those that ended before a time. */
180export const cleared = (work: Work, before = Number.POSITIVE_INFINITY): Work => ({
181 ...work,
182 recent: work.recent.filter((job) => job.endedAt >= before),
183})
184
185export const countOf = (work: Work, kind: Job['kind']): number =>
186 work.running.filter((job) => job.kind === kind).length
187types/index.d.ts 143 lines1// What the mod keeps for the session, in memory: `$.state` holds each of
2// these, and none of it is written to disk.
3
4export type JobKind = 'shell' | 'agent'
5
6/** `ended` is an end nobody reported: neither done nor failed is known. */
7export type JobStatus = 'running' | 'done' | 'failed' | 'killed' | 'ended'
8
9/** One shell command or one subagent, running or lately ended. */
10export type Job = {
11 /** The call's id for a shell, the agent's id for a subagent. */
12 id: string
13 kind: JobKind
14 /** A shell's description, or its command where it has none; an agent's type and task. */
15 title: string
16 startedAt: number
17 /** 0 while it runs. */
18 endedAt: number
19 status: JobStatus
20 /** The background task's id, as its notification names it; empty in the foreground. */
21 taskId: string
22 /** A shell's command, its first line; shown when the row is opened. Empty for an agent. */
23 detail: string
24 /** An agent's model, as its spawn answered it; empty for a shell. */
25 model: string
26 /** An agent's effort, as its last request carried it; empty when unknown. */
27 effort: string
28 /** The agent whose loop started it, by id; empty when the main thread did. */
29 owner: string
30 /**
31 * True while Claude Code asks the person whether a shell may run: its clock
32 * starts once it does.
33 */
34 isAsking: boolean
35}
36
37export type Work = { running: Job[]; recent: Job[] }
38
39export type Meter = {
40 /** The main thread's model, as its last request named it. */
41 model: string
42 /** The effort Claude Code set on that request; empty for a model without one. */
43 effort: string
44 /** False until the main thread has made a request this session. */
45 isSeen: boolean
46 /** The level a click asked for; empty while Claude Code's own stands. */
47 wanted: string
48 /** Claude Code's effort when the click was made: a change of it is `/effort`. */
49 over: string
50 /** The context window's fill, 0 to 100; -1 before it is known. */
51 context: number
52 /** What the session has cost in US dollars; -1 where Claude Code keeps no count. */
53 cost: number
54 /** The rate-limit windows the last response reported. */
55 limits: Limit[]
56}
57
58/** One rate-limit window: `five_hour`, `seven_day` or `spend_limit`, and how full it is. */
59export type Limit = { kind: string; percent: number }
60
61export type StepStatus = 'pending' | 'running' | 'done' | 'failed'
62
63/** One line of a run's plan. Its id is its place: `1`, `1.2`, `1.2.1`. */
64export type Step = {
65 id: string
66 title: string
67 /** A leaf's own state; a parent's is told from its leaves. */
68 status: StepStatus
69 /** 0 until it starts. */
70 startedAt: number
71 /** 0 until it ends. */
72 endedAt: number
73}
74
75/** Which feed a run comes from: Claude Code's task list, the mod's tool, or GitHub's checks. */
76export type RunFeed = 'tasks' | 'plan' | 'checks'
77
78/** One job's steps, in the order they were planned. */
79export type Run = {
80 id: string
81 title: string
82 feed: RunFeed
83 /** The agent whose loop opened it, by id; empty for the main thread. */
84 loop: string
85 /** That agent's name, for the row; empty for the main thread. */
86 owner: string
87 steps: Step[]
88 /** When a step of it last changed: the newest unfinished run is the one shown open. */
89 touchedAt: number
90 /** When the agent that opened it ended with steps left; 0 while it can still move. */
91 stoppedAt: number
92}
93
94/** One thing followed on GitHub, shown as a run: a commit's checks, or a workflow run's jobs. */
95export type Watch = {
96 /** The run that shows it, by id. */
97 run: string
98 kind: 'ref' | 'run'
99 /** The GitHub host, `github.com` or a company's own. */
100 host: string
101 /** `owner/name`. */
102 repo: string
103 /** A branch, a tag, a commit or `pull/12/head`; for a workflow run, its id. */
104 target: string
105 /** Its page on GitHub. */
106 url: string
107 /** True where Claude asked to be told when it is over. */
108 wake: boolean
109 startedAt: number
110 /** How many polls in a row found every check over. */
111 settled: number
112 /** How many polls in a row GitHub did not answer. */
113 failures: number
114}
115
116export type Switches = {
117 /** `/deck close`: the label and the pane away in every session. */
118 isClosed: boolean
119 /** The label's row closed with its `×`: the label is text on the hint line. */
120 isRowClosed: boolean
121}
122
123declare module 'claude-code' {
124 interface PluginState {
125 deck: {
126 work: Work
127 meter: Meter
128 /** The clock as the last tick read it: the open pane draws again with it. */
129 now: number
130 switches: Switches
131 runs: Run[]
132 /** What is followed on GitHub, with the GitHub checks setting on. */
133 watches: Watch[]
134 /** How long each of the last shell commands ran, by its call's id, for the transcript's rows. */
135 spans: Record<string, number>
136 /** The branches the person folded or unfolded, by `run/step`: true is open. */
137 folds: Record<string, boolean>
138 /** The cells the deck's pane takes from the screen's width while it is docked; 0 while it is not. */
139 docked: number
140 }
141 }
142}
143