SLOPSHOPPER

cctop

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

newpaneguardcommandtoaststatus
★ 2v0.10.0MITupdated 2026-10-01tomstagl/cctop/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cctop
│ ┃ cctop ✕ › fix the failing auth test and add an audit log call │ ┃ cctop Coach Overview Tools Agents Files │ ┃ Events Advisor bin shim hooks 2.1.284 ● cctop: cctop: plugin.json unreadable: Error: ENOENT: no such file / │ ┃ cctop claude-opus-5-5 · turn 1 · — ○ IDL… ● cctop: cctop: plugin ? (hooks contract 2.1.284) loaded; self-check │ ┃ context 97k / 200k (49 %) · 5h 31 % … ⏺ Read(src/auth.ts) │ ┃ waiting for cctop query dashboard… ⎿ Read 6 lines │ ┃ summary: unsupported by this cctop version ⏺ Update(src/auth.ts) │ ┃ dashboard: unsupported by this cctop version ⎿ Added 2 lines, removed 1 line │ ┃ coach: unsupported by this cctop version ⏺ Bash(bun test) │ ┃ tools: unsupported by this cctop version ⎿ 3 pass, 1 fail │ ┃ files: unsupported by this cctop version │ ┃ agents: unsupported by this cctop version ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ advice: unsupported by this cctop version │ ┃ events: unsupported by this cctop version ✻ Worked for 42s · done 4:20 PM │ │ › /cctop-pane │ ⎿ cctop: cctop pane docked beside the transcript (56 columns): cli │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · cctop
cctop Coach Overview Tools Agents Files Events Advisor bin shim hooks 2.1.284 cctop claude-opus-5-5 · turn 1 · — ○ IDLE bin shim hook… context 97k / 200k (49 %) · 5h 31 % waiting for cctop query dashboard… summary: unsupported by this cctop version dashboard: unsupported by this cctop version coach: unsupported by this cctop version tools: unsupported by this cctop version files: unsupported by this cctop version agents: unsupported by this cctop version advice: unsupported by this cctop version events: unsupported by this cctop version
README

cctop plugin for Claude Code

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.

Install

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.

Claude Code versions

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:

plugintested withruns on
0.2.0 – 0.4.02.1.269 / 2.1.2702.1.269 – 2.1.270 only ($.clock.now() became a Promise in 2.1.271, issue #3)
0.4.1 – 0.7.02.1.272 / 2.1.2732.1.269 and later, as far as CI has seen (the module awaits every host call)
0.8.02.1.2742.1.269 – 2.1.274 ($.agent.register, position: "absolute" on Box, a Markdown element — additive, nothing the pane calls moved)
0.9.02.1.2782.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.02.1.2842.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.

The pane

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:

ViewTUIWhat it shows
CoachcThe 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
Overviewthe dashboardConsole: 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
Tools5Calls, errors, p50/p95, tokens pushed into context, per tool
Agents6Subagents, MCP servers, background tasks
Files7Touched files, edits, re-reads
Events8Tool / hook / permission / compaction / coach / cost stream
Advisor9The 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.

Switching views

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:

replymeaning
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 and the clock

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.

When the contract moves

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.

The /diff panel and the pane share one dock

Claude 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 status

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

Enabling function hooks

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.

Fallback

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.

Verification status

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:

  1. /cctop-pane listed in the slash menu with its description
  2. pane docks at 144 columns in /tui fullscreen
  3. pane docks at 110 columns in /tui fullscreen
  4. pane draws inline in /tui default
  5. /cctop-pane toggles open/closed
  6. /cctop-pane close closes the pane
  7. /cctop-pane tools opens straight to the Tools view
  8. /cctop:cctop (the skill) still resolves, and answers the one-liner when the pane is already open
  9. the view bar sits in the pane, nothing above the prompt; a click and ctrl+x tab + tab + enter switch views
  10. ctrl+x arrows resize the pane and persist pluginPanes.dockColumns
  11. the Advisor view's top row matches cctop query advice --session <id>
  12. Context % updates within 1 s of a response
  13. /cctop-pane opens in under 500 ms
  14. /reload-plugins reopens the pane on its previous view
  15. Claude Code's idle CPU with the pane open stays under 2 %
  16. turn latency with vs. without --plugin-dir differs by under 1 %
  17. with function hooks off, /cctop still splits in tmux and opens a new window in Apple Terminal
  18. the marker file ~/.cctop/pane/<id>.json toggles open on open/close
  19. cctop split short-circuits when the pane is already open
  20. /cctop falls back to cctop split when there is no fresh open marker
  21. /diff over a docked pane hides it and pins the status line; /diff again restores the pane
  22. an open while the diff panel shows answers "open but not shown"
  23. cctop pane status from inside a session reports every line ✓ once the pane is docked, and names the missing prerequisites otherwise
  24. /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 log
  25. on Claude Code 2.1.272, a session with the pane never opened logs no cctop: failure, its marker says loaded: true, cctop pane status shows the module ✓, and /cctop fills every light within one poll

See the checklist for the exact setup, keys and expected observation for each.

What it costs

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.

Source 14 files
hooks/pane.tsx 854 lines
1// 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};
854
hooks/model.ts 422 lines
1// 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}
422
hooks/poller.ts 252 lines
1// 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}
252
hooks/views/coach.tsx 296 lines
1// 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}
296
hooks/views/index.ts 43 lines
1// 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}
43
hooks/views/overview.tsx 485 lines
1// 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}
485
hooks/views/format.ts 91 lines
1// 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}
91
hooks/views/frame.tsx 224 lines
1// 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}
224
hooks/views/table.tsx 110 lines
1// 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 };
110
hooks/views/advisor.tsx 120 lines
1// 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}
120
hooks/views/agents.tsx 258 lines
1// 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}
258
hooks/views/events.tsx 50 lines
1// 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