btop-style live dashboard for Claude Code internals: /cctop opens it in a side pane, cctop-insights lets the session answer questions from its numbers

Two skills, plus a docked pane on builds with function hooks:
/cctop — on a build with function hooks enabled, opens the dashboard in a pane docked inside Claude Code itself. Otherwise opens it in a right-hand pane of the current terminal (tmux, zellij, WezTerm, Kitty, iTerm2) via cctop split. Offers cctop install once.cctop-insights — lets the session answer questions like "why is my cache hit ratio low?" or "what is filling my context?" by running cctop query … --json and reading the numbers.The binary first:
brew install tomstagl/tap/cctop # currently v0.9.1; or: cargo install cctop
Then the plugin, from a local checkout or the marketplace entry:
claude plugin add /path/to/cctop/plugin
# or, once published:
claude plugin add tomstagl/cctop
Restart Claude Code (or /reload-plugins) and type /cctop.
The pane needs function hooks, which shipped in Claude Code 2.1.269. Each plugin release is built against one Claude Code version — the TESTED_WITH the header's hooks … light shows — and runs on the releases between the last contract change and the next one:
| plugin | tested with | runs on |
|---|---|---|
| 0.2.0 – 0.4.0 | 2.1.269 / 2.1.270 | 2.1.269 – 2.1.270 only ($.clock.now() became a Promise in 2.1.271, issue #3) |
| 0.4.1 – 0.7.0 | 2.1.272 / 2.1.273 | 2.1.269 and later, as far as CI has seen (the module awaits every host call) |
| 0.8.0 | 2.1.274 | 2.1.269 – 2.1.274 ($.agent.register, position: "absolute" on Box, a Markdown element — additive, nothing the pane calls moved) |
| 0.9.0 | 2.1.278 | 2.1.269 – 2.1.284 (an Image element, sub-cell pointer coordinates, $.ui.panes() / $.ui.root(), $.prompt.read(), a budget on next and four events, and re-typed $.fs.read (a bytes form) and $.prompt.fill's result — additive, nothing the pane calls moved); says so at session start (cctop: plugin 0.10.0 (hooks contract 2.1.284) loaded; self-check ok) and writes testedWith into the marker, which cctop pane status pairs with claude --version |
| 0.10.0 | 2.1.284 | 2.1.269 and later, as far as CI has seen ($.process.spawn, truncation flags on $.process.run's result, startedAt on $.session.usage(), and $.ui.open resolving whether the pane was placed — additive; the poller's engine facade now takes run alone from $.process, so the new spawn is not required of it); says so at session start (cctop: plugin 0.10.0 (hooks contract 2.1.284) loaded; self-check ok) |
Function hooks are early access and change between Claude Code releases. CI checks the contract against the latest release daily (scripts/check-contract.sh), cctop pane status pairs the installed plugin with claude --version, and the module says at session start what it found (see "When the contract moves"). When a release breaks the pane, the fix is a plugin release; claude plugin marketplace update cctop && claude plugin update cctop@cctop, then restart Claude Code.
With function hooks enabled, /cctop-pane docks the dashboard beside the transcript instead of opening a terminal split. It is drawn the way the standalone TUI draws it — Console: the header, six cells, the act line, the rule line and one body, row for row; round frames titled ╭5 Tools ─ 257 calls╮ in the other views, gauges (▇▇▇▁▁▁) coloured by band, dim secondary text — in Claude Code's own theme colours, so it follows light and dark. The cells, the act line and 0: home are Claude Code's own clickable chrome: a plain Button per part, the digit it draws being the hotkey, the whole cell lit and pressed as one area. It shows the same views as the TUI:
| View | TUI | What it shows |
|---|---|---|
| Coach | c | The 56-column card: the state line, the four lights, the one nudge with [1 fill] [2 snooze] [3 why], what is next and what is snoozed, the detail frame of the highest light |
| Overview | the dashboard | Console: the header, the six cells (1–6), the act line (a), the rule line (0 home, ? keys), the open body in place — every figure the TUI's nine panels carry, one body at a time |
| Tools | 5 | Calls, errors, p50/p95, tokens pushed into context, per tool |
| Agents | 6 | Subagents, MCP servers, background tasks |
| Files | 7 | Touched files, edits, re-reads |
| Events | 8 | Tool / hook / permission / compaction / coach / cost stream |
| Advisor | 9 | The slot's occupant, then what is queued and what is snoozed |
The Coach and Overview views draw cctop query coach and cctop query dashboard verbatim — the same objects the TUI draws — so the pane and the terminal show the same nudge at the same moment. The frames inside the other views carry the TUI's panel digits (╭5 Tools ─ 257 calls╮), the same numbers the guide and cctop query use. [1 fill] writes a prompt-class action into the prompt box ($.prompt.fill) and never submits it; the coach's one-line form sits under the prompt ($.ui.status) and changes only when a light's level or the nudge changes.
/cctop-pane [view|close] opens the pane (optionally straight to a view — one of coach, overview, tools, agents, files, events, advisor), or closes it; with no argument it toggles.
The bar cctop Coach Overview Tools Agents Files Events Advisor is the pane's first row, the current view drawn inverse. Click another name to switch, or give the pane the keyboard with ctrl+x tab, move with tab / shift+tab, press enter, and leave with esc. /cctop-pane <view> switches without either. Nothing is drawn above the prompt: Claude Code honours a Button's hotkey only in the band there, and that band cost the transcript a row, so the pane has no digit hotkeys — a digit typed into the composer is yours.
Context, cost and rate limits come from the engine itself, so the pane is useful with nothing installed; once the cctop binary is found, the rest (the lights, the nudge, tool timings, files, agents, the Advisor) is filled in from cctop query.
Every open answers with where the pane went, so the state is never a guess:
| reply | meaning |
|---|---|
cctop pane docked beside the transcript (71 columns): … | in the side dock |
cctop pane drawn above the prompt: the terminal is 100 columns wide, 110 or more dock it … | inline, terminal too narrow |
cctop pane drawn above the prompt: /tui fullscreen docks it … | inline, classic renderer |
cctop pane is open but not shown: the /diff panel holds the side dock. Run /diff … | hidden behind the diff panel |
/clear and the session id/clear starts a new transcript under a new session id in the same Claude Code process, and the plugin API fires no session.start for it. The pane reads the id again on every turn and every poll, so after a /clear it follows the new session: the old session's figures are dropped, the context is re-read from the engine, cctop query is run for the new id at once, and the marker file of the old id says open: false while the new id gets its own. Before 0.3.1 the pane kept the first id and every query then failed with no session matches for the rest of the process (issue #2).
Claude Code 2.1.271 turned $.clock.now() from a number into a host event that resolves a Promise. Plugin 0.4.0 did arithmetic on it, so on 2.1.271 and later every hook failed — marker write failed: RangeError: Invalid Date at the start of every session, every light on waiting for cctop once the pane was open, and cctop pane status reporting the hooks module as not loaded (issue #3). Plugin 0.4.1 awaits every reading and runs on 2.1.270 and 2.1.272 alike; the header's hooks … light names the Claude Code version the module's contract was generated from (2.1.284 for plugin 0.10.0; 2.1.273 added a vscode render surface, 2.1.274 $.agent.register and absolute Box placement, 2.1.278 an Image element, $.prompt.read() and a bytes form of $.fs.read, 2.1.284 $.process.spawn and a placement result from $.ui.open — none moved anything the pane calls). Update with claude plugin marketplace update cctop && claude plugin update cctop@cctop, then restart Claude Code.
Function hooks are early access and change between Claude Code releases (issue #4). At session.start the module checks the surfaces it cannot do without — the clock resolves a number, HOME is set, the session has an id — and writes one line to the debug log (claude --debug, ~/.claude/debug/latest) before anything else:
cctop: plugin 0.10.0 (hooks contract 2.1.284) loaded; self-check ok
When a surface moved, the line reads self-check failed: <what> with the update command, the marker carries the same text under selfCheck, and cctop pane status relays it on the hooks-module line. Every clock reading falls back to the environment's own Date.now(), so the pane keeps its books on a changed clock instead of failing every hook as 0.4.0 did.
/diff panel and the pane share one dockClaude Code's built-in /diff panel and a plugin pane occupy the same right-hand slot, and the diff panel wins: while it shows, an open cctop pane is drawn nowhere. The plugin notices (no render arrives) and pins a status line under the prompt — cctop pane hidden behind the /diff panel: run /diff to show it — until /diff hides the diff panel again, at which point the cctop pane reappears where it was. The engine gives a plugin no other signal for this; the mechanism is written up in docs/claude-code-panels.md.
cctop pane statusWhen the pane does not appear, cctop pane status (run from inside the session, or with --session <id>) prints one line per prerequisite with the fix after →, and exits 0 when the hooks module runs in that session, 2 otherwise:
cctop pane status · session ab339470
✓ Claude Code 2.1.272 (function hooks need 2.1.269 or newer)
✗ function hooks off → add "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" to the "env" block of ~/.claude/settings.json (or export it in the shell), then restart Claude Code
✗ installed cctop plugin 0.1.0 has no hooks module (skills only; 0.2.0 or newer ships it) → `claude plugin update cctop@cctop` (or start with `claude --plugin-dir <checkout>/plugin`), then restart Claude Code
✓ fullscreen renderer (tui = "fullscreen" in ~/.claude/settings.json)
✓ terminal 162 columns (110 or more dock the pane)
! the /diff panel was open when last toggled (diffSidebarOpen in ~/.claude.json); while it shows, it holds the side dock → if it is showing, run /diff to hide it; cctop takes the dock
→ not ready: fix the ✗ lines, restart Claude Code, then run /cctop-pane
The /cctop skill runs it first and relays the lines verbatim before it falls back to cctop split, so a session without the pane still tells you exactly why. --json prints the same report as data.
Once a plugin with the hooks module is installed, a compatibility line pairs it with the Claude Code that runs it. The plugin version is the one the session loaded (the marker's) when the module runs, else the installed one; what it was tested with is the module's TESTED_WITH (the header's hooks … light), read from the marker or from the install's hooks/model.ts:
✓ cctop plugin 0.10.0 tested with Claude Code 2.1.284
! Claude Code 2.1.290 is newer than cctop plugin 0.10.0 was tested with (2.1.284); function hooks are early access and change between releases → `claude plugin marketplace update cctop && claude plugin update cctop@cctop` when a newer plugin is out, then restart Claude Code; if a hook fails meanwhile, report it with both versions
✗ cctop plugin 0.4.0 does not work on Claude Code 2.1.272: 2.1.271 made `$.clock.now()` resolve a Promise and the module did arithmetic on it, so every hook failed (issue #3) → `claude plugin marketplace update cctop && claude plugin update cctop@cctop` (plugin 0.4.1 or newer), then restart Claude Code
The ✗ comes from a short table of pairs known to fail (KNOWN_INCOMPATIBLE in src/pane.rs), checked before the TESTED_WITH comparison; the ! is the general case, a Claude Code the module has not been tested against. The table ships with the cctop binary, so the line is only as current as brew upgrade cctop.
Function hooks are early access. Put the flag where every session sees it — the env block of ~/.claude/settings.json — and restart Claude Code:
{ "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }
(CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude does the same for one start.) Then /tui fullscreen (the classic renderer draws the pane's short form inline above the prompt instead of docking it) and a terminal of 110 columns or more. The first load asks you to accept the plugin's hooks module once, in /plugin. The plugin must be 0.2.0 or newer — claude plugin update cctop@cctop — since 0.1.0 shipped skills only.
Without function hooks (or on a build too old for them), /cctop falls back to cctop split, splitting the current terminal multiplexer instead of docking inside Claude Code. If no multiplexer is detected, it prints the manual command for a second terminal.
Automated (typecheck, tests, claude plugin validate --strict, cargo test) runs on every change and is green. On 2026-09-14 an agent drove a real Claude Code 2.1.270 in a 162×45 tmux window (see the run notes in docs/verification/pane.md): the pane docked, /diff hid it and the status line said so, /diff again brought it back. On 2026-09-15 the headless load check ran on Claude Code 2.1.272 with plugin 0.4.1 (issue #3): the module loaded, no hook failed, and the marker was written for a session whose pane was never opened. The Result: lines below are still for a person to fill in:
/cctop-pane listed in the slash menu with its description/tui fullscreen/tui fullscreen/tui default/cctop-pane toggles open/closed/cctop-pane close closes the pane/cctop-pane tools opens straight to the Tools view/cctop:cctop (the skill) still resolves, and answers the one-liner when the pane is already openctrl+x tab + tab + enter switch viewsctrl+x arrows resize the pane and persist pluginPanes.dockColumnscctop query advice --session <id>/cctop-pane opens in under 500 ms/reload-plugins reopens the pane on its previous view--plugin-dir differs by under 1 %/cctop still splits in tmux and opens a new window in Apple Terminal~/.cctop/pane/<id>.json toggles open on open/closecctop split short-circuits when the pane is already open/cctop falls back to cctop split when there is no fresh open marker/diff over a docked pane hides it and pins the status line; /diff again restores the panecctop pane status from inside a session reports every line ✓ once the pane is docked, and names the missing prerequisites otherwise/clear under an open pane: the next turn's queries run for the new session id, the old id's marker says open: false, the new id's open: true, and no no session matches line appears in the debug logcctop: failure, its marker says loaded: true, cctop pane status shows the module ✓, and /cctop fills every light within one pollSee the checklist for the exact setup, keys and expected observation for each.
Nothing rides in the prompt except the two one-line skill entries. Queries are only run when you ask; the skill never injects metrics unprompted.
hooks/pane.tsx 854 lines1// cctop pane: the dashboard drawn inside Claude Code's own TUI through the
2// function-hooks API (early access). It registers the /cctop-pane command,
3// opens the pane, draws it, and keeps a Model of the session up to date from
4// the engine's own events (model.ts) and, when the binary is installed, from
5// `cctop query` through the poller (poller.ts). The views (views/*.tsx) draw
6// the model; the Overview is the default.
7import type { ElementTable, EngineInterface, Register, RenderElement, RenderInput, Timer, ToolCallResult } from 'claude-code';
8import {
9 HIDDEN_STATUS,
10 initialModel,
11 outcomeText,
12 reduce,
13 unsupportedVerbs,
14 UNSUPPORTED,
15 TESTED_WITH,
16 type Action,
17 type Binary,
18 type Model,
19 type View,
20} from './model';
21import { createPoller, writeMarker, type Poller, type PollerEngine } from './poller';
22import { coachOf, statusLine, type CoachActions, type LightId } from './views/coach';
23import { renderView } from './views/index';
24import { badgesLine, header, type OverviewActions } from './views/overview';
25
26// Re-exported so the checked-in `$` contract's own version (US-009) has one
27// source (`model.ts`, already imported by both this file and the views) and
28// this file, which the header badge names, still carries the export.
29export { TESTED_WITH };
30
31const PANE_ID = 'cctop';
32// The native command is /cctop-pane, not /cctop: the engine reserves /cctop
33// for this plugin's own skill (listed as /cctop:cctop) and refuses a
34// $.command.register of that name (PRD open question 1, resolved 2026-09-12).
35// The skill stays the fallback path for builds without function hooks.
36const COMMAND = 'cctop-pane';
37const USAGE_POLL_MS = 1000;
38const VERSION_TIMEOUT_MS = 3000;
39// The least gap between two `$.ui.invalidate("ui.render")` calls: at most
40// four a second, the engine folds further (ten a second).
41const RENDER_MIN_MS = 250;
42// How long an open waits for the engine's first `ui.render` before the pane
43// is reported as not shown: the dock mounts and asks for the tree within a
44// few frames; nothing arrives at all while the /diff panel holds the dock.
45export const OPEN_SETTLE_MS = 800;
46// After an invalidate, how long without a render before an open pane counts
47// as hidden (the /diff panel took the dock after the open) and the status
48// line under the prompt says so.
49export const HIDDEN_AFTER_MS = 2000;
50const INSTALL_HINT = 'needs the cctop binary: brew install tomstagl/tap/cctop';
51// The views in view-bar order; the `view` is the /cctop-pane argument.
52const VIEWS: { view: View; label: string }[] = [
53 { view: 'coach', label: 'Coach' },
54 { view: 'overview', label: 'Overview' },
55 { view: 'tools', label: 'Tools' },
56 { view: 'agents', label: 'Agents' },
57 { view: 'files', label: 'Files' },
58 { view: 'events', label: 'Events' },
59 { view: 'advisor', label: 'Advisor' },
60];
61const USAGE = `usage: /${COMMAND} [${VIEWS.map((v) => v.view).join('|')}|close]`;
62// The `$.store` key under which `{ open, view }` survives a reload: an
63// interactive session.start reopens the pane on that view.
64const STORE_KEY = 'pane';
65const MANIFEST = '.claude-plugin/plugin.json';
66
67// Module state: one Model per loaded module (a hot reload starts a fresh
68// environment, and `register` resets it). The helpers that take `$` are
69// top-level functions: the validator refuses `$` handed to a closure.
70let model: Model = initialModel();
71// `$.session.usage()` is read from this timer alone while a turn runs, and
72// once after session.start and turn.complete: never inside a hook's own
73// path before `next(e)`, so the pane costs the turn nothing.
74let usageTimer: Timer | null = null;
75// The query poller, built at session.start and run while the pane is open
76// and the binary is present.
77let poller: Poller | null = null;
78// The redraw throttle: when the last invalidate was, the timer holding the
79// trailing one back while changes come faster than RENDER_MIN_MS, and
80// whether a request is reading the clock right now (changes that land
81// meanwhile fold into it).
82let invalidatedAt: number | null = null;
83let renderTimer: Timer | null = null;
84let renderPending = false;
85// Visibility watch: the timer that calls the pane hidden when an invalidate
86// draws no render, and the resolvers of opens waiting for their first render.
87let hiddenTimer: Timer | null = null;
88let renderWaiters: (() => void)[] = [];
89// Whether HIDDEN_STATUS is pinned under the prompt right now.
90let statusPinned = false;
91
92// Asks for a redraw as of `now` (the clock's answer, or the trailing timer's
93// due time) and arms the hidden watch.
94function invalidateAt($: EngineInterface, now: number): void {
95 invalidatedAt = now;
96 $.ui.invalidate('ui.render');
97 watchForRender($);
98}
99
100// An unthrottled redraw (an open, a view switch). `$.clock.now()` is a host
101// round trip since Claude Code 2.1.271 (issue #3), so this resolves once the
102// request is made.
103function invalidateNow($: EngineInterface): Promise<void> {
104 return clockNow($).then((now) => invalidateAt($, now));
105}
106
107// Arms (or re-arms) the hidden watch: if the engine asks for no tree within
108// HIDDEN_AFTER_MS of this invalidate, the open pane is not being drawn.
109function watchForRender($: EngineInterface): void {
110 if (!model.open) return;
111 hiddenTimer?.cancel();
112 hiddenTimer = $.clock.after(HIDDEN_AFTER_MS, () => {
113 hiddenTimer = null;
114 markHidden($);
115 });
116}
117
118function stopHiddenTimer(): void {
119 hiddenTimer?.cancel();
120 hiddenTimer = null;
121}
122
123// The pane is open but the engine draws it nowhere: pin the status line
124// (once per transition) and tell the marker.
125function markHidden($: EngineInterface): void {
126 if (!model.open || model.visibility === 'hidden') return;
127 model = reduce(model, { type: 'visibility', visibility: 'hidden' });
128 $.ui.status(HIDDEN_STATUS);
129 statusPinned = true;
130 updateMarker($);
131}
132
133function unpinStatus($: EngineInterface): void {
134 if (!statusPinned) return;
135 $.ui.status(undefined);
136 statusPinned = false;
137 model = reduce(model, { type: 'coach.status', status: null });
138 coachChanged($);
139}
140
141// A render arrived: the pane is drawn, here and this wide. Settles every
142// open waiting on it and lifts the hidden status if it was pinned.
143function noteRender($: EngineInterface, e: RenderInput<'Pane'>, now: number): void {
144 const wasHidden = model.visibility === 'hidden';
145 model = reduce(model, {
146 type: 'render',
147 at: now,
148 placement: e.props.placement,
149 bodyColumns: e.props.bodyColumns,
150 viewportColumns: e.viewport?.columns ?? null,
151 });
152 stopHiddenTimer();
153 unpinStatus($);
154 // The width may have changed the status line's form.
155 coachChanged($);
156 const waiters = renderWaiters;
157 renderWaiters = [];
158 for (const resolve of waiters) resolve();
159 if (wasHidden) updateMarker($);
160}
161
162// Resolves on the next render of the pane, or after OPEN_SETTLE_MS without
163// one (then the pane is marked hidden), so an open can answer with where the
164// pane actually went.
165function awaitRender($: EngineInterface): Promise<void> {
166 return new Promise<void>((resolve) => {
167 let settled = false;
168 const done = (): void => {
169 if (settled) return;
170 settled = true;
171 timer.cancel();
172 resolve();
173 };
174 const timer = $.clock.after(OPEN_SETTLE_MS, () => {
175 markHidden($);
176 done();
177 });
178 renderWaiters.push(done);
179 });
180}
181
182// Asks for a redraw at most every RENDER_MIN_MS: a change inside the gap
183// arms one trailing call for the end of it, later changes fold into that.
184// Runs under every model change, and the gap is measured on the host's
185// clock, a round trip: `renderPending` is set before the first await, so the
186// changes that land while one request reads the clock fold into it instead
187// of each arming a timer. The trailing timer fires at the gap's end by
188// construction, so it needs no second reading.
189async function requestRender($: EngineInterface): Promise<void> {
190 if (renderTimer !== null || renderPending) return;
191 renderPending = true;
192 try {
193 const now = await clockNow($);
194 const waited = invalidatedAt === null ? RENDER_MIN_MS : now - invalidatedAt;
195 if (waited >= RENDER_MIN_MS) {
196 invalidateAt($, now);
197 return;
198 }
199 const due = now + RENDER_MIN_MS - waited;
200 renderTimer = $.clock.after(RENDER_MIN_MS - waited, () => {
201 renderTimer = null;
202 invalidateAt($, due);
203 });
204 } finally {
205 renderPending = false;
206 }
207}
208
209function stopRenderTimer(): void {
210 renderTimer?.cancel();
211 renderTimer = null;
212}
213
214function replaceModel($: EngineInterface, next: Model): void {
215 const before = model;
216 model = next;
217 if (model.open) requestRender($).catch((err: unknown) => $.ui.log(`cctop: redraw failed: ${String(err)}`));
218 if (model.query.coach !== before.query.coach) coachChanged($);
219}
220
221// The coach's own surfaces beyond the pane, kept from the last `cctop
222// query coach`: the status line under the prompt (L0 / L1 / L2 by the
223// pane's width, set again only when it changes) and the one toast the
224// coach raises — a NOW-class nudge taking the slot, once per fire and at
225// most once per turn. The hidden notice keeps the status line while the
226// pane is not drawn.
227function coachChanged($: EngineInterface): void {
228 const c = coachOf(model.query.coach);
229 if (c === null) return;
230 // The width decides the form, so nothing is pinned before the first render.
231 if (model.open && !statusPinned && model.bodyColumns !== null) {
232 const status = statusLine(c, model.bodyColumns);
233 if (status !== model.coachStatus) {
234 $.ui.status(status);
235 model = reduce(model, { type: 'coach.status', status });
236 }
237 }
238 const n = c.nudge;
239 if (n !== null && n.cls === 'NOW') {
240 const key = `${n.id}:${n.firedAt ?? 0}`;
241 if (key !== model.coachToasted && model.coachToastTurn !== model.turn.number) {
242 $.ui.toast(n.line1);
243 model = reduce(model, { type: 'coach.toasted', key, turn: model.turn.number });
244 }
245 }
246}
247
248// What Console's targets do: a cell, the act line or `0 home` opens its
249// body in place; the header never moves.
250function overviewActions($: EngineInterface): OverviewActions {
251 return {
252 open: (id) => apply($, { type: 'overview.body', id }),
253 keys: (keys) => apply($, { type: 'overview.keys', keys }),
254 };
255}
256
257// What the agents view's Buttons do: a run's row opens its detail, `back` the list (view state).
258function agentsActions($: EngineInterface): { open(run: string | null): void } {
259 return { open: (run) => apply($, { type: 'agents.run', run }) };
260}
261
262// What the coach view's Buttons do: `fill` writes a prompt- or slash-class
263// action into the prompt box (never submits it), `snooze` asks the binary
264// (queued for the TUI while it runs) and re-polls, `why` and `light` are
265// view state.
266function coachActions($: EngineInterface): CoachActions {
267 return {
268 fill: (text) => {
269 $.prompt
270 .fill({ text })
271 .then(({ isFilled }) => {
272 if (!isFilled) $.ui.toast('cctop: the prompt box is busy — the action was not filled');
273 })
274 .catch((err: unknown) => $.ui.log(`cctop: prompt.fill failed: ${String(err)}`));
275 },
276 snooze: (rule) => {
277 const id = model.sessionId;
278 if (id === null) return;
279 $.process
280 .run(['cctop', 'query', 'coach', '--snooze', rule, '--session', id, '--surface', 'pane'], { timeoutMs: 5000 })
281 .then((result) => {
282 if (result.exitCode !== 0) throw new Error(`exit ${result.exitCode}`);
283 const answer = JSON.parse(result.stdout) as { snooze?: unknown };
284 if (typeof answer.snooze === 'string') $.ui.toast(`cctop: ${answer.snooze}`);
285 apply($, { type: 'query', verb: 'coach', data: answer });
286 })
287 .catch((err: unknown) => $.ui.log(`cctop: snooze failed: ${String(err)}`));
288 },
289 why: () => apply($, { type: 'coach.why', why: !model.coachWhy }),
290 light: (light: LightId) => apply($, { type: 'coach.light', light }),
291 };
292}
293
294function apply($: EngineInterface, action: Action): void {
295 replaceModel($, reduce(model, action));
296}
297
298// Every clock reading of the module: the host's when it resolves a number,
299// else the environment's own `Date.now()`. A contract change in the clock
300// (2.1.271 made it resolve a Promise; issue #3) then costs the session its
301// engine timestamps, not every hook: the self-check names it once.
302function clockNow($: EngineInterface): Promise<number> {
303 return $.clock.now().then(
304 (at) => (typeof at === 'number' && Number.isFinite(at) ? at : Date.now()),
305 () => Date.now(),
306 );
307}
308
309// What session.start found of the `$` surfaces the module cannot do without:
310// the clock resolves a number, HOME is set (the marker's path), the session
311// has an id. Never throws; `now` is the clock's answer or the fallback.
312type SelfCheck = { now: number; problems: string[] };
313
314const describe = (value: unknown): string => (value === null ? 'null' : typeof value);
315const message = (err: unknown): string => (err instanceof Error ? err.message : String(err));
316
317async function selfCheck($: EngineInterface): Promise<SelfCheck> {
318 const problems: string[] = [];
319 let now = Date.now();
320 try {
321 const at = await $.clock.now();
322 if (typeof at === 'number' && Number.isFinite(at)) now = at;
323 else problems.push(`$.clock.now() resolved ${describe(at)}, not a number`);
324 } catch (err) {
325 problems.push(`$.clock.now() failed: ${message(err)}`);
326 }
327 try {
328 const home = await $.env.get('HOME');
329 if (typeof home !== 'string' || home === '') problems.push('$.env.get("HOME") is unset: no marker can be written');
330 } catch (err) {
331 problems.push(`$.env.get("HOME") failed: ${message(err)}`);
332 }
333 try {
334 const id = await $.session.id();
335 if (typeof id !== 'string' || id === '') problems.push(`$.session.id() answered ${describe(id)}, not a string`);
336 } catch (err) {
337 problems.push(`$.session.id() failed: ${message(err)}`);
338 }
339 return { now, problems };
340}
341
342// The one line the debug log gets at session.start: the module, the
343// contract it was built against and what the self-check found, so a
344// contract change reads as a cause (`cctop pane status` relays the marker's
345// `selfCheck`), not as a failure per hook.
346export function selfCheckLine(version: string | null, check: SelfCheck): string {
347 const who = `cctop: plugin ${version ?? '?'} (hooks contract ${TESTED_WITH}) loaded`;
348 if (check.problems.length === 0) return `${who}; self-check ok`;
349 return (
350 `${who}; self-check failed: ${check.problems.join('; ')} — ` +
351 `the Claude Code running this is newer than ${TESTED_WITH} and changed the contract, or the environment is unusual; ` +
352 'update the plugin (claude plugin marketplace update cctop && claude plugin update cctop@cctop) or report it with `claude --version`'
353 );
354}
355
356// After session.start's `next(e)`: the manifest first, so the line and the
357// marker name the version (a `-p` run used to write `version: null` — the
358// read lost the race), then the line, then the marker through noteSession.
359function announce($: EngineInterface, check: SelfCheck): Promise<void> {
360 return readVersion($).then(() => {
361 $.ui.log(selfCheckLine(model.version, check));
362 noteSession($);
363 });
364}
365
366// The slice of `$` the poller runs on: the validator follows `$` only into
367// functions declared in this file, so the calls are spelled here.
368function pollerEngine($: EngineInterface): PollerEngine {
369 return {
370 clock: { now: () => clockNow($), every: (ms, fn) => $.clock.every(ms, fn) },
371 process: { run: (argv, init) => $.process.run(argv, init) },
372 session: { id: () => $.session.id() },
373 fs: { write: (path, text) => $.fs.write(path, text) },
374 ui: { log: (text) => $.ui.log(text) },
375 home: () => $.env.get('HOME'),
376 };
377}
378
379function makePoller($: EngineInterface): Poller {
380 return createPoller(
381 pollerEngine($),
382 () => model,
383 (next) => replaceModel($, next),
384 );
385}
386
387// Whether the `cctop` binary answers `--version`: `present` on exit 0,
388// `missing` when it cannot start, exits non-zero or takes over 3 s. Runs
389// after session.start's `next(e)` and is never awaited by a hook; settles
390// once the answer is in the model, so the restore can follow it.
391function detectBinary($: EngineInterface): Promise<void> {
392 return $.process
393 .run(['cctop', '--version'], { timeoutMs: VERSION_TIMEOUT_MS })
394 .then(
395 (result): Binary => (result.exitCode === 0 ? 'present' : 'missing'),
396 (): Binary => 'missing',
397 )
398 .then((binary) => {
399 apply($, { type: 'binary', binary });
400 if (binary === 'present' && model.open) poller?.start();
401 })
402 .catch((err: unknown) => $.ui.log(`cctop: binary detection failed: ${String(err)}`));
403}
404
405// The plugin's version for the marker file, from plugin.json under
406// `$.plugin.root`; stays null when the manifest cannot be read.
407function readVersion($: EngineInterface): Promise<void> {
408 return $.fs
409 .read(`${$.plugin.root}/${MANIFEST}`)
410 .then((text) => {
411 const version = (JSON.parse(text) as { version?: unknown }).version;
412 if (typeof version === 'string') model = { ...model, version };
413 })
414 .catch((err: unknown) => $.ui.log(`cctop: plugin.json unreadable: ${String(err)}`));
415}
416
417// Reopens the pane a reload closed: `$.store` holds `{ open: true, view }`
418// from the last persistPane. Only where a person is at the prompt, and only
419// after the binary check, so the poller starts with the pane.
420function restorePane($: EngineInterface, isInteractive: boolean): Promise<void> {
421 if (!isInteractive) return Promise.resolve();
422 return $.store
423 .get(STORE_KEY)
424 .then((saved) => {
425 if (model.open || saved === null || typeof saved !== 'object') return;
426 const { open, view } = saved as { open?: unknown; view?: unknown };
427 if (open !== true) return;
428 const known = VIEWS.find((v) => v.view === view);
429 // A restore has nobody to answer; the outcome reaches the person
430 // through the status line (hidden) or the pane itself.
431 return openPane($, known?.view ?? 'overview').then(() => undefined);
432 })
433 .catch((err: unknown) => $.ui.log(`cctop: restore failed: ${String(err)}`));
434}
435
436function updateMarker($: EngineInterface): void {
437 writeMarker(pollerEngine($), model).catch((err: unknown) => $.ui.log(`cctop: marker write failed: ${String(err)}`));
438}
439
440// Learns the session id after session.start; the poller's sync writes the
441// marker with `open: false`, so from then on `cctop pane status` can tell
442// that the hooks module runs in this session, before any pane was opened.
443function noteSession($: EngineInterface): void {
444 poller?.sync().catch((err: unknown) => $.ui.log(`cctop: session.id failed: ${String(err)}`));
445}
446
447// `/clear` gives the session a new id and fires no session.start (the d.ts:
448// "Not `/clear`"), so every turn reads the id again. When it changed, the
449// model has just dropped the old session (model.ts) and, while the pane is
450// open, the new one is read at once instead of at the next timer. Never
451// awaited by the hook: the turn owes the pane nothing.
452function followSession($: EngineInterface): void {
453 poller
454 ?.sync()
455 .then((rotated) => {
456 if (!rotated || !model.open) return;
457 readUsage($);
458 void poller?.tick();
459 })
460 .catch((err: unknown) => $.ui.log(`cctop: session.id failed: ${String(err)}`));
461}
462
463function readUsage($: EngineInterface): void {
464 Promise.all([$.session.usage(), clockNow($)])
465 .then(([usage, at]) => apply($, { type: 'usage', usage, at }))
466 .catch((err: unknown) => $.ui.log(`cctop: session.usage failed: ${String(err)}`));
467}
468
469// The model's name for the header, read once after session.start's `next(e)`.
470function readModelName($: EngineInterface): void {
471 $.session
472 .model()
473 .then((name) => apply($, { type: 'session.model', name }))
474 .catch((err: unknown) => $.ui.log(`cctop: session.model failed: ${String(err)}`));
475}
476
477function stopUsageTimer(): void {
478 usageTimer?.cancel();
479 usageTimer = null;
480}
481
482function startUsageTimer($: EngineInterface): void {
483 if (usageTimer !== null) return;
484 usageTimer = $.clock.every(USAGE_POLL_MS, () => {
485 if (model.turn.state !== 'busy') {
486 stopUsageTimer();
487 return;
488 }
489 readUsage($);
490 });
491}
492
493// Remembers `{ open, view }` so the next session.start can reopen the pane
494// on the same view.
495function persistPane($: EngineInterface): void {
496 $.store
497 .set(STORE_KEY, { open: model.open, view: model.view })
498 .catch((err: unknown) => $.ui.log(`cctop: store.set failed: ${String(err)}`));
499}
500
501// Opens the pane (an open id is merely retitled) on `view` when given. Never
502// asks for `focus`: the keyboard stays the person's. The timers and the
503// poller run only while the pane is open, so they start here. Resolves to
504// the sentence for the person once the engine's first render (or its
505// absence, OPEN_SETTLE_MS later) says where the pane went.
506async function openPane($: EngineInterface, view?: View): Promise<string> {
507 await $.ui.open({ id: PANE_ID, title: 'cctop' });
508 const opened = model.open;
509 const label = view === undefined ? undefined : VIEWS.find((v) => v.view === view)?.label;
510 const openedAt = opened ? model.openedAt : await clockNow($);
511 model = {
512 ...model,
513 open: true,
514 view: view ?? model.view,
515 openedAt,
516 visibility: opened && model.visibility === 'visible' ? 'visible' : 'unknown',
517 };
518 persistPane($);
519 if (!opened) {
520 // The id first, so a `/clear` since the last look does not wipe the
521 // usage read next.
522 await poller?.sync();
523 if (model.binary === 'present') poller?.start();
524 if (model.turn.state === 'busy') startUsageTimer($);
525 readUsage($);
526 updateMarker($);
527 }
528 const rendered = awaitRender($);
529 await invalidateNow($);
530 await rendered;
531 updateMarker($);
532 return outcomeText(model, label);
533}
534
535// What every close does, whoever closes: the timers and the poller stop,
536// the choice is remembered, the marker says `open: false`. Runs from the
537// ui.close hook (any origin) and after the command's own $.ui.close, so it
538// is a no-op the second time round.
539function paneClosed($: EngineInterface): void {
540 if (!model.open) return;
541 model = { ...model, open: false, visibility: 'unknown' };
542 poller?.stop();
543 stopUsageTimer();
544 stopRenderTimer();
545 stopHiddenTimer();
546 unpinStatus($);
547 if (model.coachStatus !== null) {
548 $.ui.status(undefined);
549 model = reduce(model, { type: 'coach.status', status: null });
550 }
551 persistPane($);
552 updateMarker($);
553}
554
555async function closePane($: EngineInterface): Promise<void> {
556 await $.ui.close({ id: PANE_ID });
557 paneClosed($);
558}
559
560// A view-bar press: the view changes, the choice is remembered, the pane
561// redraws.
562function selectView($: EngineInterface, view: View): void {
563 model = { ...model, view };
564 persistPane($);
565 invalidateNow($).catch((err: unknown) => $.ui.log(`cctop: redraw failed: ${String(err)}`));
566}
567
568// The pane's tree for one render. Inline (the classic renderer's few rows
569// above the prompt): the header and the Context and Limits lines only, no
570// view bar. Docked: the view bar, the view, then the state of the binary and
571// its query verbs beneath it. `now` is the render hook's one clock reading,
572// for the elapsed times and countdowns.
573function buildPane($: EngineInterface, e: RenderInput<'Pane'>, now: number): RenderElement {
574 const el = $.ui.resolve(e);
575 const { Box, Text } = el;
576 const columns = e.props.bodyColumns;
577 if (e.props.placement === 'inline') {
578 return (
579 <Box flexDirection="column">
580 {renderView(model, el, columns, 'inline', now)}
581 {model.binary === 'missing' && <Text wrap="truncate">{INSTALL_HINT}</Text>}
582 </Box>
583 );
584 }
585 return (
586 <Box flexDirection="column">
587 {viewBar($, el, columns)}
588 {renderView(model, el, columns, 'dock', now, { el, coach: coachActions($), overview: overviewActions($), agents: agentsActions($) })}
589 {model.binary === 'missing' && <Text wrap="truncate">{INSTALL_HINT}</Text>}
590 {model.stale && <Text wrap="truncate">cctop query stale</Text>}
591 {unsupportedVerbs(model).map((verb) => (
592 <Text key={verb} wrap="truncate">
593 {verb}: {UNSUPPORTED}
594 </Text>
595 ))}
596 </Box>
597 );
598}
599
600// The view bar, the pane's first row: `cctop Overview Tools …`. The
601// current view is an inverse Text; the others are plain Buttons (a click,
602// or Enter after `ctrl+x tab` and `tab`, switches). No hotkeys: the docked
603// pane never reads them (docs/claude-code-panels.md §5.5), and the band
604// above the prompt that used to carry them cost the transcript a row, so
605// the bar lives here alone. Tabs that do not fit on one line continue on
606// the next, so the bar never overflows a narrow pane.
607function viewBar(
608 $: EngineInterface,
609 el: Pick<ElementTable<'terminal'>, 'Box' | 'Text' | 'Button'>,
610 columns: number,
611): RenderElement {
612 const { Box, Text, Button } = el;
613 const lines: RenderElement[][] = [
614 [
615 <Text wrap="truncate" bold>
616 {BAR_LEAD}
617 </Text>,
618 ],
619 ];
620 let used = BAR_LEAD.length;
621 let prevActive = false;
622 for (const { view, label } of VIEWS) {
623 const active = view === model.view;
624 // Two cells between tabs; the active tab's inverse padding is one of them.
625 let gap = prevActive || active ? 1 : 2;
626 // The label plus two: the active tab's padding, or the `[ ]` a surface
627 // may draw around a Button (the harness does; the engine's plain form
628 // is the bare label), so the row never overflows on either.
629 const width = label.length + 2;
630 if (used + gap + width > columns) {
631 lines.push([]);
632 used = 0;
633 gap = 0;
634 }
635 const line = lines[lines.length - 1];
636 if (gap > 0) line.push(<Text wrap="truncate">{' '.repeat(gap)}</Text>);
637 line.push(
638 active ? (
639 <Text key={view} wrap="truncate" inverse>
640 {` ${label} `}
641 </Text>
642 ) : (
643 <Button key={view} label={label} plain onPress={() => selectView($, view)} />
644 ),
645 );
646 used += gap + width;
647 prevActive = active;
648 }
649 // The `bin · shim · hooks 2.1.273` badges after the tabs, when they fit:
650 // Console's header is the object's, identical on both surfaces.
651 const badges = badgesLine(header(model, 0).badges);
652 const badgesWidth = badges.reduce((n, b) => n + b.text.length, 0);
653 if (used + 2 + badgesWidth <= columns) {
654 lines[lines.length - 1].push(
655 <Text wrap="truncate">{' '}</Text>,
656 ...badges.map((b) => (
657 <Text wrap="truncate" color={b.color} dimColor={b.dim}>
658 {b.text}
659 </Text>
660 )),
661 );
662 }
663 return (
664 <Box flexDirection="column">
665 {lines.map((line, i) => (
666 <Box key={`view-bar-${i}`} flexDirection="row">
667 {line}
668 </Box>
669 ))}
670 </Box>
671 );
672}
673// The label before the first tab: whose bar this is.
674const BAR_LEAD = 'cctop';
675
676export const register: Register = (on) => {
677 model = initialModel();
678 stopUsageTimer();
679 stopRenderTimer();
680 stopHiddenTimer();
681 renderWaiters = [];
682 statusPinned = false;
683 invalidatedAt = null;
684 renderPending = false;
685 poller?.stop();
686 poller = null;
687
688 // The command is declared once the session is ready; session.start is
689 // awaited before the first prompt, so the command is listed from turn one.
690 // Every hook below reads the clock through the host (one round trip, the
691 // cost of the hook's own dispatch again) before its `next(e)`: the
692 // timestamps are the engine's, so the pane and the marker agree with it.
693 // session.start checks the surfaces first and says once what it found.
694 on('session.start', async ($, e, next) => {
695 const check = await selfCheck($);
696 apply($, { type: 'session.start', at: check.now, selfCheck: check.problems.length === 0 ? 'ok' : check.problems.join('; ') });
697 poller = makePoller($);
698 await $.command.register({
699 name: COMMAND,
700 description: 'Open the cctop dashboard pane',
701 argumentHint: '[view|close]',
702 immediate: true,
703 });
704 const result = await next(e);
705 void announce($, check);
706 readModelName($);
707 void detectBinary($).then(() => restorePane($, e.isInteractive));
708 return result;
709 }).catch(($, e, next) => {
710 $.ui.log(`cctop: session.start failed: ${next.error.message ?? next.error.kind}`);
711 return next(e);
712 });
713
714 // While the pane is closed the turn and tool hooks keep the books and
715 // follow the session id, nothing else: no timer, no poll, no usage read.
716 on('turn.start', async ($, e, next) => {
717 apply($, { type: 'turn.start', at: await clockNow($) });
718 if (model.open) startUsageTimer($);
719 poller?.reschedule();
720 followSession($);
721 return next(e);
722 }).catch(($, e, next) => {
723 $.ui.log(`cctop: turn.start failed: ${next.error.message ?? next.error.kind}`);
724 return next(e);
725 });
726
727 on('turn.complete', async ($, e, next) => {
728 apply($, { type: 'turn.complete', at: await clockNow($), durationMs: e.durationMs, reason: e.reason });
729 stopUsageTimer();
730 poller?.reschedule();
731 const result = await next(e);
732 if (model.open) readUsage($);
733 return result;
734 }).catch(($, e, next) => {
735 $.ui.log(`cctop: turn.complete failed: ${next.error.message ?? next.error.kind}`);
736 return next(e);
737 });
738
739 on('session.compact', ($, e, next) => {
740 apply($, { type: 'session.compact' });
741 return next(e);
742 }).catch(($, e, next) => {
743 $.ui.log(`cctop: session.compact failed: ${next.error.message ?? next.error.kind}`);
744 return next(e);
745 });
746
747 // Times every tool call around `next(e)`: a timestamp before, the result's
748 // `isError` and text length after. The result itself passes through
749 // untouched; a call that throws beneath us is recorded as an error.
750 on('tool.call', async ($, e, next) => {
751 const startedAt = await clockNow($);
752 apply($, { type: 'tool.start', name: e.tool, at: startedAt });
753 let result: ToolCallResult | undefined;
754 try {
755 result = await next(e);
756 return result;
757 } finally {
758 apply($, {
759 type: 'tool.end',
760 name: e.tool,
761 startedAt,
762 at: await clockNow($),
763 isError: result === undefined || result.isError === true,
764 resultChars: result?.text?.length ?? 0,
765 });
766 }
767 }).catch(($, e, next) => {
768 $.ui.log(`cctop: tool.call failed: ${next.error.message ?? next.error.kind}`);
769 return next(e);
770 });
771
772 // `/cctop-pane` toggles the pane, `/cctop-pane <view>` opens it on that
773 // view, `/cctop-pane close` closes it; anything else prints the usage. An
774 // open answers with where the pane went (outcomeText), never a bare
775 // "opened": the person must be able to tell a docked pane from one the
776 // /diff panel is hiding.
777 on('command.run', { command: COMMAND }, async ($, e) => {
778 const arg = e.args.trim();
779 if (arg === 'close') {
780 await closePane($);
781 return { text: 'cctop pane closed' };
782 }
783 if (arg === '') {
784 if (model.open) {
785 await closePane($);
786 return { text: 'cctop pane closed' };
787 }
788 return { text: await openPane($) };
789 }
790 const view = VIEWS.find((v) => v.view === arg);
791 if (view === undefined) return { text: USAGE };
792 return { text: await openPane($, view.view) };
793 }).catch(($, _e, next) => {
794 $.ui.log(`cctop: /${COMMAND} failed: ${next.error.message ?? next.error.kind}`);
795 return { text: 'cctop pane could not be opened' };
796 });
797
798 // On a build with function hooks, `/cctop` (the skill, US-001) still
799 // resolves first; this replaces its prompt so the model does the same
800 // thing the native command does — open the pane — instead of running
801 // `cctop split`, and says nothing back into the transcript.
802 on('skill.prompt', { skill: 'cctop' }, async ($) => {
803 const outcome = await openPane($);
804 return {
805 text: `The cctop pane was just opened by the plugin; its state is: "${outcome}" Reply with exactly that sentence and nothing else. Do not run any tool.`,
806 };
807 }).catch(($, e, next) => {
808 $.ui.log(`cctop: skill.prompt failed: ${next.error.message ?? next.error.kind}`);
809 return next(e);
810 });
811
812 // Every close of the pane, the person's and an unload's as much as the
813 // command's, ends the timers and the poller; a close of another pane is
814 // not ours.
815 on('ui.close', { id: PANE_ID }, ($, e, next) => {
816 return next(e).then((result) => {
817 paneClosed($);
818 return result;
819 });
820 }).catch(($, e, next) => {
821 $.ui.log(`cctop: ui.close failed: ${next.error.message ?? next.error.kind}`);
822 return next(e);
823 });
824
825 // A view that throws must not take the pane down: the error is drawn in
826 // one line above the last tree that built, kept in the model for that.
827 // The clock is read once per render, before the tree: the frame's time.
828 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
829 if (e.requestId !== PANE_ID) return next(e);
830 const now = await clockNow($);
831 noteRender($, e, now);
832 const { Box, Text } = $.ui.resolve(e);
833 try {
834 const tree = buildPane($, e, now);
835 model = { ...model, lastTree: tree };
836 return tree;
837 } catch (err) {
838 const message = err instanceof Error ? err.message : String(err);
839 $.ui.log(`cctop: render error: ${message}`);
840 return (
841 <Box flexDirection="column">
842 <Text color="red" wrap="truncate">
843 cctop render error: {message}
844 </Text>
845 {model.lastTree}
846 </Box>
847 );
848 }
849 }).catch(($, e, next) => {
850 $.ui.log(`cctop: render failed: ${next.error.message ?? next.error.kind}`);
851 return next(e);
852 });
853};
854hooks/model.ts 422 lines1// The pane's state and the pure reducer that advances it. Nothing in here
2// touches `$`: the hooks in pane.tsx turn engine events into actions, the
3// views draw the model. Kept pure so it is testable from plain data.
4import type { RenderElement, SessionUsage } from 'claude-code';
5
6export type TurnState = 'idle' | 'busy' | 'waiting';
7export type View = 'coach' | 'overview' | 'tools' | 'agents' | 'files' | 'events' | 'advisor';
8export type Placement = 'dock' | 'inline';
9export type Binary = 'unknown' | 'present' | 'missing';
10/** Whether the engine is drawing the pane: `hidden` once an open pane got
11 * no `ui.render` for a while (the /diff panel holds the dock), `unknown`
12 * while closed or before the first render after an open. */
13export type Visibility = 'unknown' | 'visible' | 'hidden';
14
15/** The Claude Code version this hooks module's `$` contract was checked
16 * against (`plugin/.claude/types/claude-code.d.ts`'s own first line);
17 * `scripts/check-plugin-types.sh` catches drift, the header badge shows it. */
18export const TESTED_WITH = '2.1.284';
19
20/** The narrowest terminal (whole screen, columns) at which the fullscreen
21 * renderer docks a pane beside the transcript; below it the pane is drawn
22 * inline above the prompt (docs/claude-code-panels.md §4). */
23export const MIN_DOCK_COLUMNS = 110;
24
25/** The `cctop query` verbs the pane polls, in the order one tick runs them. */
26export const QUERY_VERBS = ['summary', 'dashboard', 'coach', 'tools', 'files', 'agents', 'advice', 'events'] as const;
27/** Verbs a busy tick (every 2 s) skips: they change at turn boundaries, the idle tick reads them. */
28export const IDLE_ONLY_VERBS: readonly QueryVerb[] = ['advice'];
29export type QueryVerb = (typeof QUERY_VERBS)[number];
30/** The parsed JSON of the last successful `cctop query <verb>`, per verb. */
31export type QueryData = Partial<Record<QueryVerb, unknown>>;
32/** What a section draws when its verb is not in this binary's `cctop query --help`. */
33export const UNSUPPORTED = 'unsupported by this cctop version';
34
35export type RunningTool = { name: string; startedAt: number };
36
37export type ToolStats = {
38 calls: number;
39 errors: number;
40 durationsMs: number[];
41 /** Σ ceil(len(result text) / 4): the TUI's `tokens_to_ctx` heuristic. */
42 tokensToCtx: number;
43};
44
45export type Turn = {
46 /** Turns started this session (0 before the first `turn.start`). */
47 number: number;
48 state: TurnState;
49 /** When the current turn started; null while idle. */
50 startedAt: number | null;
51 /** `durationMs` and `reason` of the last `turn.complete`. */
52 lastDurationMs: number | null;
53 lastReason: string | null;
54 /** The most recently started tool call that has not ended. */
55 runningTool: RunningTool | null;
56 /** Σ durations of the tool calls that ended in this turn (the running one is added at render). */
57 toolMs: number;
58};
59
60export type Model = {
61 /** The last `$.session.usage()` answer and when it was read. */
62 usage: SessionUsage | null;
63 usageAt: number | null;
64 /** `$.session.model()`, read once after session.start. */
65 modelName: string | null;
66 turn: Turn;
67 tools: Record<string, ToolStats>;
68 compactions: number;
69 binary: Binary;
70 /** `$.session.id()`, read after session.start and again on every turn and
71 * poller tick: `/clear` rotates it in place (a new transcript, the registry
72 * entry rewritten) and no session.start says so. */
73 sessionId: string | null;
74 // Precedence between the two sources: the Context and Limits rows come
75 // from `usage` (engine-native, live) when it is present, else from
76 // `query.summary`; every other figure (tokens & cost breakdown, burn rate,
77 // tools, files, agents, advice, events) comes from the query JSON alone.
78 query: QueryData;
79 /** When the last tick in which every called verb parsed ended; null before one. */
80 queryAt: number | null;
81 /** The verbs `cctop query --help` lists; null until the poller has read it. */
82 verbs: readonly QueryVerb[] | null;
83 view: View;
84 open: boolean;
85 /** When the pane was last opened (the marker's `openedAt`); null before the first open. */
86 openedAt: number | null;
87 /** True when the last successful `cctop query` tick is older than 30 s. */
88 stale: boolean;
89 placement: Placement;
90 /** The `version` of plugin.json, read once after session.start; null until then. */
91 version: string | null;
92 /** The last tree `ui.render` built without throwing: drawn again beneath a render error. */
93 lastTree: RenderElement | null;
94 /** When session.start ran (the marker's `loadedAt`); null before it. */
95 loadedAt: number | null;
96 /** What session.start's self-check of the `$` surfaces found: `ok`, or the
97 * problems in one line (the marker's `selfCheck`); null before it ran. */
98 selfCheck: string | null;
99 visibility: Visibility;
100 /** When the engine last asked for the pane's tree; null before the first render. */
101 renderedAt: number | null;
102 /** `e.props.bodyColumns` of the last render: cells across the body. */
103 bodyColumns: number | null;
104 /** `e.viewport.columns` of the last render: the whole screen's width; null where unmeasured. */
105 viewportColumns: number | null;
106 /** The context size at the end of each turn (the last HISTORY_TURNS), for the Context sparkline. */
107 contextHistory: number[];
108 /** The coach view: which light's detail frame is open (null = the highest light) and whether the why frame is. */
109 coachLight: 'context' | 'cache' | 'limits' | 'rework' | null;
110 coachWhy: boolean;
111 /** `id:fired_at_ms` of the nudge last toasted, and the turn it was toasted in (one toast per turn). */
112 coachToasted: string | null;
113 coachToastTurn: number | null;
114 /** The last `$.ui.status` line the coach set; set again only when it changes. */
115 coachStatus: string | null;
116 /** Console's open body (its id); events, the way home, by default. */
117 body: string;
118 /** Console's rule line expanded into the key map (`? keys` pressed). */
119 keys: boolean;
120 /** The agents view's open workflow run (its id, a `Button` press); null = the list. */
121 openRun: string | null;
122};
123
124/** How many turn-end context sizes the model keeps for the sparkline. */
125export const HISTORY_TURNS = 24;
126
127export type Action =
128 | { type: 'session.start'; at: number; selfCheck?: string }
129 | { type: 'turn.start'; at: number }
130 | { type: 'turn.complete'; at: number; durationMs: number; reason: string }
131 | { type: 'tool.start'; name: string; at: number }
132 // `startedAt` is the matching `tool.start`'s `at`: the hook keeps it in its
133 // closure, so concurrent calls of the same tool time themselves apart.
134 | { type: 'tool.end'; name: string; startedAt: number; at: number; isError: boolean; resultChars: number }
135 | { type: 'session.compact' }
136 | { type: 'usage'; usage: SessionUsage; at: number }
137 | { type: 'session.model'; name: string }
138 | { type: 'binary'; binary: Binary }
139 | { type: 'session.id'; id: string }
140 | { type: 'verbs'; verbs: readonly QueryVerb[] }
141 | { type: 'query'; verb: QueryVerb; data: unknown }
142 // One poller tick ended; `ok` when every verb it called parsed.
143 | { type: 'tick'; at: number; ok: boolean }
144 | { type: 'stale'; stale: boolean }
145 // The engine asked for the pane's tree: it is being drawn, here and this wide.
146 | { type: 'render'; at: number; placement: Placement; bodyColumns: number; viewportColumns: number | null }
147 | { type: 'visibility'; visibility: Visibility }
148 | { type: 'coach.light'; light: 'context' | 'cache' | 'limits' | 'rework' | null }
149 | { type: 'coach.why'; why: boolean }
150 | { type: 'coach.toasted'; key: string; turn: number }
151 | { type: 'coach.status'; status: string | null }
152 | { type: 'overview.body'; id: string }
153 | { type: 'overview.keys'; keys: boolean }
154 | { type: 'agents.run'; run: string | null };
155
156export function initialModel(): Model {
157 return {
158 usage: null,
159 usageAt: null,
160 modelName: null,
161 turn: {
162 number: 0,
163 state: 'idle',
164 startedAt: null,
165 lastDurationMs: null,
166 lastReason: null,
167 runningTool: null,
168 toolMs: 0,
169 },
170 tools: {},
171 compactions: 0,
172 binary: 'unknown',
173 sessionId: null,
174 query: {},
175 queryAt: null,
176 verbs: null,
177 view: 'overview',
178 open: false,
179 openedAt: null,
180 stale: false,
181 placement: 'dock',
182 version: null,
183 lastTree: null,
184 loadedAt: null,
185 selfCheck: null,
186 visibility: 'unknown',
187 renderedAt: null,
188 bodyColumns: null,
189 viewportColumns: null,
190 contextHistory: [],
191 coachLight: null,
192 coachWhy: false,
193 coachToasted: null,
194 coachToastTurn: null,
195 coachStatus: null,
196 body: 'events',
197 keys: false,
198 openRun: null,
199 };
200}
201
202const EMPTY_TOOL: ToolStats = { calls: 0, errors: 0, durationsMs: [], tokensToCtx: 0 };
203
204export function reduce(model: Model, action: Action): Model {
205 switch (action.type) {
206 case 'session.start':
207 return {
208 ...model,
209 loadedAt: action.at,
210 selfCheck: action.selfCheck ?? null,
211 turn: { ...model.turn, state: 'idle', startedAt: null, runningTool: null },
212 };
213 case 'turn.start':
214 return {
215 ...model,
216 turn: { ...model.turn, number: model.turn.number + 1, state: 'busy', startedAt: action.at, toolMs: 0 },
217 };
218 case 'turn.complete': {
219 // The turn's closing context size, from the engine's last usage read.
220 const size = model.usage?.context.tokens;
221 const contextHistory = size === undefined ? model.contextHistory : [...model.contextHistory, size].slice(-HISTORY_TURNS);
222 return {
223 ...model,
224 contextHistory,
225 turn: {
226 ...model.turn,
227 state: 'idle',
228 startedAt: null,
229 lastDurationMs: action.durationMs,
230 lastReason: action.reason,
231 runningTool: null,
232 },
233 };
234 }
235 case 'tool.start':
236 return { ...model, turn: { ...model.turn, runningTool: { name: action.name, startedAt: action.at } } };
237 case 'tool.end': {
238 const prev = model.tools[action.name] ?? EMPTY_TOOL;
239 const durationMs = Math.max(0, action.at - action.startedAt);
240 const stats: ToolStats = {
241 calls: prev.calls + 1,
242 errors: prev.errors + (action.isError ? 1 : 0),
243 durationsMs: [...prev.durationsMs, durationMs],
244 tokensToCtx: prev.tokensToCtx + Math.ceil(action.resultChars / 4),
245 };
246 const running = model.turn.runningTool;
247 const stillRunning =
248 running !== null && !(running.name === action.name && running.startedAt === action.startedAt);
249 return {
250 ...model,
251 tools: { ...model.tools, [action.name]: stats },
252 turn: { ...model.turn, runningTool: stillRunning ? running : null, toolMs: model.turn.toolMs + durationMs },
253 };
254 }
255 case 'session.compact':
256 return { ...model, compactions: model.compactions + 1 };
257 case 'usage':
258 return { ...model, usage: action.usage, usageAt: action.at };
259 case 'session.model':
260 return { ...model, modelName: action.name };
261 case 'binary':
262 return { ...model, binary: action.binary };
263 case 'session.id':
264 if (model.sessionId === action.id) return model;
265 if (model.sessionId === null) return { ...model, sessionId: action.id };
266 // A different id for a known session: `/clear` rotated it, and every
267 // figure the pane held describes a session that is over. The engine's
268 // own bookkeeping and the query JSON start again (a turn running now
269 // is the new session's first); the pane's own state, the binary and
270 // its verbs are the process's and stay.
271 return {
272 ...model,
273 sessionId: action.id,
274 usage: null,
275 usageAt: null,
276 turn: { ...model.turn, number: model.turn.state === 'idle' ? 0 : 1, lastDurationMs: null, lastReason: null },
277 tools: {},
278 compactions: 0,
279 query: {},
280 queryAt: null,
281 stale: false,
282 contextHistory: [],
283 coachToasted: null,
284 coachToastTurn: null,
285 coachStatus: null,
286 openRun: null,
287 };
288 case 'verbs':
289 return { ...model, verbs: action.verbs };
290 case 'query':
291 return { ...model, query: { ...model.query, [action.verb]: action.data } };
292 case 'tick':
293 return action.ok ? { ...model, queryAt: action.at } : model;
294 case 'stale':
295 return model.stale === action.stale ? model : { ...model, stale: action.stale };
296 case 'render':
297 return {
298 ...model,
299 renderedAt: action.at,
300 placement: action.placement,
301 bodyColumns: action.bodyColumns,
302 viewportColumns: action.viewportColumns,
303 visibility: 'visible',
304 };
305 case 'visibility':
306 return model.visibility === action.visibility ? model : { ...model, visibility: action.visibility };
307 case 'coach.light':
308 return { ...model, coachLight: action.light, coachWhy: false };
309 case 'coach.why':
310 return { ...model, coachWhy: action.why };
311 case 'coach.toasted':
312 return { ...model, coachToasted: action.key, coachToastTurn: action.turn };
313 case 'coach.status':
314 return model.coachStatus === action.status ? model : { ...model, coachStatus: action.status };
315 case 'overview.body':
316 return model.body === action.id ? model : { ...model, body: action.id };
317 case 'overview.keys':
318 return model.keys === action.keys ? model : { ...model, keys: action.keys };
319 case 'agents.run':
320 return model.openRun === action.run ? model : { ...model, openRun: action.run };
321 }
322}
323
324/** The status line pinned under the prompt while an open pane is not drawn. */
325export const HIDDEN_STATUS = 'cctop pane hidden behind the /diff panel: run /diff to show it';
326
327// What the person is told after an open (the command's reply, the skill's
328// one-liner): where the engine put the pane and, when it is not beside the
329// transcript, the one thing that gets it there. The engine tells the module
330// about the renderer and the width through the first render's props; it
331// says nothing when the /diff panel holds the dock, so that case is read
332// off a missing render (docs/claude-code-panels.md §5.4).
333export function outcomeText(model: Model, viewLabel?: string): string {
334 const subject = viewLabel === undefined ? 'cctop pane' : `cctop pane on ${viewLabel}`;
335 if (model.visibility !== 'visible') {
336 return `${subject} is open but not shown: the /diff panel holds the side dock. Run /diff to hide it and cctop takes its place (needs /tui fullscreen and 110+ columns).`;
337 }
338 if (model.placement === 'dock') {
339 const width = model.bodyColumns === null ? '' : ` (${model.bodyColumns} columns)`;
340 return `${subject} docked beside the transcript${width}: click a view in its bar to switch (or ctrl+x tab, then tab and enter), ctrl+x x closes it.`;
341 }
342 const columns = model.viewportColumns;
343 if (columns !== null && columns < MIN_DOCK_COLUMNS) {
344 return `${subject} drawn above the prompt: the terminal is ${columns} columns wide, ${MIN_DOCK_COLUMNS} or more dock it beside the transcript.`;
345 }
346 return `${subject} drawn above the prompt: /tui fullscreen docks it beside the transcript.`;
347}
348
349/** Whether `verb` may be polled: unknown until `--help` is read, then as listed. */
350export function isSupported(model: Model, verb: QueryVerb): boolean {
351 return model.verbs === null || model.verbs.includes(verb);
352}
353
354/** The verbs this binary lacks, for the sections that read `UNSUPPORTED`. */
355export function unsupportedVerbs(model: Model): QueryVerb[] {
356 return model.verbs === null ? [] : QUERY_VERBS.filter((verb) => !isSupported(model, verb));
357}
358
359/** The p-th percentile (0..1, nearest rank) of `values`; 0 when empty. */
360export function percentile(values: readonly number[], p: number): number {
361 if (values.length === 0) return 0;
362 const sorted = [...values].sort((a, b) => a - b);
363 const rank = Math.min(sorted.length - 1, Math.max(0, Math.ceil(p * sorted.length) - 1));
364 return sorted[rank];
365}
366
367/** `2h 30m`, `45m`, `30s`; `now` once the instant has passed. */
368export function formatCountdown(ms: number): string {
369 if (ms <= 0) return 'now';
370 const s = Math.round(ms / 1000);
371 const d = Math.floor(s / 86400);
372 const h = Math.floor((s % 86400) / 3600);
373 const m = Math.floor((s % 3600) / 60);
374 if (d > 0) return `${d}d ${h}h`;
375 if (h > 0) return `${h}h ${m}m`;
376 if (m > 0) return `${m}m`;
377 return `${s}s`;
378}
379
380export function formatTokens(n: number): string {
381 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
382 if (n >= 1000) return `${Math.round(n / 1000)}k`;
383 return String(n);
384}
385
386/** One line of the Overview built from the engine's own figures. */
387export type UsageRow = {
388 /** The metric id as docs/metrics.md names it (`context_size`, `limit_5h`). */
389 key: string;
390 label: string;
391 value: string;
392};
393
394const LIMIT_KEYS: Record<string, { key: string; label: string }> = {
395 five_hour: { key: 'limit_5h', label: '5h' },
396 seven_day: { key: 'limit_7d', label: '7d' },
397};
398
399// The engine-native rows: Context as `tokens / window (percent %)`, one row
400// per rate-limit window with its countdown, and the cost with two decimals.
401// A figure the engine left out is drawn as `?`, never as 0.
402export function usageRows(usage: SessionUsage, now: number): UsageRow[] {
403 const rows: UsageRow[] = [];
404 const { tokens, window } = usage.context;
405 const percent =
406 usage.context.percent ?? (tokens !== undefined && window > 0 ? Math.round((tokens / window) * 100) : undefined);
407 const size = tokens === undefined ? '?' : formatTokens(tokens);
408 const pct = percent === undefined ? '?' : String(percent);
409 rows.push({ key: 'context_size', label: 'Context', value: `${size} / ${formatTokens(window)} (${pct} %)` });
410 for (const limit of usage.rateLimits) {
411 const named = LIMIT_KEYS[limit.kind] ?? { key: `limit_${limit.kind}`, label: limit.kind };
412 let value = `${Math.round(limit.percentUsed)} %`;
413 if (limit.resetsAt !== undefined) {
414 const resetsAt = Date.parse(limit.resetsAt);
415 if (!Number.isNaN(resetsAt)) value += `, resets in ${formatCountdown(resetsAt - now)}`;
416 }
417 rows.push({ key: named.key, label: named.label, value });
418 }
419 if (usage.cost !== undefined) rows.push({ key: 'cost', label: 'Cost', value: `$${usage.cost.usd.toFixed(2)}` });
420 return rows;
421}
422hooks/poller.ts 252 lines1// The query poller: pulls the deep metrics from the `cctop` binary with
2// `cctop query <verb> --session <id>`, one verb after another through a single
3// in-flight promise, every 2 s while a turn runs and every 10 s idle. A verb
4// the binary lacks (per `cctop query --help`, read once) is never called; a
5// failed or unparsable call keeps the verb's previous JSON, and the model
6// turns `stale` once no tick has fully succeeded for 30 s. It also follows
7// the session id (`sync`): `/clear` rotates it under the running pane.
8//
9// The poller never sees `$` itself: `claude plugin validate` follows `$` only
10// into functions declared in pane.tsx, so it receives `PollerEngine`, the
11// slice of `$` it uses, built there by a top-level function.
12import type { EngineInterface, Timer } from 'claude-code';
13import { IDLE_ONLY_VERBS, QUERY_VERBS, TESTED_WITH, isSupported, reduce, type Action, type Model, type QueryVerb } from './model';
14
15export type PollerEngine = {
16 /** `now` resolves a Promise: a host round trip since Claude Code 2.1.271 (issue #3). */
17 clock: Pick<EngineInterface['clock'], 'now' | 'every'>;
18 process: Pick<EngineInterface['process'], 'run'>;
19 session: Pick<EngineInterface['session'], 'id'>;
20 fs: Pick<EngineInterface['fs'], 'write'>;
21 ui: Pick<EngineInterface['ui'], 'log'>;
22 /** `$.env.get("HOME")`: spelled in pane.tsx, the validator reads the name off its source. */
23 home: () => Promise<string | undefined>;
24};
25
26export type Poller = {
27 /** Arms the timer for the current cadence and runs a first tick; a no-op while running. */
28 start(): void;
29 stop(): void;
30 /** One round of queries; resolves at once when a round is already in flight. */
31 tick(): Promise<void>;
32 /** Re-arms the timer when the cadence changed with the turn state; a no-op while stopped. */
33 reschedule(): void;
34 running(): boolean;
35 /**
36 * Reads `$.session.id()` again and follows it: learns the id the first
37 * time, and when a known id changed (`/clear` rotates it in place, with no
38 * session.start) drops the old session from the model, closes its marker
39 * and writes the new one. Resolves to whether a known id changed. Runs
40 * whether or not the binary is present or the poller started.
41 */
42 sync(): Promise<boolean>;
43};
44
45export const BUSY_POLL_MS = 2000;
46export const IDLE_POLL_MS = 10000;
47export const STALE_AFTER_MS = 30000;
48export const QUERY_TIMEOUT_MS = 5000;
49
50/** How often to poll: every 2 s while a turn runs, every 10 s otherwise. */
51export function pollInterval(model: Model): number {
52 return model.turn.state === 'busy' ? BUSY_POLL_MS : IDLE_POLL_MS;
53}
54
55// The `Commands:` block of `cctop query --help`: one verb per line up to the
56// blank line before `Options:`; only the verbs the pane knows are kept.
57export function parseQueryVerbs(help: string): QueryVerb[] {
58 const lines = help.split('\n');
59 const start = lines.findIndex((line) => line.trim() === 'Commands:');
60 if (start < 0) return [];
61 const found = new Set<string>();
62 for (const line of lines.slice(start + 1)) {
63 if (line.trim() === '') break;
64 const m = /^\s+([a-z][\w-]*)/.exec(line);
65 if (m) found.add(m[1]);
66 }
67 return QUERY_VERBS.filter((verb) => found.has(verb));
68}
69
70// The marker other cctop processes read to learn what the pane is doing in
71// this session: `cctop split` refuses a second dashboard while it is open
72// (US-009) and `cctop pane status` reports it. Written once the module knows
73// its session id (so `loaded: true` alone proves the hooks module runs in
74// this session), on open, on every tick, and with `open: false` on ui.close.
75// `$.fs` cannot delete, so a reader treats `open: false` or a `heartbeatAt`
76// older than 30 s as "not open".
77export async function writeMarker($: PollerEngine, model: Model): Promise<void> {
78 if (model.sessionId === null) return;
79 const home = await $.home();
80 if (home === undefined) return;
81 const now = await $.clock.now();
82 const marker = {
83 version: model.version,
84 // The contract the module was built against and what its session.start
85 // self-check found, so `cctop pane status` pairs the running module with
86 // `claude --version` and relays a cause when a surface moved (issue #4).
87 testedWith: TESTED_WITH,
88 selfCheck: model.selfCheck,
89 sessionId: model.sessionId,
90 loaded: true,
91 loadedAt: model.loadedAt === null ? null : new Date(model.loadedAt).toISOString(),
92 openedAt: model.openedAt === null ? null : new Date(model.openedAt).toISOString(),
93 heartbeatAt: new Date(now).toISOString(),
94 open: model.open,
95 // Where the engine drew it last and whether it still does; `unknown`
96 // before the first render after an open (docs/claude-code-panels.md §5.4).
97 visibility: model.visibility,
98 placement: model.placement,
99 bodyColumns: model.bodyColumns,
100 viewportColumns: model.viewportColumns,
101 };
102 await $.fs.write(`${home}/.cctop/pane/${model.sessionId}.json`, JSON.stringify(marker));
103}
104
105export function createPoller($: PollerEngine, getModel: () => Model, setModel: (model: Model) => void): Poller {
106 let active = false;
107 let timer: Timer | null = null;
108 let timerMs = 0;
109 let inflight: Promise<void> | null = null;
110 // When the poller started; stands in for `queryAt` until the first success.
111 let startedAt = 0;
112 // Verbs that failed since their last success, so each failure is logged once.
113 const failing = new Set<string>();
114
115 const dispatch = (action: Action): void => setModel(reduce(getModel(), action));
116
117 const updateStale = async (): Promise<void> => {
118 const now = await $.clock.now();
119 dispatch({ type: 'stale', stale: now - (getModel().queryAt ?? startedAt) > STALE_AFTER_MS });
120 };
121
122 const readVerbs = async (): Promise<readonly QueryVerb[] | null> => {
123 try {
124 const result = await $.process.run(['cctop', 'query', '--help'], { timeoutMs: QUERY_TIMEOUT_MS });
125 if (result.exitCode !== 0) throw new Error(`exit ${result.exitCode}`);
126 const verbs = parseQueryVerbs(result.stdout);
127 dispatch({ type: 'verbs', verbs });
128 return verbs;
129 } catch (err) {
130 // Unknown until the next tick: every verb is tried meanwhile.
131 $.ui.log(`cctop: query --help failed: ${err instanceof Error ? err.message : String(err)}`);
132 return null;
133 }
134 };
135
136 const query = async (verb: QueryVerb, sessionId: string): Promise<boolean> => {
137 try {
138 // `--surface pane`: a fire the binary promotes for this pane (no
139 // dashboard running) is recorded as shown here.
140 const argv = ['cctop', 'query', verb, '--session', sessionId, '--surface', 'pane'];
141 const result = await $.process.run(argv, { timeoutMs: QUERY_TIMEOUT_MS });
142 // The session rotated while the binary ran: whatever it answered is
143 // the old session's, not the new one's.
144 if (getModel().sessionId !== sessionId) return false;
145 if (result.exitCode !== 0) throw new Error(`exit ${result.exitCode}: ${result.stderr.trim()}`);
146 dispatch({ type: 'query', verb, data: JSON.parse(result.stdout) as unknown });
147 failing.delete(verb);
148 return true;
149 } catch (err) {
150 if (!failing.has(verb)) {
151 failing.add(verb);
152 $.ui.log(`cctop: query ${verb} failed: ${err instanceof Error ? err.message : String(err)}`);
153 }
154 return false;
155 }
156 };
157
158 // After `/clear` the engine serves a new session id for the same process
159 // and, the d.ts says, no session.start; the registry entry is rewritten
160 // and every `cctop query --session <old id>` exits 2 with "no session
161 // matches". So the id is read again here, at every round and turn. The
162 // model is switched before the markers are written, so a failing write
163 // costs one round, never the switch.
164 const sync = async (): Promise<boolean> => {
165 const id = await $.session.id();
166 const before = getModel();
167 if (before.sessionId === id) return false;
168 const rotated = before.sessionId !== null;
169 if (rotated) {
170 // Each verb's next failure is the new session's own, logged once
171 // again; stale is judged from here, the new session having had no
172 // success yet.
173 failing.clear();
174 startedAt = await $.clock.now();
175 $.ui.log(`cctop: session ${before.sessionId} rotated to ${id}: following it`);
176 }
177 dispatch({ type: 'session.id', id });
178 // The old id's marker says the pane is not open there (`$.fs` cannot
179 // delete): `cctop split` would otherwise refuse for 30 s more, and the
180 // new id's marker says the module runs in this session.
181 if (rotated) await writeMarker($, { ...before, open: false });
182 await writeMarker($, getModel());
183 return rotated;
184 };
185
186 const round = async (): Promise<void> => {
187 await sync();
188 const sessionId = getModel().sessionId;
189 if (sessionId === null || getModel().binary !== 'present') return;
190 if (getModel().verbs === null) await readVerbs();
191 let ok = true;
192 let called = 0;
193 const busy = getModel().turn.state === 'busy';
194 for (const verb of QUERY_VERBS) {
195 if (!isSupported(getModel(), verb)) continue;
196 // The busy tick is the coach's: the slow verbs wait for the idle one.
197 if (busy && IDLE_ONLY_VERBS.includes(verb)) continue;
198 called += 1;
199 if (!(await query(verb, sessionId))) ok = false;
200 }
201 dispatch({ type: 'tick', at: await $.clock.now(), ok: ok && called > 0 });
202 await updateStale();
203 await writeMarker($, getModel());
204 };
205
206 const poller: Poller = {
207 start() {
208 if (active) return;
209 active = true;
210 poller.reschedule();
211 // The first round follows the start time, which `stale` is judged
212 // against until the first success; a stop meanwhile ends it there.
213 void $.clock
214 .now()
215 .then((now) => {
216 startedAt = now;
217 return active ? poller.tick() : undefined;
218 })
219 .catch((err: unknown) => $.ui.log(`cctop: poll failed: ${err instanceof Error ? err.message : String(err)}`));
220 },
221 stop() {
222 active = false;
223 timer?.cancel();
224 timer = null;
225 },
226 tick() {
227 if (inflight !== null) return Promise.resolve();
228 inflight = round()
229 .catch((err: unknown) => $.ui.log(`cctop: poll failed: ${err instanceof Error ? err.message : String(err)}`))
230 .finally(() => {
231 inflight = null;
232 });
233 return inflight;
234 },
235 reschedule() {
236 if (!active) return;
237 const ms = pollInterval(getModel());
238 if (timer !== null && timerMs === ms) return;
239 timer?.cancel();
240 timerMs = ms;
241 // Stale is judged beside the tick so a hung query still flips it.
242 timer = $.clock.every(ms, () => {
243 void updateStale().catch((err: unknown) => $.ui.log(`cctop: clock failed: ${err instanceof Error ? err.message : String(err)}`));
244 void poller.tick();
245 });
246 },
247 running: () => active,
248 sync,
249 };
250 return poller;
251}
252hooks/views/coach.tsx 296 lines1// The Coach view: `cctop query coach` drawn as the TUI draws it — the state
2// line, the four lights, the nudge slot, `next`, `snoozed` — in one frame,
3// then the action Buttons (`[1 fill]` for prompt- and slash-class actions,
4// `[2 snooze]`, `[3 why]`), then the detail frame of one light (the highest
5// by default; four Buttons pick another) or the why frame. Every string is
6// the binary's, cut at 52 cells there, so the two surfaces never disagree;
7// below 50 body columns each light's row drops its third figure.
8import type { ElementTable, RenderElement } from 'claude-code';
9import type { Model } from '../model';
10import { at, stringAt } from './format';
11import { ACCENT, CRIT, WARN, seg, textRow, type Line } from './frame';
12import { NEEDS_BINARY, type ViewElements } from './overview';
13import { bodyWidth, line, panel, row, wrapWords, type FrameRow } from './table';
14
15/** Body columns below which a light's row keeps only its first two figures. */
16export const NARROW_COLUMNS = 50;
17
18export type LightId = 'context' | 'cache' | 'limits' | 'rework';
19export const LIGHT_IDS: readonly LightId[] = ['context', 'cache', 'limits', 'rework'];
20export type Level = 'quiet' | 'watch' | 'act';
21
22export type Light = { id: LightId; level: Level; glyph: string; number: string; text: string; lines: string[]; source: string; approx: boolean };
23export type Nudge = {
24 id: string;
25 family: string;
26 cls: string;
27 line1: string;
28 line2: string;
29 evidence: string;
30 actionText: string;
31 actionKind: string;
32 explain: string;
33 saving: string;
34 retiresOn: string;
35 firedAt: number | null;
36 acting: boolean;
37};
38export type Coach = {
39 session: string;
40 model: string;
41 turn: number;
42 stateLine: string;
43 stateKind: string;
44 lights: Light[];
45 nudge: Nudge | null;
46 nextRow: string;
47 snoozedRow: string;
48 quietRow: string;
49 recent: string[];
50 sessionMode: string;
51 /** The one-line forms: L0 (≥ 80 columns), L1 (≥ 40), L2. */
52 lines: { l0: string; l1: string; l2: string };
53};
54
55const LEVELS: Level[] = ['quiet', 'watch', 'act'];
56
57function levelOf(v: unknown): Level {
58 const s = stringAt(v, 'level');
59 return s !== null && (LEVELS as string[]).includes(s) ? (s as Level) : 'quiet';
60}
61
62function lightOf(v: unknown): Light | null {
63 const id = stringAt(v, 'id');
64 if (id === null || !(LIGHT_IDS as string[]).includes(id)) return null;
65 const lines = at(v, 'lines');
66 return {
67 id: id as LightId,
68 level: levelOf(v),
69 glyph: stringAt(v, 'glyph') ?? '○',
70 number: stringAt(v, 'number') ?? '—',
71 text: stringAt(v, 'text') ?? '',
72 lines: Array.isArray(lines) ? lines.filter((l): l is string => typeof l === 'string') : [],
73 source: stringAt(v, 'source') ?? '',
74 approx: at(v, 'approx') === true,
75 };
76}
77
78function nudgeOf(v: unknown): Nudge | null {
79 if (v === null || typeof v !== 'object') return null;
80 const id = stringAt(v, 'id');
81 if (id === null) return null;
82 const fired = at(v, 'fired_at_ms');
83 return {
84 id,
85 family: stringAt(v, 'family') ?? '',
86 cls: stringAt(v, 'class') ?? '',
87 line1: stringAt(v, 'line1') ?? '',
88 line2: stringAt(v, 'line2') ?? '',
89 evidence: stringAt(v, 'evidence') ?? '',
90 actionText: stringAt(v, 'action_text') ?? '',
91 actionKind: stringAt(v, 'action_kind') ?? '',
92 explain: stringAt(v, 'explain') ?? '',
93 saving: stringAt(v, 'saving') ?? '',
94 retiresOn: stringAt(v, 'retires_on') ?? '',
95 firedAt: typeof fired === 'number' ? fired : null,
96 acting: at(v, 'acting') === true,
97 };
98}
99
100/** The coach object as the pane reads it, or null when the query has not answered (or is not this shape). */
101export function coachOf(query: unknown): Coach | null {
102 if (query === null || typeof query !== 'object') return null;
103 const lights = at(query, 'lights');
104 if (!Array.isArray(lights)) return null;
105 const parsed = lights.map(lightOf).filter((l): l is Light => l !== null);
106 if (parsed.length !== 4) return null;
107 const state = at(query, 'state');
108 const lines = at(query, 'lines');
109 const recent = at(query, 'recent');
110 return {
111 session: stringAt(query, 'session') ?? '',
112 model: stringAt(query, 'model') ?? '',
113 turn: typeof at(query, 'turn') === 'number' ? (at(query, 'turn') as number) : 0,
114 stateLine: stringAt(state, 'line') ?? '',
115 stateKind: stringAt(state, 'kind') ?? '',
116 lights: parsed,
117 nudge: nudgeOf(at(query, 'nudge')),
118 nextRow: stringAt(query, 'next_row') ?? 'next —',
119 snoozedRow: stringAt(query, 'snoozed_row') ?? 'snoozed —',
120 quietRow: stringAt(query, 'quiet_row') ?? ' quiet · nothing to act on',
121 recent: Array.isArray(recent) ? recent.map((r) => stringAt(r, 'row') ?? '').filter((r) => r !== '') : [],
122 sessionMode: stringAt(query, 'session_mode') ?? 'interactive',
123 lines: {
124 l0: stringAt(lines, 'l0') ?? '',
125 l1: stringAt(lines, 'l1') ?? '',
126 l2: stringAt(lines, 'l2') ?? '',
127 },
128 };
129}
130
131/** The status line for `columns` body cells: L0 at ≥ 80, L1 at ≥ 40, L2 below. */
132export function statusLine(c: Coach, columns: number): string {
133 return columns >= 80 ? c.lines.l0 : columns >= 40 ? c.lines.l1 : c.lines.l2;
134}
135
136/** The light whose level is highest (the first of equals): what the detail frame follows. */
137export function highestLight(c: Coach): LightId {
138 let best = c.lights[0];
139 for (const l of c.lights) if (LEVELS.indexOf(l.level) > LEVELS.indexOf(best.level)) best = l;
140 return best.id;
141}
142
143/** Prompt- and slash-class actions go into the prompt box; nothing else does. */
144export function fillable(n: Nudge | null): boolean {
145 return n !== null && n.actionText !== '' && (n.actionKind === 'prompt' || n.actionKind === 'slash');
146}
147
148function levelColor(level: Level): string | undefined {
149 return level === 'act' ? CRIT : level === 'watch' ? WARN : undefined;
150}
151
152/** A light's row text, its third figure dropped below NARROW_COLUMNS body columns (the context light keeps its tokens and bar, and drops the price). */
153export function lightText(l: Light, columns: number): string {
154 if (columns >= NARROW_COLUMNS) return l.text;
155 const parts = l.text.split(' · ');
156 return parts.slice(0, l.id === 'context' ? 1 : 2).join(' · ');
157}
158
159/** The elements the coach draws with: the views' Box and Text, plus Button. */
160export type CoachElements = Pick<ElementTable<'terminal'>, 'Box' | 'Text' | 'Button'>;
161
162/** What the pane may do on a press; built in pane.tsx over `$`. */
163export type CoachActions = {
164 fill(text: string): void;
165 snooze(rule: string): void;
166 why(): void;
167 light(id: LightId): void;
168};
169
170function stateRow(c: Coach): FrameRow {
171 const text = c.stateLine;
172 const waiting = c.stateKind.startsWith('◆');
173 const idle = c.stateKind === 'IDLE' || c.stateKind === 'LOOP';
174 const sp = text.indexOf(' ', waiting ? 2 : 0);
175 const word = sp < 0 ? text : text.slice(0, sp);
176 const rest = sp < 0 ? '' : text.slice(sp);
177 const style = waiting ? { color: WARN } : idle ? { dim: true } : { color: ACCENT };
178 return { line: [seg(word, style), seg(rest)] };
179}
180
181function separatorRow(inner: number): FrameRow {
182 return { line: [seg('─'.repeat(inner), { dim: true })] };
183}
184
185export function renderCoach(model: Model, el: CoachElements, columns: number, actions: CoachActions): RenderElement {
186 const { Box } = el;
187 const c = coachOf(model.query.coach);
188 const p = { title: 'coach', summary: c === null ? undefined : `${c.model.replace(/^claude-/, '')} · turn ${c.turn}` };
189 if (model.binary === 'missing') return panel(p, [line(NEEDS_BINARY, { key: 'advice_saving' })], columns, el);
190 if (c === null) return panel(p, [line('waiting for cctop query coach…', { key: 'advice_saving' })], columns, el);
191 const inner = bodyWidth(columns);
192 const rows: FrameRow[] = [stateRow(c), separatorRow(inner)];
193 for (const l of c.lights) {
194 const color = levelColor(l.level);
195 const style = color === undefined ? {} : { color };
196 rows.push({
197 line: [seg(l.glyph, style), seg(' '), seg(l.id.padEnd(8), style), seg(' '), seg(lightText(l, columns), style)],
198 key: `coach_${l.id}`,
199 });
200 }
201 rows.push(separatorRow(inner));
202 const n = c.nudge;
203 if (n !== null) {
204 rows.push({ line: [seg(n.line1, { bold: true })], key: 'advice_saving' });
205 rows.push({ line: [seg(n.line2, { color: ACCENT })] });
206 rows.push({ line: [seg(n.evidence, { dim: true })] });
207 } else {
208 rows.push({ line: [seg(c.quietRow, { dim: true })], key: 'advice_saving' });
209 for (const r of c.recent.slice(0, 2)) rows.push({ line: [seg(` ${r}`, { dim: true })] });
210 }
211 rows.push({ line: [seg('')] });
212 rows.push({ line: [seg(c.nextRow, { dim: true })] });
213 rows.push({ line: [seg(c.snoozedRow, { dim: true })] });
214 const card = panel(p, rows, columns, el);
215 return (
216 <Box flexDirection="column">
217 {card}
218 {buttons(c, el, actions)}
219 {model.coachWhy && n !== null ? whyFrame(n, columns, el) : detailFrame(c, model.coachLight ?? highestLight(c), columns, el, actions)}
220 </Box>
221 );
222}
223
224// The action row: fill (prompt-class only), snooze and why, hotkeys armed
225// only while a Button of the row has the focus.
226function buttons(c: Coach, el: CoachElements, actions: CoachActions): RenderElement {
227 const { Box, Text, Button } = el;
228 const n = c.nudge;
229 const parts: RenderElement[] = [];
230 if (fillable(n)) {
231 parts.push(<Button key="coach-fill" label="fill" hotkey="1" onPress={() => actions.fill(n!.actionText)} />);
232 }
233 if (n !== null) {
234 parts.push(<Text wrap="truncate"> </Text>);
235 parts.push(<Button key="coach-snooze" label="snooze" hotkey="2" onPress={() => actions.snooze(n.id)} />);
236 parts.push(<Text wrap="truncate"> </Text>);
237 parts.push(<Button key="coach-why" label="why" hotkey="3" onPress={() => actions.why()} />);
238 }
239 if (parts.length === 0) return <Box />;
240 return <Box flexDirection="row">{parts}</Box>;
241}
242
243// The detail frame of one light: its three lines, then the four Buttons
244// that pick another light.
245function detailFrame(c: Coach, id: LightId, columns: number, el: CoachElements, actions: CoachActions): RenderElement {
246 const { Box, Text, Button } = el;
247 const l = c.lights.find((x) => x.id === id) ?? c.lights[0];
248 const inner = bodyWidth(columns);
249 const rows: FrameRow[] = l.lines.map((text) => row([{ text }], inner));
250 if (rows.length === 0) rows.push(line('—'));
251 const frame = panel({ title: `${l.glyph} ${l.id}`, summary: `${l.source}${l.approx ? ' ≈' : ''}` }, rows, columns, el);
252 const picks: RenderElement[] = [];
253 for (const x of c.lights) {
254 if (picks.length > 0) picks.push(<Text wrap="truncate"> </Text>);
255 picks.push(
256 x.id === l.id ? (
257 <Text key={`coach-light-${x.id}`} wrap="truncate" inverse>
258 {` ${x.glyph} ${x.id} `}
259 </Text>
260 ) : (
261 <Button key={`coach-light-${x.id}`} label={`${x.glyph} ${x.id}`} plain onPress={() => actions.light(x.id)} />
262 ),
263 );
264 }
265 return (
266 <Box flexDirection="column">
267 {frame}
268 <Box flexDirection="row">{picks}</Box>
269 </Box>
270 );
271}
272
273// The why frame: what the nudge rests on and the rule's explanation.
274function whyFrame(n: Nudge, columns: number, el: ViewElements): RenderElement {
275 const inner = bodyWidth(columns);
276 const rows: FrameRow[] = [
277 { line: [seg(`${n.cls} ${n.id} · ${n.family}`, { bold: true })] },
278 { line: [seg('evidence ', { dim: true }), seg(n.evidence.trim())] },
279 { line: [seg('retires ', { dim: true }), seg(`on ${n.retiresOn}`)] },
280 { line: [seg('saving ', { dim: true }), seg(`${n.saving} (an estimate)`)] },
281 ];
282 if (n.actionText !== '') rows.push({ line: [seg(`${n.actionKind.padEnd(10)}`, { dim: true }), seg(n.actionText, { color: ACCENT })] });
283 rows.push({ line: [seg('')] });
284 for (const w of wrapWords(n.explain, inner)) rows.push({ line: [seg(w, { dim: true })] });
285 return panel({ title: 'why' }, rows, columns, el);
286}
287
288/** The inline (classic renderer) form: the L1 line as one row. */
289export function coachInlineRow(model: Model, el: ViewElements, columns: number): RenderElement | null {
290 const c = coachOf(model.query.coach);
291 if (c === null) return null;
292 const text = statusLine(c, columns);
293 const line: Line = [seg(text)];
294 return textRow(line, el, 'advice_saving');
295}
296hooks/views/index.ts 43 lines1// The view dispatcher: `model.view` picks which view draws the pane. Inline
2// placement (the classic renderer's few rows above the prompt) always draws
3// the Overview's short form, whatever view is selected.
4import type { RenderElement } from 'claude-code';
5import type { Model, Placement } from '../model';
6import { renderAdvisor } from './advisor';
7import { renderCoach, type CoachActions, type CoachElements } from './coach';
8import { renderAgents, type AgentsActions } from './agents';
9import { renderEvents } from './events';
10import { renderFiles } from './files';
11import { renderOverview, type OverviewActions, type ViewElements } from './overview';
12import { renderTools } from './tools';
13
14/** What the pane hands the views that press: the Button element and the closures over `$`. */
15export type ViewActions = { el: CoachElements; coach: CoachActions; overview: OverviewActions; agents: Pick<AgentsActions, 'open'> };
16
17export function renderView(
18 model: Model,
19 el: ViewElements,
20 columns: number,
21 placement: Placement,
22 now: number,
23 actions?: ViewActions,
24): RenderElement {
25 if (placement === 'inline') return renderOverview(model, el, columns, placement, now);
26 switch (model.view) {
27 case 'coach':
28 return actions === undefined ? renderOverview(model, el, columns, placement, now) : renderCoach(model, actions.el, columns, actions.coach);
29 case 'tools':
30 return renderTools(model, el, columns, now);
31 case 'agents':
32 return renderAgents(model, el, columns, now, actions === undefined ? undefined : { el: actions.el, open: actions.agents.open });
33 case 'files':
34 return renderFiles(model, el, columns);
35 case 'events':
36 return renderEvents(model, el, columns);
37 case 'advisor':
38 return renderAdvisor(model, el, columns);
39 case 'overview':
40 return renderOverview(model, el, columns, placement, now, actions === undefined ? undefined : { el: actions.el, actions: actions.overview });
41 }
42}
43hooks/views/overview.tsx 485 lines1// The Overview view: Console (PRD dashboard-v2 §4) drawn from `cctop query
2// dashboard` (src/dashboard.rs, schema 2) the way the TUI draws it
3// (src/ui/dashboard.rs): the header line with the phase cell right-aligned,
4// six cells — each a keyed Box of plain Buttons sharing one scope and one
5// press, the digit the engine's own chrome — the act line as a target of its
6// own with `a: advisor` at the right, the rule line naming the open body with
7// `0: home`, then the open body's rows. The pane has four colours and no hex
8// (views/frame.tsx), so a slice's series step is carried by weight and by the
9// alternating fill glyph alone.
10//
11// Before the binary answers, the engine's own header and a waiting line; on a
12// binary whose object is not schema 2, one line naming the cctop this pane
13// needs (FR-16). Inline placement (the classic renderer's few rows above the
14// prompt) is the strip: the status line, the coach's L1 line, the act line.
15import type { ElementTable, RenderElement } from 'claude-code';
16import { TESTED_WITH, usageRows, type Model, type Placement, type TurnState } from '../model';
17import { DASH, at, formatDuration, isMissing, stringAt } from './format';
18import { ACCENT, THEME, clip, dim, fit, join, seg, textRow, width, lineWidth, type Line, type Seg } from './frame';
19
20/** The elements a view draws with, as `$.ui.resolve(e)` answers them. */
21export type ViewElements = Pick<ElementTable<'terminal'>, 'Box' | 'Text'>;
22
23export const NEEDS_BINARY = 'needs the cctop binary';
24
25/** The dashboard object's schema this pane draws; an older binary sends 1. */
26export const SCHEMA = 2;
27
28/** Three cells per row from this many body columns; two below (the prototype's ladder). */
29export const THREE_CELLS = 80;
30/** A cell's middle form from this many cells wide; the short one below. */
31export const MID_CELL = 28;
32/** The act line's right-aligned `a: advisor` from this width. */
33export const ACT_TAIL = 66;
34/** The act line's full copy from this width; the short one below. */
35export const ACT_FULL = 72;
36
37/** The four palette colours a view may name. */
38export type Color = 'green' | 'yellow' | 'red' | 'cyan';
39
40export type Badge = { label: 'bin' | 'shim' | 'hooks'; on: boolean; text?: string };
41
42export type Header = {
43 status: TurnState;
44 turn: number;
45 elapsed: string;
46 model: string;
47 mode: string | null;
48 effort: string;
49 badges: Badge[];
50};
51
52const STATUS_MARK: Record<TurnState, string> = { busy: '●', idle: '○', waiting: '◆' };
53const STATUS_PILL: Record<TurnState, string> = { busy: 'BUSY', idle: 'IDLE', waiting: 'WAITING' };
54
55function turnElapsedMs(model: Model, now: number): number | null {
56 return model.turn.startedAt === null ? null : Math.max(0, now - model.turn.startedAt);
57}
58
59/** The engine's own header: what the pane knows before the binary answers. */
60export function header(model: Model, now: number): Header {
61 const summary = model.query.summary;
62 const elapsed = turnElapsedMs(model, now);
63 return {
64 status: model.turn.state,
65 turn: model.turn.number,
66 elapsed: elapsed === null ? DASH : formatDuration(elapsed),
67 model: model.modelName ?? stringAt(summary, 'session', 'model') ?? DASH,
68 mode: stringAt(summary, 'session', 'permission_mode'),
69 effort: stringAt(summary, 'session', 'effort') ?? DASH,
70 badges: [
71 { label: 'bin', on: model.binary === 'present' },
72 // Limits come from the status-line shim alone, so their presence is its.
73 { label: 'shim', on: at(summary, 'limits') !== undefined && !isMissing(summary, 'limits') },
74 // TESTED_WITH names the Claude Code version the `$` contract was
75 // checked against (pane.tsx re-exports it; scripts/check-plugin-types.sh
76 // catches drift), shown here whether or not the hooks are installed.
77 { label: 'hooks', on: at(summary, 'hooks_installed') === true, text: `hooks ${TESTED_WITH}` },
78 ],
79 };
80}
81
82function statusPill(status: TurnState): Line {
83 const text = `${STATUS_MARK[status]} ${STATUS_PILL[status]}`;
84 if (status === 'busy') return [seg(text, { color: THEME.green, bold: true })];
85 if (status === 'waiting') return [seg(text, { color: THEME.yellow, bold: true })];
86 return [dim(text)];
87}
88
89/** The `bin · shim · hooks 2.1.273` badges, the on ones green. */
90export function badgesLine(badges: Badge[]): Line {
91 return join(
92 badges.map((b) => [b.on ? seg(b.text ?? b.label, { color: THEME.green }) : dim(b.text ?? b.label)]),
93 seg(' '),
94 );
95}
96
97// ---------------------------------------------------------------- the object
98
99export type Cell = { key: string; id: string; opens: string; label: Seg[]; mid: Seg[]; short: Seg[] };
100export type Act = { key: string; opens: string; line: Seg[]; short: Seg[]; tag: string; nudge: string | null; blocked: boolean; acting: boolean };
101export type Slice = { label: string; tokens: number; step: number };
102export type Body = { key: string; id: string; title: string; keys: string; rows: Seg[][]; slices: Slice[] };
103export type Dashboard = {
104 schema: number;
105 headerLine: string;
106 /** The header's second row, the workflow strip, while a run is live (`header.workflow`); null otherwise. */
107 workflow: Seg[] | null;
108 phase: { glyph: string; word: string; elapsed: string; tokens: string[] };
109 cells: Cell[];
110 act: Act;
111 bodies: Body[];
112 /** Nudges are shown this session; false on the control arm of the coach's own measurement (`cctop run --coach off|auto`). */
113 exposed: boolean;
114 lines: { l1: string; l2: string };
115};
116
117/** A tone as the binary tags it, to the pane's segment style: the series
118 * steps by weight (the pane has no ramp — dim, plain, bold). */
119function toned(text: string, tone: unknown): Seg {
120 switch (tone) {
121 case 'dim':
122 case 's0':
123 return dim(text);
124 case 'accent':
125 return seg(text, { color: ACCENT });
126 case 'ok':
127 return seg(text, { color: THEME.green });
128 case 'warn':
129 return seg(text, { color: THEME.yellow });
130 case 'crit':
131 return seg(text, { color: THEME.red });
132 case 'bold':
133 case 's2':
134 return seg(text, { bold: true });
135 default:
136 return seg(text);
137 }
138}
139
140function segsOf(v: unknown): Seg[] {
141 if (!Array.isArray(v)) return [];
142 return v.map((s) => toned(stringAt(s, 'text') ?? '', at(s, 'tone'))).filter((s) => s.text !== '');
143}
144
145function strings(v: unknown): string[] {
146 return Array.isArray(v) ? v.filter((t): t is string => typeof t === 'string') : [];
147}
148
149/** The `schema` of a dashboard object, or null when there is none yet. */
150export function schemaOf(query: unknown): number | null {
151 if (query === null || typeof query !== 'object') return null;
152 const s = at(query, 'schema');
153 return typeof s === 'number' ? s : null;
154}
155
156/** The dashboard object as the pane reads it, or null before the binary answered or on another schema. */
157export function dashboardOf(query: unknown): Dashboard | null {
158 if (schemaOf(query) !== SCHEMA) return null;
159 const cells = at(query, 'cells');
160 const bodies = at(query, 'bodies');
161 const act = at(query, 'act');
162 if (!Array.isArray(cells) || !Array.isArray(bodies) || act === null || typeof act !== 'object') return null;
163 const phase = at(query, 'header', 'phase');
164 return {
165 schema: SCHEMA,
166 headerLine: stringAt(query, 'header', 'line') ?? '',
167 workflow: Array.isArray(at(query, 'header', 'workflow')) ? segsOf(at(query, 'header', 'workflow')) : null,
168 phase: {
169 glyph: stringAt(phase, 'glyph') ?? '○',
170 word: stringAt(phase, 'word') ?? '',
171 elapsed: stringAt(phase, 'elapsed') ?? DASH,
172 tokens: strings(at(phase, 'tokens')),
173 },
174 cells: cells.map((c) => ({
175 key: stringAt(c, 'key') ?? '',
176 id: stringAt(c, 'id') ?? '',
177 opens: stringAt(c, 'opens') ?? '',
178 label: segsOf(at(c, 'label')),
179 mid: segsOf(at(c, 'mid')),
180 short: segsOf(at(c, 'short')),
181 })),
182 act: {
183 key: stringAt(act, 'key') ?? 'a',
184 opens: stringAt(act, 'opens') ?? 'advisor',
185 line: segsOf(at(act, 'line')),
186 short: segsOf(at(act, 'short')),
187 tag: stringAt(act, 'tag') ?? '',
188 nudge: stringAt(act, 'nudge'),
189 blocked: at(act, 'blocked') === true,
190 acting: at(act, 'acting') === true,
191 },
192 bodies: bodies.map((b) => ({
193 key: stringAt(b, 'key') ?? '',
194 id: stringAt(b, 'id') ?? '',
195 title: stringAt(b, 'title') ?? '',
196 keys: stringAt(b, 'keys') ?? '',
197 rows: Array.isArray(at(b, 'rows')) ? (at(b, 'rows') as unknown[]).map(segsOf) : [],
198 slices: Array.isArray(at(b, 'slices'))
199 ? (at(b, 'slices') as unknown[]).map((s) => ({
200 label: stringAt(s, 'label') ?? '',
201 tokens: typeof at(s, 'tokens') === 'number' ? (at(s, 'tokens') as number) : 0,
202 step: typeof at(s, 'step') === 'number' ? (at(s, 'step') as number) : 0,
203 }))
204 : [],
205 })),
206 exposed: at(query, 'exposed') !== false,
207 lines: { l1: stringAt(query, 'lines', 'l1') ?? '', l2: stringAt(query, 'lines', 'l2') ?? '' },
208 };
209}
210
211/** The line the pane draws on a binary whose object is another schema (FR-16). */
212export function schemaMismatch(schema: number): string {
213 return `cctop ${schema < SCHEMA ? '0.6.0 or newer' : 'newer than this pane'} needed: the binary sends dashboard schema ${schema}, this pane draws ${SCHEMA} — brew upgrade cctop && claude plugin update cctop@cctop`;
214}
215
216// -------------------------------------------------------------- the surface
217
218export type OverviewActions = {
219 /** A cell's, the act line's or `0 home`'s press: open that body in place. */
220 open(id: string): void;
221 /** `? keys` pressed: expand the rule line into the key map, or collapse it. */
222 keys(keys: boolean): void;
223};
224
225/** What the pane's keys are, when the rule line is expanded: the hotkeys, and the pointer. */
226export const PANE_KEYS = '1-6 a 0 body · click a cell, the act line or home · the view bar for the other views';
227
228/** The elements the Overview draws with: the views' Box and Text, plus Button for the targets. */
229export type OverviewElements = Pick<ElementTable<'terminal'>, 'Box' | 'Text' | 'Button'>;
230
231/** Row 1: ` cctop`, the session facts dim, the phase cell right-aligned in its colour. */
232function headerLine(d: Dashboard, columns: number): Line {
233 const facts = d.headerLine.replace(/^cctop\s+/, '');
234 const phaseText = clip(`${d.phase.glyph} ${d.phase.word} ${d.phase.elapsed}`, Math.min(40, Math.max(0, columns - 10)));
235 const factsText = clip(facts, Math.max(0, columns - 8 - width(phaseText) - 2));
236 const padCells = Math.max(0, columns - 8 - width(factsText) - width(phaseText));
237 const phaseStyle: Omit<Seg, 'text'> =
238 d.phase.glyph === '◆' ? { color: THEME.yellow, bold: true } : d.phase.glyph === '○' ? { dim: true, bold: true } : { color: THEME.green, bold: true };
239 return [seg(' cctop ', { color: ACCENT, bold: true }), dim(factsText), seg(' '.repeat(padCells)), seg(phaseText, phaseStyle)];
240}
241
242/** The cell text form for the width: wide at ≥ 80 columns, mid at cells ≥ 28, short below. */
243export function cellForm(columns: number, cellWidth: number): (c: Cell) => Seg[] {
244 if (columns >= THREE_CELLS) return (c) => c.label;
245 if (cellWidth >= MID_CELL) return (c) => c.mid;
246 return (c) => c.short;
247}
248
249// A cell: a keyed Box of plain Buttons sharing one scope and one press —
250// the first carries the hotkey (the engine draws `1: ctx 35%`), the rest of
251// the label and the padding are Buttons too, so the whole area presses and
252// lights (FR-13). The open body's cell is Text in `ok`, bold: the one state
253// that is not a target. Without Buttons (a test's bare elements, the inline
254// strip) every part is Text and the digit is drawn as the engine would.
255function cellBox(c: Cell, text: Seg[], cw: number, active: boolean, el: OverviewElements, actions: OverviewActions | undefined): RenderElement {
256 const { Box, Button } = el;
257 // `1: ` is the engine's; the text has the rest of the cell, cut a cell
258 // short so the gap to the next cell survives (the TUI's `cw - 4`), the
259 // gap a Button of one space so the whole area presses.
260 const body = [...fit(text, Math.max(1, cw - 4)), seg(' ')];
261 const first = body[0] ?? seg('');
262 const rest = body.slice(1);
263 if (actions === undefined || active) {
264 const style: Omit<Seg, 'text'> = active ? { color: THEME.green, bold: true } : { color: ACCENT, bold: true };
265 const line: Line = [seg(`${c.key}: `, style), ...(active ? body.map((s) => ({ text: s.text, color: THEME.green, bold: true })) : body)];
266 return textRow(line, el, `cell_${c.id}`);
267 }
268 const scope = `cctop-cell-${c.id}`;
269 const press = () => actions.open(c.opens);
270 return (
271 <Box key={`cell_${c.id}`} flexDirection="row" hover={{ scope }}>
272 <Button key={`cell-${c.id}`} label={first.text} hotkey={c.key} plain dimColor={first.dim === true} onPress={press} />
273 {rest.map((s, i) => (
274 <Button key={`cell-${c.id}-${i}`} label={s.text} plain dimColor={s.dim === true} onPress={press} />
275 ))}
276 </Box>
277 );
278}
279
280/** Rows 2–4: the cells, three per row at ≥ 80 columns, two below. */
281function cellRows(d: Dashboard, open: string, columns: number, el: OverviewElements, actions: OverviewActions | undefined): RenderElement[] {
282 const { Box } = el;
283 const perRow = columns >= THREE_CELLS ? 3 : 2;
284 const cw = Math.floor(Math.max(0, columns - 2) / perRow);
285 const form = cellForm(columns, cw);
286 const out: RenderElement[] = [];
287 for (let i = 0; i < d.cells.length; i += perRow) {
288 const cells = d.cells.slice(i, i + perRow).map((c) => (
289 <Box width={cw} flexShrink={0}>
290 {cellBox(c, form(c), cw, c.opens === open, el, actions)}
291 </Box>
292 ));
293 out.push(
294 <Box key={`cells_${i}`} flexDirection="row">
295 {textRow([seg(' ')], el)}
296 {cells}
297 </Box>,
298 );
299 }
300 return out;
301}
302
303/** Word-wrap segments into rows of at most `columns` cells, breaking at spaces (FR-7). */
304export function wrap(segs: Seg[], columns: number, indent: number): Line[] {
305 const rows: Line[] = [[]];
306 let used = 0;
307 for (const s of segs) {
308 let pending = '';
309 const flush = (): void => {
310 if (pending !== '') rows[rows.length - 1].push({ ...s, text: pending });
311 pending = '';
312 };
313 for (const word of s.text.split(/(?<= )/)) {
314 const w = width(word);
315 if (used + w > columns && used > indent) {
316 flush();
317 rows.push([seg(' '.repeat(indent))]);
318 used = indent;
319 const trimmed = word.replace(/^ +/, '');
320 pending += trimmed;
321 used += width(trimmed);
322 } else {
323 pending += word;
324 used += w;
325 }
326 }
327 flush();
328 }
329 return rows;
330}
331
332// Row 5: the act line, the whole of it a target (a keyed Box of plain
333// Buttons in the advisor's scope) with `a: advisor` right-aligned at
334// ACT_TAIL columns; wrapped onto a second row rather than cut.
335function actRows(d: Dashboard, open: string, columns: number, el: OverviewElements, actions: OverviewActions | undefined): RenderElement[] {
336 const { Box, Button } = el;
337 const text = columns >= ACT_FULL ? d.act.line : d.act.short;
338 const segs: Seg[] = [dim(' '), ...text];
339 if (d.act.tag !== '' && columns >= ACT_FULL) segs.push(dim(` ${d.act.tag}`));
340 if (d.act.acting) segs.push(dim(' · acting…'));
341 const tailWidth = columns >= ACT_TAIL ? 'a: advisor '.length : 0;
342 const rows = wrap(segs, Math.max(1, columns - tailWidth - 1), 3).slice(0, 2);
343 const active = open === d.act.opens;
344 const out: RenderElement[] = [];
345 rows.forEach((row, i) => {
346 const line = fit(row, Math.max(1, columns - (i === 0 ? tailWidth : 0)));
347 if (actions === undefined || active) {
348 const tail: Line = i === 0 && tailWidth > 0 ? [seg('a: ', { color: active ? THEME.green : ACCENT, bold: true }), dim('advisor ')] : [];
349 out.push(textRow([...line, ...tail], el, i === 0 ? 'advice_saving' : undefined));
350 return;
351 }
352 const scope = 'cctop-cell-advisor';
353 const press = () => actions.open(d.act.opens);
354 out.push(
355 <Box key={i === 0 ? 'advice_saving' : `act_${i}`} flexDirection="row" hover={{ scope }}>
356 {line.map((s, j) => (
357 <Button key={`act-${i}-${j}`} label={s.text} plain dimColor={s.dim === true} onPress={press} />
358 ))}
359 {i === 0 && tailWidth > 0 ? <Button key="act-advisor" label="advisor " hotkey="a" plain dimColor onPress={press} /> : null}
360 </Box>,
361 );
362 });
363 return out;
364}
365
366// Row 6: `─── title ─────── 0: home · ? keys ───`, the home a plain
367// Button with hotkey 0 and `? keys` a plain Button that expands the tail
368// into the pane's key map (the TUI's `?`); short widths lose `? keys`,
369// then the word `home`, as the TUI does.
370function ruleRow(body: Body, expanded: boolean, columns: number, el: OverviewElements, actions: OverviewActions | undefined): RenderElement {
371 const { Box, Button } = el;
372 const home = body.id === 'events';
373 const head: Line = [dim('─── '), seg(body.title, { bold: true }), dim(' ')];
374 const homeText = '0: home';
375 const keysText = expanded ? clip(PANE_KEYS, Math.max(1, columns - lineWidth(head) - 1 - width(homeText) - 5 - 4)) : '? keys';
376 let withKeys = expanded || lineWidth(head) + 1 + width(homeText) + 5 + width(keysText) + 4 <= columns;
377 let tailWidth = 1 + width(homeText) + (withKeys ? 5 + width(keysText) : 0) + 4;
378 let bare = false;
379 if (lineWidth(head) + tailWidth > columns) {
380 withKeys = false;
381 bare = true;
382 tailWidth = 1 + 1 + 4;
383 }
384 const rule = dim('─'.repeat(Math.max(0, columns - lineWidth(head) - tailWidth)));
385 const keyStyle: Omit<Seg, 'text'> = { color: home ? THEME.green : ACCENT, bold: true };
386 if (actions === undefined || bare) {
387 const tail: Line = bare
388 ? [seg('0', keyStyle)]
389 : [seg('0: ', keyStyle), dim('home'), ...(withKeys ? (expanded ? [dim(' · '), dim(keysText)] : [dim(' · '), seg('?', { color: ACCENT, bold: true }), dim(' keys')]) : [])];
390 return textRow([...head, rule, dim(' '), ...tail, dim(' ───')], el, `rule_${body.id}`);
391 }
392 const homePart: RenderElement = home ? textRow([seg('0: ', keyStyle), dim('home')], el) : <Button key="cell-home" label="home" hotkey="0" plain dimColor onPress={() => actions.open('events')} />;
393 const keysPart: RenderElement[] = withKeys
394 ? [textRow([dim(' · ')], el), <Button key="rule-keys" label={keysText} plain dimColor onPress={() => actions.keys(!expanded)} />]
395 : [];
396 return (
397 <Box key={`rule_${body.id}`} flexDirection="row">
398 {textRow([...head, rule, dim(' ')], el)}
399 {homePart}
400 {keysPart}
401 {textRow([dim(' ───')], el)}
402 </Box>
403 );
404}
405
406/** The engine's own reading (`$.session.usage`): the context and the rate
407 * limits on one line — the pane's figures with no binary behind them. */
408export function engineUsageLine(model: Model, now: number): Line | null {
409 if (model.usage === null) return null;
410 const rows = usageRows(model.usage, now).filter((r) => r.key !== 'cost');
411 if (rows.length === 0) return null;
412 return [seg(' '), ...join(rows.map((r) => [dim(`${r.label.toLowerCase()} `), seg(r.value)]), dim(' · '))];
413}
414
415/** The inline form (the classic renderer's few rows above the prompt): the status line, the engine's usage line, the strip, the act line. */
416function renderInline(model: Model, el: ViewElements, columns: number, now: number): RenderElement {
417 const { Box } = el;
418 const head = header(model, now);
419 const rows: RenderElement[] = [
420 textRow(
421 [...statusPill(head.status), seg(` · turn ${head.turn} · ${head.elapsed} · ${head.model} · ${head.effort} `), ...badgesLine(head.badges)],
422 el,
423 'session_status',
424 ),
425 ];
426 const usage = engineUsageLine(model, now);
427 if (usage !== null) rows.push(textRow(fit(usage, columns), el, 'context_size'));
428 const d = dashboardOf(model.query.dashboard);
429 if (d !== null) {
430 if (d.lines.l1 !== '') rows.push(textRow([seg(` ${d.lines.l1}`)], el, 'coach_context'));
431 rows.push(textRow(fit([dim(' '), ...d.act.short], columns), el, 'advice_saving'));
432 }
433 return <Box flexDirection="column">{rows}</Box>;
434}
435
436/**
437 * Console: the header line, the six cells, the act line, the rule line and
438 * the open body's rows. Inline placement draws the strip (renderInline).
439 */
440export function renderOverview(
441 model: Model,
442 el: ViewElements,
443 columns: number,
444 placement: Placement,
445 now: number,
446 buttons?: { el: OverviewElements; actions: OverviewActions },
447): RenderElement {
448 const { Box } = el;
449 if (placement === 'inline') return renderInline(model, el, columns, now);
450 const d = dashboardOf(model.query.dashboard);
451 const body: RenderElement[] = [];
452 if (d === null) {
453 // Before the binary answers, or on another schema: the engine's own
454 // header and one line saying what is missing.
455 const head = header(model, now);
456 body.push(
457 textRow(
458 [seg(' cctop ', { bold: true }), seg(`${head.model} · turn ${head.turn} · ${head.elapsed} `), ...statusPill(head.status), seg(' '), ...badgesLine(head.badges)],
459 el,
460 'session_status',
461 ),
462 );
463 const usage = engineUsageLine(model, now);
464 if (usage !== null) body.push(textRow(fit(usage, columns), el, 'context_size'));
465 const schema = schemaOf(model.query.dashboard);
466 const text = schema !== null && schema !== SCHEMA ? schemaMismatch(schema) : model.binary === 'missing' ? NEEDS_BINARY : 'waiting for cctop query dashboard…';
467 body.push(textRow([dim(` ${text}`)], el, 'advice_saving'));
468 return <Box flexDirection="column">{body}</Box>;
469 }
470 const bel: OverviewElements = buttons?.el ?? { ...el, Button: el.Box as OverviewElements['Button'] };
471 const actions = buttons?.actions;
472 const open = model.body;
473 body.push(textRow(headerLine(d, columns), el, 'session_status'));
474 // The workflow strip, verbatim, above the cells while a run is live (the TUI's row 2).
475 if (d.workflow !== null) body.push(textRow(fit(d.workflow, columns), el, 'workflow_failed'));
476 body.push(...cellRows(d, open, columns, bel, actions));
477 body.push(...actRows(d, open, columns, bel, actions));
478 const shown = d.bodies.find((b) => b.id === open) ?? d.bodies.find((b) => b.id === 'events') ?? d.bodies[0];
479 if (shown !== undefined) {
480 body.push(ruleRow(shown, model.keys, columns, bel, actions));
481 shown.rows.forEach((row, i) => body.push(textRow(row.length === 0 ? [seg('')] : fit(row, columns), el, i === 0 ? `body_${shown.id}` : undefined)));
482 }
483 return <Box flexDirection="column">{body}</Box>;
484}
485hooks/views/format.ts 91 lines1// Shared by the views: the shape `cctop query` gives every number (a value
2// with its unit, metric id and `approx` flag, or a `{ source: "missing" }`
3// object), accessors that read one out of the untyped query JSON, and the
4// number and time formats the TUI uses (src/ui/fmt.rs) so both draw the same
5// text.
6import { formatTokens } from '../model';
7
8/** A measured number as `cctop query` reports it. */
9export type Measured = { value: number; unit: string; metric_id: string; approx: boolean };
10
11/** What a view draws for a figure it has no source for. */
12export const DASH = '—';
13
14function isRecord(x: unknown): x is Record<string, unknown> {
15 return x !== null && typeof x === 'object' && !Array.isArray(x);
16}
17
18/** `obj.a.b.c` of an unknown JSON value; undefined at the first miss. */
19export function at(obj: unknown, ...path: string[]): unknown {
20 let cur = obj;
21 for (const key of path) {
22 if (!isRecord(cur)) return undefined;
23 cur = cur[key];
24 }
25 return cur;
26}
27
28/** The measured number at `path`; null when absent, `missing` or malformed. */
29export function measured(obj: unknown, ...path: string[]): Measured | null {
30 const v = at(obj, ...path);
31 if (!isRecord(v) || typeof v.value !== 'number' || !Number.isFinite(v.value)) return null;
32 return {
33 value: v.value,
34 unit: typeof v.unit === 'string' ? v.unit : '',
35 metric_id: typeof v.metric_id === 'string' ? v.metric_id : '',
36 approx: v.approx === true,
37 };
38}
39
40/** Whether the value at `path` is a `{ source: "missing" }` object. */
41export function isMissing(obj: unknown, ...path: string[]): boolean {
42 return at(obj, ...path, 'source') === 'missing';
43}
44
45/** The non-empty string at `path`, else null. */
46export function stringAt(obj: unknown, ...path: string[]): string | null {
47 const v = at(obj, ...path);
48 return typeof v === 'string' && v !== '' ? v : null;
49}
50
51/** `≈` before an estimate, as the TUI marks `approx: true`. */
52export function mark(text: string, approx: boolean): string {
53 return approx ? `≈${text}` : text;
54}
55
56/** A measured token count, marked when estimated; `—` when absent. */
57export function tokensOf(m: Measured | null): string {
58 return m === null ? DASH : mark(formatTokens(Math.round(m.value)), m.approx);
59}
60
61/** Milliseconds as the TUI's fmt::duration_ms: `0:48`, `2:31`, `1h 12m`, `4d 03h`. */
62export function formatDuration(ms: number): string {
63 const s = Math.floor(Math.max(0, ms) / 1000);
64 const two = (n: number) => String(n).padStart(2, '0');
65 if (s < 3600) return `${Math.floor(s / 60)}:${two(s % 60)}`;
66 if (s < 86400) return `${Math.floor(s / 3600)}h ${two(Math.floor((s % 3600) / 60))}m`;
67 return `${Math.floor(s / 86400)}d ${two(Math.floor((s % 86400) / 3600))}h`;
68}
69
70/** Milliseconds as the TUI's fmt::short_ms: `123ms`, `1.6s`, then duration_ms. */
71export function formatShortMs(ms: number): string {
72 if (ms < 1000) return `${Math.round(Math.max(0, ms))}ms`;
73 if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
74 return formatDuration(ms);
75}
76
77/** Bytes as the TUI's fmt::bytes: `1.2 GB`, `41 MB`, `512 kB`. */
78export function formatBytes(b: number): string {
79 if (b >= 1 << 30) return `${(b / (1 << 30)).toFixed(1)} GB`;
80 if (b >= 1 << 20) return `${Math.floor(b / (1 << 20))} MB`;
81 return `${Math.floor(b / 1024)} kB`;
82}
83
84/** Dollars as the TUI's fmt::usd: `$4.37`, `$12.5`, `$137`, `$0.004`. */
85export function formatUsd(v: number): string {
86 if (v >= 100) return `$${v.toFixed(0)}`;
87 if (v >= 10) return `$${v.toFixed(1)}`;
88 if (v >= 0.01) return `$${v.toFixed(2)}`;
89 return `$${v.toFixed(3)}`;
90}
91hooks/views/frame.tsx 224 lines1// The pane's drawing primitives, shaped after the TUI (src/ui/panel.rs,
2// src/ui/widgets.rs): a framed panel whose top border carries the hotkey,
3// title and summary (`╭1 Context ─ 40 % est ──╮`), gauges of ▇ and ▁, a
4// sparkline, and the colour bands. Every row is one truncating Text of inline
5// segments, padded to the frame's width in TypeScript, so a row is three or
6// four nodes and a whole view stays far below the engine's 2000-node cap.
7//
8// Colours are Claude Code theme keys, not palette names, so the pane follows
9// the person's theme: `suggestion` is the accent (what the engine colours a
10// Button's hotkey with), `success` / `warning` / `error` the three bands,
11// and the borders are dimmed default text like the engine's own frames.
12import type { ElementTable, RenderElement } from 'claude-code';
13import type { Color, ViewElements } from './overview';
14
15export const ACCENT = 'suggestion';
16export const OK = 'success';
17export const WARN = 'warning';
18export const CRIT = 'error';
19
20/** The TUI's palette roles, as the views name them, to the theme keys drawn. */
21export const THEME: Record<Color, string> = { green: OK, yellow: WARN, red: CRIT, cyan: ACCENT };
22
23/** One styled run of text inside a row; `key` names the metric it shows (docs/metrics.md). */
24export type Seg = { text: string; color?: string; dim?: boolean; bold?: boolean; inverse?: boolean; key?: string };
25export type Line = Seg[];
26
27export const GAUGE_FILL = '▇';
28export const GAUGE_EMPTY = '▁';
29const SPARK = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
30const H = '─';
31
32export const seg = (text: string, style: Omit<Seg, 'text'> = {}): Seg => ({ text, ...style });
33export const dim = (text: string): Seg => ({ text, dim: true });
34
35/** Display columns of a string: every glyph the pane draws is one cell. */
36export function width(text: string): number {
37 return [...text].length;
38}
39
40export function lineWidth(line: Line): number {
41 return line.reduce((n, s) => n + width(s.text), 0);
42}
43
44/** The colour band of a 0–1 fill, as the TUI's `band_style`. */
45export function band(ratio: number, warn: number, crit: number): string {
46 return ratio >= crit ? CRIT : ratio >= warn ? WARN : OK;
47}
48
49/** A gauge of `cells` columns filled to `ratio`, the fill in `color`, the rest dim. */
50export function gauge(ratio: number, cells: number, color: string): Line {
51 const n = Math.max(0, cells);
52 const filled = Math.min(n, Math.round(Math.max(0, Math.min(1, ratio)) * n));
53 const out: Line = [];
54 if (filled > 0) out.push(seg(GAUGE_FILL.repeat(filled), { color }));
55 if (n - filled > 0) out.push(dim(GAUGE_EMPTY.repeat(n - filled)));
56 return out;
57}
58
59/** The last `cells` values scaled to their max, one ▁–█ glyph each; empty when all are zero. */
60export function sparkline(values: readonly number[], cells: number): string {
61 const tail = values.slice(Math.max(0, values.length - cells));
62 const max = Math.max(0, ...tail);
63 if (max <= 0) return '';
64 return tail.map((v) => SPARK[Math.round((Math.max(0, v) / max) * 7)]).join('');
65}
66
67/** `text` cut to `cells` with `…`, or as it is when it fits. */
68export function clip(text: string, cells: number): string {
69 const chars = [...text];
70 if (chars.length <= cells) return text;
71 if (cells <= 0) return '';
72 if (cells === 1) return '…';
73 return chars.slice(0, cells - 1).join('') + '…';
74}
75
76/** `text` in a field of `cells`: left-aligned and space-padded, or right-aligned. */
77export function pad(text: string, cells: number, right = false): string {
78 const cut = clip(text, cells);
79 const fill = ' '.repeat(Math.max(0, cells - width(cut)));
80 return right ? fill + cut : cut + fill;
81}
82
83// Cuts a line at `cells` (an ellipsis on the segment that crosses the edge)
84// and pads it with spaces to exactly `cells`, merging same-styled neighbours
85// so a row stays a handful of nodes.
86export function fit(line: Line, cells: number): Line {
87 const out: Line = [];
88 let used = 0;
89 const push = (s: Seg): void => {
90 if (s.text === '') return;
91 const last = out[out.length - 1];
92 if (last !== undefined && sameStyle(last, s)) last.text += s.text;
93 else out.push({ ...s });
94 };
95 for (const s of line) {
96 const room = cells - used;
97 if (room <= 0) break;
98 const w = width(s.text);
99 if (w <= room) {
100 push(s);
101 used += w;
102 } else {
103 // A text the binary already clipped (`…`) grows no second ellipsis
104 // when the cut lands on its first, as the TUI's `cut` collapses it.
105 push({ ...s, text: clip(s.text, room).replace(/……$/, '…') });
106 used = cells;
107 }
108 }
109 if (used < cells) push({ text: ' '.repeat(cells - used) });
110 return out;
111}
112
113function sameStyle(a: Seg, b: Seg): boolean {
114 return (
115 a.color === b.color && !!a.dim === !!b.dim && !!a.bold === !!b.bold && !!a.inverse === !!b.inverse && a.key === b.key
116 );
117}
118
119/** Joins lines with a separator segment. */
120export function join(parts: Line[], separator: Seg): Line {
121 const out: Line = [];
122 parts.forEach((p, i) => {
123 if (i > 0) out.push(separator);
124 out.push(...p);
125 });
126 return out;
127}
128
129/** One row of the pane: a truncating Text of inline segments. `key` names the metric it shows. */
130export function textRow(line: Line, el: ViewElements, key?: string): RenderElement {
131 const { Box, Text } = el;
132 const keyed = key === undefined ? {} : { key };
133 return (
134 <Box {...keyed}>
135 <Text wrap="truncate">
136 {line.map((s) => (
137 <Text {...(s.key === undefined ? {} : { key: s.key })} color={s.color} dimColor={s.dim} bold={s.bold} inverse={s.inverse}>
138 {s.text}
139 </Text>
140 ))}
141 </Text>
142 </Box>
143 );
144}
145
146export type Frame = {
147 /** The digit drawn in the accent colour before the title, as the TUI numbers its panels. */
148 hotkey?: string;
149 title: string;
150 /** Drawn after the title, `─ summary`, as the TUI's panel summaries. */
151 summary?: string;
152 /** Rows inside the frame; each is fitted to the inner width. A `key` names its metric. */
153 rows: { line: Line; key?: string; press?: Press }[];
154 /** The whole frame's width in columns, borders included. */
155 width: number;
156 /** Accent-coloured border, as the TUI draws the focused panel. */
157 focused?: boolean;
158};
159
160/** A frame row drawn as one plain Button (its text, dim) between the borders, when the frame has a Button to draw with. */
161export type Press = { key: string; onPress: () => void };
162
163/** The elements a frame draws with: Box and Text, and Button for the rows that press. */
164export type FrameElements = ViewElements & Partial<Pick<ElementTable<'terminal'>, 'Button'>>;
165
166/** Columns a frame leaves for its rows: the two borders and one space each side. */
167export function innerWidth(frameWidth: number): number {
168 return Math.max(0, frameWidth - 4);
169}
170
171/**
172 * A framed panel like the TUI's: `╭1 Title ─ summary ────╮`, the rows between
173 * `│ ` and ` │`, `╰────╯`. Every line is exactly `width` columns.
174 */
175export function frame(f: Frame, el: FrameElements): RenderElement {
176 const { Box, Button } = el;
177 const w = Math.max(4, f.width);
178 const border: Omit<Seg, 'text'> = f.focused ? { color: ACCENT } : { dim: true };
179 const inner = innerWidth(w);
180 // The title run is trimmed before the fill so the corner always lands.
181 const head: Line = [seg('╭', border)];
182 if (f.hotkey !== undefined) head.push(seg(f.hotkey, { color: ACCENT, bold: true }), seg(' ', border));
183 head.push(seg(f.title, { bold: true }));
184 if (f.summary !== undefined && f.summary !== '') head.push(seg(` ${H} `, border), seg(f.summary));
185 head.push(seg(' ', border));
186 const used = lineWidth(head);
187 const top: Line =
188 used + 1 <= w
189 ? [...head, seg(H.repeat(w - used - 1), border), seg('╮', border)]
190 : [...fit(head, w - 1), seg('╮', border)];
191 const bottom: Line = [seg('╰', border), seg(H.repeat(w - 2), border), seg('╯', border)];
192 const rows = f.rows.map((r) => {
193 const cells = fit(r.line, inner);
194 if (r.press === undefined || Button === undefined) return textRow([seg('│ ', border), ...cells, seg(' │', border)], el, r.key);
195 const press = r.press;
196 return (
197 <Box flexDirection="row">
198 {textRow([seg('│ ', border)], el)}
199 <Button key={press.key} label={cells.map((s) => s.text).join('')} plain dimColor onPress={press.onPress} />
200 {textRow([seg(' │', border)], el)}
201 </Box>
202 );
203 });
204 return (
205 <Box flexDirection="column" width={w} flexShrink={0}>
206 {textRow(top, el)}
207 {rows}
208 {textRow(bottom, el)}
209 </Box>
210 );
211}
212
213/** Frames side by side on one row; each keeps its own width. */
214export function sideBySide(frames: RenderElement[], el: ViewElements): RenderElement {
215 const { Box } = el;
216 return <Box flexDirection="row">{frames}</Box>;
217}
218
219/** Widths of the left and right frames that together fill `columns`. */
220export function split(columns: number): [number, number] {
221 const left = Math.floor(columns / 2);
222 return [left, columns - left];
223}
224hooks/views/table.tsx 110 lines1// Shared by the detail views: a table row of fixed-width and flexible cells
2// laid out into one frame row, the dim single line a view draws when it has
3// nothing to show (or lacks the binary), the framed panel every detail view
4// is, and the row cap they keep to. A cell that shows a metric carries its
5// id as the row's `key` (docs/metrics.md).
6import type { RenderElement } from 'claude-code';
7import { THEME, clip, frame, innerWidth, pad, width, type FrameElements, type Line, type Press, type Seg } from './frame';
8import type { Color } from './overview';
9
10/** The most rows a view's frame holds: longer lists are cut, the pane scrolls the rest. */
11export const MAX_ROWS = 120;
12
13export type Cell = {
14 text: string;
15 /** Columns the cell takes; the one cell without it takes the rest of the row. */
16 width?: number;
17 /** Right-aligned inside its width (numbers); left by default. */
18 right?: boolean;
19 /** Keeps the end of an overlong text (a path) rather than its start. */
20 tail?: boolean;
21 color?: Color;
22 dim?: boolean;
23 bold?: boolean;
24 /** The metric id the cell shows, as docs/metrics.md names it. */
25 key?: string;
26};
27
28/** One row of a framed panel: its segments, the metric id it shows, and its press when it is a Button. */
29export type FrameRow = { line: Line; key?: string; press?: Press };
30
31/** `text` kept from its end when it overflows `cells` (a path), else cut at the end. */
32function clipTail(text: string, cells: number): string {
33 const chars = [...text];
34 if (chars.length <= cells) return text;
35 if (cells <= 1) return clip(text, cells);
36 return '…' + chars.slice(chars.length - (cells - 1)).join('');
37}
38
39function cellSeg(cell: Cell, cells: number): Seg {
40 const text = cell.tail ? clipTail(cell.text, cells) : clip(cell.text, cells);
41 const padded = pad(text, cells, cell.right);
42 const style: Omit<Seg, 'text'> = {};
43 if (cell.color !== undefined) style.color = THEME[cell.color];
44 if (cell.dim) style.dim = true;
45 if (cell.bold) style.bold = true;
46 if (cell.key !== undefined) style.key = cell.key;
47 return { text: padded, ...style };
48}
49
50/**
51 * One row `inner` columns wide: fixed cells keep their width, the flexible
52 * one takes what is left (at least one column), one space between cells.
53 * The row's key is the first keyed cell's metric id unless `key` is given.
54 */
55export function row(cells: Cell[], inner: number, key?: string): FrameRow {
56 const gaps = Math.max(0, cells.length - 1);
57 const fixed = cells.reduce((n, c) => n + (c.width ?? 0), 0);
58 const flexible = Math.max(1, inner - gaps - fixed);
59 const line: Line = [];
60 cells.forEach((cell, i) => {
61 if (i > 0) line.push({ text: ' ' });
62 line.push(cellSeg(cell, cell.width ?? flexible));
63 });
64 return { line, key: key ?? cells.find((c) => c.key !== undefined)?.key };
65}
66
67/** A single line of text, dim unless coloured. */
68export function line(text: string, opts: { key?: string; color?: Color; bold?: boolean } = {}): FrameRow {
69 const style: Omit<Seg, 'text'> = opts.color === undefined ? { dim: true } : { color: THEME[opts.color] };
70 if (opts.bold) style.bold = true;
71 return { line: [{ text, ...style }], key: opts.key };
72}
73
74/** Greedy word wrap into lines of at most `width` columns, for a long text drawn as one truncating Text per line. */
75export function wrapWords(text: string, width: number): string[] {
76 const w = Math.max(1, width);
77 const lines: string[] = [];
78 let cur = '';
79 for (const word of text.split(/\s+/).filter((x) => x !== '')) {
80 if (cur === '') cur = word;
81 else if (cur.length + 1 + word.length <= w) cur += ` ${word}`;
82 else {
83 lines.push(cur);
84 cur = word;
85 }
86 }
87 if (cur !== '') lines.push(cur);
88 return lines;
89}
90
91export type Panel = {
92 /** The TUI's id for this panel (5 Tools … 9 Advisor), drawn in the frame as the TUI and the guide number it; none for the coach's frames. */
93 hotkey?: string;
94 title: string;
95 summary?: string;
96};
97
98/** A detail view: one framed panel `columns` wide holding at most MAX_ROWS rows. */
99export function panel(p: Panel, rows: FrameRow[], columns: number, el: FrameElements): RenderElement {
100 return frame({ hotkey: p.hotkey, title: p.title, summary: p.summary, width: columns, rows: rows.slice(0, MAX_ROWS) }, el);
101}
102
103/** Columns a detail view's rows may use inside its frame. */
104export function bodyWidth(columns: number): number {
105 return innerWidth(columns);
106}
107
108/** Re-exported for the views that size a cell by its text. */
109export { width };
110hooks/views/advisor.tsx 120 lines1// The Advisor view: `cctop query advice` (schema 2) as it comes — the
2// coach's slot occupant first (`▸ NOW A47 headline … saving`), then the
3// ranked queue one row per item, the occupant expanded beneath its row with
4// evidence, action, the exact text to fill, saving and the rule's
5// explanation, each wrapped by word into one truncating Text per line.
6// Snoozed rules and the engine's session mode close the frame.
7import type { RenderElement } from 'claude-code';
8import type { Model } from '../model';
9import { DASH, at, stringAt } from './format';
10import { NEEDS_BINARY, type Color, type ViewElements } from './overview';
11import { bodyWidth, line, panel, row, wrapWords, type Cell, type FrameRow } from './table';
12
13const W = { rank: 3, cls: 5, rule: 4, saving: 12, label: 10 };
14const EMPTY = 'no recommendation right now — the session looks efficient';
15
16export type Advice = {
17 rule: string;
18 cls: string;
19 headline: string;
20 saving: string;
21 evidence: string;
22 action: string;
23 actionText: string;
24 actionKind: string;
25 retiresOn: string;
26 explain: string;
27};
28
29export function adviceOf(item: unknown): Advice {
30 return {
31 rule: stringAt(item, 'rule') ?? '',
32 cls: stringAt(item, 'class') ?? '',
33 headline: stringAt(item, 'headline') ?? DASH,
34 saving: stringAt(item, 'saving') ?? '',
35 evidence: stringAt(item, 'evidence') ?? '',
36 action: stringAt(item, 'action') ?? '',
37 actionText: stringAt(item, 'action_text') ?? '',
38 actionKind: stringAt(item, 'action_kind') ?? '',
39 retiresOn: stringAt(item, 'retires_on') ?? '',
40 explain: stringAt(item, 'explain') ?? '',
41 };
42}
43
44/** The advice payload as the pane reads it: schema 2's object, or the pre-2 bare array. */
45export type AdvicePayload = { items: Advice[]; primary: Advice | null; mode: string | null; snoozed: string[] };
46
47export function advicePayload(query: unknown): AdvicePayload {
48 if (Array.isArray(query)) return { items: query.map(adviceOf), primary: null, mode: null, snoozed: [] };
49 const items = at(query, 'items');
50 const primary = at(query, 'primary');
51 const snoozed = at(query, 'snoozed');
52 return {
53 items: Array.isArray(items) ? items.map(adviceOf) : [],
54 primary: primary !== null && typeof primary === 'object' ? adviceOf(primary) : null,
55 mode: stringAt(query, 'session_mode'),
56 snoozed: Array.isArray(snoozed) ? snoozed.map((s) => stringAt(s, 'rule') ?? '').filter((r) => r !== '') : [],
57 };
58}
59
60/** The class colour: NOW red, NEXT yellow, LATER plain. */
61export function classColor(cls: string): Color | undefined {
62 return cls === 'NOW' ? 'red' : cls === 'NEXT' ? 'yellow' : undefined;
63}
64
65function itemCells(a: Advice, rank: number, slot: boolean): Cell[] {
66 return [
67 { text: rank === 1 && slot ? '▸' : `${rank}.`, width: W.rank, right: true, color: rank === 1 && slot ? 'yellow' : undefined, dim: !(rank === 1 && slot) },
68 { text: a.cls, width: W.cls, color: classColor(a.cls), dim: a.cls === 'LATER' },
69 { text: a.rule, width: W.rule, dim: true },
70 { text: a.headline, bold: rank === 1 },
71 { text: a.saving, width: W.saving, right: true, key: 'advice_saving' },
72 ];
73}
74
75// A labelled paragraph: the label on its first line, the text wrapped to
76// the columns left of it.
77function detail(label: string, text: string, inner: number, color?: Color): FrameRow[] {
78 const width = inner - W.rank - 1 - W.label - 1;
79 return wrapWords(text, width).map((l, i) =>
80 row(
81 [
82 { text: '', width: W.rank },
83 { text: i === 0 ? label : '', width: W.label, dim: true },
84 { text: l, color },
85 ],
86 inner,
87 ),
88 );
89}
90
91export function renderAdvisor(model: Model, el: ViewElements, columns: number): RenderElement {
92 const { items, primary, mode, snoozed } = advicePayload(model.query.advice);
93 const snoozedTail = snoozed.length > 0 ? ` · ${snoozed.length} snoozed` : '';
94 const p = {
95 hotkey: '9',
96 title: 'Advisor',
97 summary: model.binary === 'missing' ? undefined : items.length === 0 ? (snoozedTail === '' ? undefined : snoozedTail.slice(3)) : `1 of ${items.length}${snoozedTail}`,
98 };
99 if (model.binary === 'missing') return panel(p, [line(NEEDS_BINARY, { key: 'advice_saving' })], columns, el);
100 if (items.length === 0) return panel(p, [line(EMPTY, { key: 'advice_saving' })], columns, el);
101 const inner = bodyWidth(columns);
102 const rows: FrameRow[] = [];
103 items.forEach((a, i) => {
104 rows.push(row(itemCells(a, i + 1, primary !== null), inner, 'advice_saving'));
105 if (i === 0) {
106 rows.push(...detail('evidence', a.evidence, inner));
107 rows.push(...detail('action', a.action, inner, 'cyan'));
108 if (a.actionText !== '') rows.push(...detail(a.actionKind || 'text', a.actionText, inner, 'cyan'));
109 rows.push(...detail('saving', a.saving || DASH, inner));
110 if (a.retiresOn !== '') rows.push(...detail('retires', `on ${a.retiresOn}`, inner));
111 rows.push(...detail('why', a.explain, inner));
112 }
113 });
114 if (snoozed.length > 0 || mode !== null) {
115 const parts = [...(snoozed.length > 0 ? [`snoozed: ${snoozed.join(', ')}`] : []), ...(mode !== null && mode !== 'interactive' ? [`session mode: ${mode}`] : [])];
116 if (parts.length > 0) rows.push(row([{ text: '', width: W.rank }, { text: parts.join(' · '), dim: true }], inner));
117 }
118 return panel(p, rows, columns, el);
119}
120hooks/views/agents.tsx 258 lines1// The Agents & MCP view: subagents as the TUI's agents view lists them
2// (state glyph, type, model family, elapsed, tokens, priced cost, what came
3// back, waste with its reason), the team the session leads as a second
4// group (glyph, name, model, elapsed, context, tokens, its own cost with
5// its mark, human/machine turns, the status word — team PRD §4.3), MCP
6// servers (name, RSS, calls, restarts) and background tasks, one row each,
7// from `cctop query agents`. A workflow run is one row — the query's `row`,
8// verbatim, after the agents outside any run, as the TUI lists it — a Button
9// when the pane has one to draw with; pressed, the view is that run's
10// `detail` (or `detail_narrow` below 116 body columns), verbatim, under a
11// `back` Button (workflows PRD §4.4).
12import type { ElementTable, RenderElement } from 'claude-code';
13import type { Model } from '../model';
14import { DASH, at, formatBytes, formatDuration, formatUsd, isMissing, measured, stringAt, tokensOf } from './format';
15import { NEEDS_BINARY, type Color, type ViewElements } from './overview';
16import { THEME, seg, type Line } from './frame';
17import { bodyWidth, line, panel, row, type Cell, type FrameRow } from './table';
18
19const W = { glyph: 10, model: 6, prefix: 3, kind: 6, elapsed: 6, tokens: 6, cost: 5, ret: 5, rss: 7, calls: 9, restarts: 4, status: 16, ctx: 5, teamCost: 6, turns: 5 };
20
21/** A teammate's liveness glyph, as the TUI's team group: `●` working, `○` ended, `—` unknown. */
22const TEAM_GLYPH: Record<string, [string, Color | undefined]> = {
23 active: ['●', 'cyan'],
24 recent: ['●', 'cyan'],
25 ended: ['○', undefined],
26 gone: ['○', undefined],
27 missing: ['—', 'yellow'],
28};
29
30const STATE_GLYPH: Record<string, [string, Color]> = {
31 running: ['◐', 'cyan'],
32 done: ['✓', 'green'],
33 failed: ['✗', 'red'],
34};
35
36/** `claude-opus-5` → `opus`: the family, as the TUI's agents view. */
37function family(model: string | null): string {
38 return (model ?? '').replace(/^claude-/, '').split('-')[0] ?? '';
39}
40
41/** Dollars without the sign in five cells, as the TUI's agents view (`0.19`, `12.4`, `118`). */
42function cents(usd: number): string {
43 if (usd >= 100) return usd.toFixed(0);
44 if (usd >= 10) return usd.toFixed(1);
45 return usd.toFixed(2);
46}
47
48/** `idle 55m` / `idle 2h10` from the waste's age, else the reason word. */
49function wasteText(a: unknown): string {
50 const waste = at(a, 'waste');
51 if (waste === null || waste === undefined) return '0.00';
52 const usd = measured(waste, 'usd');
53 const reason = stringAt(waste, 'reason') ?? '';
54 const idle = at(waste, 'idle_ms');
55 // `reason` is the query's enum value (`failed`, `killed`, `no_ret`, `idle`); the TUI prints it with a space.
56 const label = reason === 'idle' && typeof idle === 'number' ? `idle ${shortDuration(idle)}` : reason.replace('_', ' ');
57 const amount = usd === null || stringAt(waste, 'usd', 'source') === 'unpriced' ? DASH : cents(usd.value);
58 return `${amount} ${label}`;
59}
60
61/** Minutes-first, as the TUI's coach::short_duration: `41m`, `2h10`, `4:12` under five minutes. */
62function shortDuration(ms: number): string {
63 const s = Math.floor(Math.max(0, ms) / 1000);
64 if (s < 300) return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
65 if (s < 3600) return `${Math.floor(s / 60)}m`;
66 return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}`;
67}
68
69/** Below this many body columns the model and `ret` columns make way for the waste. */
70const NARROW = 58;
71
72function agentCells(a: unknown, inner: number): Cell[] {
73 const state = stringAt(a, 'state') ?? '';
74 const [glyph, color] = STATE_GLYPH[state] ?? ['·', undefined];
75 const elapsed = measured(a, 'elapsed');
76 const cost = measured(a, 'cost');
77 const unpriced = stringAt(a, 'cost', 'source') === 'unpriced';
78 const ret = measured(a, 'returned_tokens');
79 const wide = inner >= NARROW;
80 const cells: Cell[] = [{ text: `${glyph} ${stringAt(a, 'type') ?? DASH}`, width: W.glyph, color, key: 'agent_state' }];
81 if (wide) cells.push({ text: family(stringAt(a, 'model')), width: W.model, dim: true });
82 cells.push(
83 { text: elapsed === null ? DASH : formatDuration(elapsed.value), width: W.elapsed, right: true },
84 { text: tokensOf(measured(a, 'tokens')), width: W.tokens, right: true, key: 'agent_tokens' },
85 { text: cost === null || unpriced ? DASH : cents(cost.value), width: W.cost, right: true, key: 'agent_cost' },
86 );
87 if (wide) cells.push({ text: ret === null ? (state === 'running' ? '...' : DASH) : tokensOf(ret).replace(/^≈/, ''), width: W.ret, right: true, key: 'agent_returned' });
88 cells.push({ text: wasteText(a), key: 'agent_waste', color: at(a, 'waste') ? 'yellow' : undefined });
89 return cells;
90}
91
92function mcpCells(m: unknown): Cell[] {
93 const rss = measured(m, 'rss');
94 const calls = measured(m, 'calls');
95 const restarts = at(m, 'restarts');
96 return [
97 { text: 'mcp', width: W.prefix, dim: true },
98 { text: stringAt(m, 'name') ?? DASH },
99 { text: rss === null ? DASH : formatBytes(rss.value), width: W.rss, right: true, key: 'mcp_rss' },
100 { text: calls === null ? DASH : `${calls.value} calls`, width: W.calls, right: true, key: 'mcp_calls' },
101 { text: typeof restarts === 'number' && restarts > 0 ? `↻${restarts}` : '', width: W.restarts, color: 'yellow' },
102 ];
103}
104
105function taskCells(t: unknown, now: number): Cell[] {
106 const started = at(t, 'started_at_ms');
107 return [
108 { text: 'bg', width: W.prefix, dim: true },
109 { text: stringAt(t, 'kind') ?? DASH, width: W.kind },
110 { text: stringAt(t, 'description') ?? '', dim: true },
111 { text: typeof started === 'number' ? formatDuration(now - started) : DASH, width: W.elapsed, right: true },
112 { text: [stringAt(t, 'id')?.slice(0, 8), stringAt(t, 'status')].filter((x) => x !== undefined && x !== null).join(' '), width: W.status, dim: true },
113 ];
114}
115
116/** A teammate's row: the subagent columns plus context and turns; a member with no transcript has only its word. */
117function teammateCells(t: unknown, inner: number): Cell[] {
118 const state = stringAt(t, 'state') ?? '';
119 const [glyph, color] = TEAM_GLYPH[state] ?? ['·', undefined];
120 const wide = inner >= NARROW;
121 const cells: Cell[] = [{ text: `${glyph} ${stringAt(t, 'name') ?? DASH}`, width: W.glyph, color, key: 'teammate_state' }];
122 if (wide) cells.push({ text: family(stringAt(t, 'model')), width: W.model, dim: true });
123 if (state === 'missing') {
124 cells.push({ text: stringAt(t, 'status') ?? 'no transcript', color: 'yellow' });
125 return cells;
126 }
127 const elapsed = measured(t, 'elapsed');
128 const cost = measured(t, 'cost');
129 const approx = at(t, 'cost', 'approx') === true;
130 const human = measured(t, 'turns', 'human');
131 const machine = measured(t, 'turns', 'machine');
132 cells.push({ text: elapsed === null ? DASH : formatDuration(elapsed.value), width: W.elapsed, right: true });
133 if (wide) cells.push({ text: tokensOf(measured(t, 'context')), width: W.ctx, right: true, key: 'teammate_context' });
134 cells.push(
135 { text: tokensOf(measured(t, 'tokens')), width: W.tokens, right: true, key: 'teammate_tokens' },
136 { text: cost === null ? DASH : `${approx ? '≈' : ''}${cents(cost.value)}`, width: W.teamCost, right: true, key: 'teammate_cost' },
137 { text: human === null || machine === null ? DASH : `${human.value}/${machine.value}`, width: W.turns, right: true, key: 'teammate_turns' },
138 { text: stringAt(t, 'status') ?? '', key: 'teammate_waste', color: at(t, 'waste') ? 'yellow' : undefined },
139 );
140 return cells;
141}
142
143/** `team 3 · 1 active · ≈$0.48 · 2 of 3 read`, as the TUI's team group row. */
144function teamGroupText(team: unknown): string {
145 const parts = [`team ${at(team, 'members') ?? DASH} · ${at(team, 'active') ?? 0} active`];
146 const cost = measured(team, 'cost');
147 parts.push(cost === null ? DASH : `${cost.approx ? '≈' : ''}${formatUsd(cost.value)}`);
148 const waste = measured(team, 'waste');
149 if (waste !== null && waste.value > 0) parts.push(`wasted ≈${formatUsd(waste.value)}`);
150 const read = at(team, 'read');
151 const members = at(team, 'members');
152 if (typeof read === 'number' && typeof members === 'number' && read < members) parts.push(`${read} of ${members} read`);
153 return parts.join(' · ');
154}
155
156function listAt(obj: unknown, key: string): unknown[] {
157 const v = at(obj, key);
158 return Array.isArray(v) ? v : [];
159}
160
161/** `0/1 agents` as the TUI's panel summary: running over listed; a team alone as `1/3 team`. */
162function agentsSummary(data: unknown): string | undefined {
163 const agents = listAt(data, 'agents');
164 const parts: string[] = [];
165 if (agents.length > 0) {
166 const running = agents.filter((a) => stringAt(a, 'state') === 'running').length;
167 parts.push(`${running}/${agents.length} agents`);
168 }
169 const team = at(data, 'team');
170 if (team !== null && team !== undefined) parts.push(`${at(team, 'active') ?? 0}/${at(team, 'members') ?? 0} team`);
171 return parts.length === 0 ? undefined : parts.join(' · ');
172}
173
174/** Body columns from which a run's detail is the wide layout (the query's `detail`), as the TUI's DETAIL_WIDE. */
175export const DETAIL_WIDE = 116;
176
177/** The elements the agents view presses with, and what a press does; built in pane.tsx over `$`. */
178export type AgentsElements = Pick<ElementTable<'terminal'>, 'Box' | 'Text' | 'Button'>;
179export type AgentsActions = { el: AgentsElements; open(run: string | null): void };
180
181/** The run's row as the TUI's group line: dim, its `✗N` red when agents failed. */
182function runLine(w: unknown): Line {
183 const text = stringAt(w, 'row') ?? '';
184 const failed = at(w, 'failed');
185 const mark = ` ✗${typeof failed === 'number' ? failed : 0}`;
186 const i = typeof failed === 'number' && failed > 0 ? text.indexOf(mark) : -1;
187 if (i < 0) return [seg(text, { dim: true })];
188 const end = i + mark.length;
189 return [seg(text.slice(0, i + 1), { dim: true }), seg(text.slice(i + 1, end), { color: THEME.red }), seg(text.slice(end), { dim: true })];
190}
191
192/** A run's detail rows, styled as the TUI's: the fixes after ` ───` warn, their pointer lines and the column heads (row `heads`) dim. */
193function detailRows(rows: string[], heads: number): FrameRow[] {
194 let fixes = false;
195 return rows.map((text, i) => {
196 if (text === ' ───') fixes = true;
197 const style =
198 fixes && text !== ' ───' && (text.startsWith(' →') || text.startsWith(' '))
199 ? { dim: true }
200 : fixes
201 ? { color: THEME.yellow }
202 : i === heads
203 ? { dim: true }
204 : {};
205 return { line: [seg(text, style)], key: i === 0 ? 'workflow_failed' : undefined };
206 });
207}
208
209export function renderAgents(model: Model, el: ViewElements, columns: number, now: number, actions?: AgentsActions): RenderElement {
210 const data = model.query.agents;
211 const p = { hotkey: '6', title: 'Agents & MCP', summary: model.binary === 'missing' ? undefined : agentsSummary(data) };
212 if (model.binary === 'missing') return panel(p, [line(NEEDS_BINARY, { key: 'agent_state' })], columns, el);
213 const inner = bodyWidth(columns);
214 const fel = actions?.el ?? el;
215 // The runs the query drew a row for; an older binary's entry without one
216 // leaves its agents listed one by one and draws no run row (ruling R17).
217 const workflows = listAt(data, 'workflows').filter((w) => (stringAt(w, 'row') ?? '') !== '');
218 const runs = new Set(workflows.map((w) => stringAt(w, 'run') ?? ''));
219 const agents = listAt(data, 'agents');
220 const open = model.openRun === null ? undefined : workflows.find((w) => stringAt(w, 'run') === model.openRun);
221 if (open !== undefined) {
222 const rows: FrameRow[] = [];
223 if (actions !== undefined) rows.push({ line: [seg('back')], press: { key: 'agents-back', onPress: () => actions.open(null) } });
224 const wide = inner >= DETAIL_WIDE;
225 const detail = at(open, wide ? 'detail' : 'detail_narrow');
226 // The narrow header is two rows (the run, then its money), so its column heads are row 2.
227 rows.push(...detailRows(Array.isArray(detail) ? detail.filter((t): t is string => typeof t === 'string') : [], wide ? 1 : 2));
228 // The run's agents under its phase table, one row each, as the TUI's detail (spec §4.4).
229 for (const a of agents) if (at(a, 'workflow') === model.openRun) rows.push(row(agentCells(a, inner), inner, 'agent_state'));
230 return panel(p, rows, columns, fel);
231 }
232 const rows: FrameRow[] = [];
233 // A run's agents are its row, not a row each (the TUI's list).
234 for (const a of agents) {
235 const run = at(a, 'workflow');
236 if (typeof run !== 'string' || !runs.has(run)) rows.push(row(agentCells(a, inner), inner, 'agent_state'));
237 }
238 for (const w of workflows) {
239 const run = stringAt(w, 'run') ?? '';
240 const r: FrameRow = { line: runLine(w), key: 'workflow_failed' };
241 if (actions !== undefined) r.press = { key: run, onPress: () => actions.open(run) };
242 rows.push(r);
243 }
244 // The team under the subagents: the group row, then a row per member
245 // (the query's `teammates[]` are the rows only when `team` is present).
246 const team = at(data, 'team');
247 if (team !== null && team !== undefined) {
248 const missing = listAt(team, 'missing').length > 0;
249 rows.push(line(teamGroupText(team), { key: 'team_cost', ...(missing ? { color: 'yellow' as Color } : {}) }));
250 for (const t of listAt(data, 'teammates')) rows.push(row(teammateCells(t, inner), inner, 'teammate_state'));
251 }
252 if (isMissing(data, 'mcp')) rows.push(line(`mcp: ${stringAt(data, 'mcp', 'hint') ?? DASH}`, { key: 'mcp_rss' }));
253 for (const m of listAt(data, 'mcp')) rows.push(row(mcpCells(m), inner, 'mcp_rss'));
254 for (const t of listAt(data, 'tasks')) rows.push(row(taskCells(t, now), inner));
255 if (rows.length === 0) rows.push(line('no subagents, MCP servers or background tasks'));
256 return panel(p, rows, columns, fel);
257}
258hooks/views/events.tsx 50 lines1// The Events view: the last 50 of `cctop query events` as `HH:MM:SS kind
2// text`, oldest first so the newest is at the bottom, the kind coloured as
3// the TUI does (tool ok, hook/agent accent, api crit, the rest warn).
4import type { RenderElement } from 'claude-code';
5import type { Model } from '../model';
6import { at, stringAt } from './format';
7import { NEEDS_BINARY, type Color, type ViewElements } from './overview';
8import { bodyWidth, line, panel, row, type Cell, type FrameRow } from './table';
9
10export const EVENT_ROWS = 50;
11const W = { clock: 8, kind: 7 };
12
13const KIND_COLOR: Record<string, Color> = {
14 tool: 'green',
15 hook: 'cyan',
16 agent: 'cyan',
17 perm: 'yellow',
18 note: 'yellow',
19 compact: 'yellow',
20 away: 'yellow',
21 api: 'red',
22};
23
24/** The UTC wall clock of a millisecond timestamp, as the TUI's events panel shows it. */
25export function clock(atMs: number): string {
26 const s = ((Math.floor(atMs / 1000) % 86_400) + 86_400) % 86_400;
27 const two = (n: number) => String(n).padStart(2, '0');
28 return `${two(Math.floor(s / 3600))}:${two(Math.floor((s % 3600) / 60))}:${two(s % 60)}`;
29}
30
31function eventCells(e: unknown): Cell[] {
32 const atMs = at(e, 'at_ms');
33 const kind = stringAt(e, 'kind') ?? '';
34 return [
35 { text: typeof atMs === 'number' ? clock(atMs) : '', width: W.clock, dim: true },
36 { text: kind, width: W.kind, color: KIND_COLOR[kind] },
37 { text: stringAt(e, 'text') ?? '' },
38 ];
39}
40
41export function renderEvents(model: Model, el: ViewElements, columns: number): RenderElement {
42 const all = Array.isArray(model.query.events) ? model.query.events : [];
43 const p = { hotkey: '8', title: 'Events', summary: model.binary === 'missing' ? undefined : String(all.length) };
44 if (model.binary === 'missing') return panel(p, [line(NEEDS_BINARY)], columns, el);
45 const inner = bodyWidth(columns);
46 const events = all.slice(-EVENT_ROWS);
47 const rows: FrameRow[] = events.length === 0 ? [line('no events yet')] : events.map((e) => row(eventCells(e), inner));
48 return panel(p, rows, columns, el);
49}
50