Панель в стиле btop: контекст, лимиты, стоимость, тулы, сабагенты

Моды для Claude Code от sirrorist-mods. Репозиторий - маркетплейс: один раз подключил, дальше ставишь любой мод одной командой.
<!-- TODO: GIF с cctop -->
| Мод | Что делает |
|---|---|
| cctop | панель в стиле btop: контекст, лимиты, стоимость, тулы, сабагенты и аналитика сессии |
Нужен Claude Code 2.1.287 или новее (claude --version).
claude plugin marketplace add sirrorist/lazanus
claude plugin install cctop@lazanus
В сессии: /reload-plugins, затем /cctop.
Чтобы новые версии приходили сами: /plugin -> Marketplaces -> lazanus -> Enable auto-update. Без этого - вручную:
claude plugin marketplace update lazanus
claude plugin update cctop@lazanus
/cctop открывает панель сбоку от переписки. Работает и пока Claude занят.
По умолчанию видны первые пять, остальные включаются в меню (≡ menu или m) или цифрой.
| # | Панель | Что показывает |
|---|---|---|
| 1 | context | заполненность окна и её график по ходам |
| 2 | limits | окна 5h и 7d; метка │ - где был бы ровный расход, ▲/▼ - насколько впереди или позади |
| 3 | tools | вызовы, ошибки, среднее, суммарное и максимальное время по каждому тулу |
| 4 | agents | сабагенты: тип, модель, сколько работают |
| 5 | week | $, токены и ходы по дням за неделю, по всем сессиям машины |
| 6 | cost | $, $ в час, стоимость каждого хода, токены, доля кэша |
| 7 | forecast | таблица по окнам лимитов: расход в час, когда дойдёт до 100%, когда сброс, успеет ли |
| 8 | time | куда ушло время: модель, тулы, ожидание тебя |
| 9 | files | какие файлы Claude читал и правил чаще всего |
| 0 | bash | команды по семействам (git status, docker compose): запуски, ошибки, время |
reset layout в меню возвращает этот набор: первые пять видны, все развёрнуты, тулы по времени. Настройки оповещения сброс не трогает.
| Терминал | Десктоп | |
|---|---|---|
| Свернуть панель | клик по заголовку | ▾ Name |
| Показать или скрыть | 1...9, 0 или ≡ menu | ● Name |
| Сортировка тулов | клик по колонке, s - следующая, r - обратно | кнопки sort |
| Оповещение о контексте | ≡ menu -> context alert | speaks as, past |
Оповещение срабатывает, когда контекст переходит порог (60, 70, 80 или 90%, по умолчанию 70). Способ: всплывашка, строка статуса, оба или выключено.
Раскладка и настройки сохраняются между сессиями. Суммы по дням лежат в ~/.claude/cctop/days.json, одном на все сессии машины, терминальные и десктопные. Мышь в терминале работает в полноэкранном режиме, где панель стоит сбоку.
У десктопа своя вёрстка: карточки, кнопки, SVG-графики. Десктоп запускает свой встроенный Claude Code: моды работают, когда он версии 2.1.287 или новее.
Десктоп берёт мод из копии в ~/.claude/plugins/cache/, а не из папки маркетплейса. При разработке после правки переустанови мод и открой новую сессию в десктопе:
claude plugin uninstall cctop@lazanus --keep-data
claude plugin install cctop@lazanus
Открытые терминальные сессии после переустановки перезапусти (/exit, затем claude -c): /reload-plugins в них мод не поднимет.
.claude-plugin/marketplace.json каталог модов
mods/<мод>/ один мод - один плагин
Новый мод: папка в mods/ и строка в marketplace.json. Поле version в модах не ставится, поэтому каждый коммит - новая версия, и автообновление его подхватывает.
claude plugin marketplace add ~/Dev/lazanus # мод читается прямо из папки
claude plugin install cctop@lazanus
После правки - /reload-plugins. Проверки перед коммитом:
claude plugin validate .
claude plugin validate mods/cctop
claude plugin test mods/cctop
Десктоп запускает свой Claude Code, и он бывает строже терминального. Проверь, что мод поднимается и в нём (версия и хэш - из ~/.config/Claude/claude-code/):
~/.config/Claude/claude-code/<версия>/<хэш>/claude -p "/cctop" --debug-file /tmp/cctop.log
grep -E "failed to load|/cctop listed" /tmp/cctop.loghooks/register.tsx 363 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionContextUsage, SessionCost, SessionRateLimit } from 'claude-code'
3
4import type { AgentRow, AgentState, CommandStat, FileStat, Prefs, Tokens, ToolStat, UsageSnap } from '../types'
5import { drawDesktop } from './desktop'
6import { toolLabel } from './format'
7import type { Action, Snapshot } from './model'
8import type { SessionChange } from './model'
9import { applyAction, commandKey, dayKey, DEFAULT_PREFS, parseAction, parseDayBook, parsePrefs, sumDays, touchDay } from './model'
10
11const PANE = 'cctop'
12const HISTORY = 120
13const MAX_AGENTS = 32
14const PREFS_KEY = 'prefs'
15const MAX_FILES = 200
16const MAX_COMMANDS = 100
17const MAX_SAMPLES = 60
18const FILE_TOOLS = new Set(['Read', 'Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
19
20const ZERO_TOKENS: Tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, turns: 0 }
21const ZERO_TOOL: ToolStat = { calls: 0, errors: 0, denied: 0, totalMs: 0, maxMs: 0 }
22
23const usage = atom({ plugin: 'cctop', key: 'usage' } as const, null)
24const history = atom({ plugin: 'cctop', key: 'history' } as const, [])
25const costs = atom({ plugin: 'cctop', key: 'costs' } as const, [])
26const tokens = atom({ plugin: 'cctop', key: 'tokens' } as const, ZERO_TOKENS)
27const tools = atom({ plugin: 'cctop', key: 'tools' } as const, {})
28const agents = atom({ plugin: 'cctop', key: 'agents' } as const, [])
29const prefs = atom({ plugin: 'cctop', key: 'prefs' } as const, DEFAULT_PREFS)
30const model = atom({ plugin: 'cctop', key: 'model' } as const, '')
31const startedAt = atom({ plugin: 'cctop', key: 'startedAt' } as const, 0)
32const cwd = atom({ plugin: 'cctop', key: 'cwd' } as const, '')
33const files = atom({ plugin: 'cctop', key: 'files' } as const, {})
34const commands = atom({ plugin: 'cctop', key: 'commands' } as const, {})
35const timing = atom({ plugin: 'cctop', key: 'timing' } as const, { turnMs: 0, toolMs: 0, turns: 0 })
36const samples = atom({ plugin: 'cctop', key: 'samples' } as const, {})
37const days = atom({ plugin: 'cctop', key: 'days' } as const, {})
38const isAlerted = atom({ plugin: 'cctop', key: 'isAlerted' } as const, false)
39
40const snapOf = (
41 context: SessionContextUsage,
42 rateLimits: readonly SessionRateLimit[],
43 cost: SessionCost | undefined,
44): UsageSnap => ({
45 tokens: context.tokens,
46 window: context.window,
47 percent: context.percent,
48 rateLimits: rateLimits.map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, resetsAt })),
49 costUsd: cost?.usd,
50})
51
52const agentState = (reason: string): AgentState =>
53 reason === 'aborted' ? 'killed' : reason === 'answer' ? 'completed' : 'failed'
54
55/** Copies the saved layout into the session: at the start, and again after /clear, /resume or /branch. */
56const loadPrefs = async ($: EngineInterface) => {
57 const saved = parsePrefs(await $.store.get(PREFS_KEY))
58 await update($, prefs, () => saved)
59}
60
61/** Applies a layout change and saves it, building on what the store holds now (another session may have changed it). */
62const change = async ($: EngineInterface, action: Action) => {
63 const next: Prefs = applyAction(parsePrefs(await $.store.get(PREFS_KEY)), action)
64 await $.store.set(PREFS_KEY, next)
65 await update($, prefs, () => next)
66 // A new mode or threshold is judged afresh at the next measurement.
67 if (action.kind === 'alert' || action.kind === 'threshold' || action.kind === 'reset') {
68 $.ui.status(undefined)
69 await update($, isAlerted, () => false)
70 await checkAlert($, (await read($, usage))?.percent)
71 }
72}
73
74/**
75 * The day book lives in a file, not in `$.store`: the desktop loads the mod as
76 * `cctop@inline` and the terminal as `cctop@lazanus`, and each id gets a store
77 * of its own. A file under the home directory is one for every session.
78 */
79const bookPath = async ($: EngineInterface) => `${(await $.env.get('HOME')) ?? ''}/.claude/cctop/days.json`
80
81/** A missing or unreadable file is an empty book: the week starts over rather than the session failing. */
82const readBook = async ($: EngineInterface) => {
83 try {
84 return parseDayBook(await $.fs.read(await bookPath($)))
85 } catch {
86 return parseDayBook(undefined)
87 }
88}
89
90/** Updates this session's share of today: read the file, change, write it back at once. */
91const recordDay = async ($: EngineInterface, delta: Omit<SessionChange, 'isStartDay'>) => {
92 const now = await $.clock.now()
93 const today = dayKey(now)
94 const began = await read($, startedAt)
95 const book = touchDay(await readBook($), today, await $.session.id(), {
96 ...delta,
97 // An unknown start counts the session's whole cost: better than counting none of it.
98 isStartDay: began <= 0 || dayKey(began) === today,
99 })
100 await $.fs.write(await bookPath($), JSON.stringify(book))
101 await update($, days, () => sumDays(book))
102}
103
104/**
105 * Speaks once when the context fills past the threshold, in the chosen mode;
106 * a status line entry follows the fill until it drops 5 points below.
107 */
108const checkAlert = async ($: EngineInterface, percent: number | undefined) => {
109 if (percent === undefined) return
110 const { alert, threshold } = await read($, prefs)
111 const wasAlerted = await read($, isAlerted)
112 const rounded = Math.round(percent)
113
114 if (alert !== 'off' && percent >= threshold) {
115 if (!wasAlerted && (alert === 'toast' || alert === 'both')) {
116 $.ui.toast(`cctop: context ${rounded}%, past ${threshold}%`)
117 }
118 if (alert === 'status' || alert === 'both') $.ui.status(`ctx ${rounded}%`)
119 if (!wasAlerted) await update($, isAlerted, () => true)
120 } else if (wasAlerted && percent < threshold - 5) {
121 $.ui.status(undefined)
122 await update($, isAlerted, () => false)
123 }
124}
125
126const startSession = async ($: EngineInterface) => {
127 const now = await $.session.usage()
128 await update($, usage, () => snapOf(now.context, now.rateLimits, now.cost))
129 await update($, startedAt, () => now.startedAt)
130 const current = await $.session.model()
131 await update($, model, () => current ?? '')
132 const folder = await $.session.cwd()
133 await update($, cwd, () => folder)
134 const book = await readBook($)
135 await update($, days, () => sumDays(book))
136}
137
138export const register: Register = on => {
139 on('session.start', async ($, e, next) => {
140 await $.command.register({
141 name: 'cctop',
142 description: 'Open cctop: context, limits, cost, tools and subagents',
143 immediate: true,
144 })
145 await loadPrefs($)
146 await startSession($)
147 // No redraw timer: the terminal's Client ticks its own clock, and a per-second redraw
148 // makes the desktop reload every SVG, which shows as a flicker. Data changes redraw by state.
149
150 return next(e)
151 })
152
153 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
154 await loadPrefs($)
155 await startSession($)
156
157 return next(e)
158 })
159
160 on('command.run', { command: 'cctop' }, async $ => {
161 const opened = await $.ui.open({ id: PANE, title: 'cctop' })
162
163 return opened.isPlaced ? {} : { text: 'cctop: widen the terminal to see it.' }
164 })
165
166 on('session.measure', async ($, e, next) => {
167 await update($, usage, () => snapOf(e.context, e.rateLimits, e.cost))
168
169 try {
170 const at = await $.clock.now()
171 await update($, samples, all => {
172 const nextAll = { ...all }
173 for (const limit of e.rateLimits) {
174 const list = nextAll[limit.kind] ?? []
175 if (list[list.length - 1]?.percent !== limit.percentUsed) {
176 nextAll[limit.kind] = [...list, { at, percent: limit.percentUsed }].slice(-MAX_SAMPLES)
177 }
178 }
179
180 return nextAll
181 })
182 await checkAlert($, e.context.percent)
183 // The day's cost follows the session's running total, so it matches the cost panel.
184 if (e.cost !== undefined) await recordDay($, { usd: e.cost.usd })
185 } catch {
186 // A missed reading only blurs the forecast.
187 }
188
189 return next(e)
190 })
191
192 on('tool.call', async ($, e, next) => {
193 const began = await $.clock.now()
194 const ran = await next(e)
195
196 try {
197 const ms = (await $.clock.now()) - began
198 const name = toolLabel(String(e.tool))
199 const isDenied = ran.deny !== undefined
200 const isError = !isDenied && ran.isError === true
201 await update($, tools, all => {
202 const stat = all[name] ?? ZERO_TOOL
203
204 return {
205 ...all,
206 [name]: {
207 calls: stat.calls + 1,
208 errors: stat.errors + (isError ? 1 : 0),
209 denied: stat.denied + (isDenied ? 1 : 0),
210 totalMs: stat.totalMs + ms,
211 maxMs: Math.max(stat.maxMs, ms),
212 },
213 }
214 })
215
216 const input = e as unknown as Record<string, unknown>
217 if (e.agentId === undefined) await update($, timing, was => ({ ...was, toolMs: was.toolMs + ms }))
218
219 const path = input.file_path ?? input.notebook_path
220 if (FILE_TOOLS.has(String(e.tool)) && typeof path === 'string' && !isDenied) {
221 const isRead = e.tool === 'Read'
222 const at = await $.clock.now()
223 await update($, files, all => {
224 const was: FileStat = all[path] ?? { reads: 0, edits: 0, lastAt: 0 }
225 const grown = { ...all, [path]: { reads: was.reads + (isRead ? 1 : 0), edits: was.edits + (isRead ? 0 : 1), lastAt: at } }
226
227 return Object.keys(grown).length <= MAX_FILES
228 ? grown
229 : Object.fromEntries(Object.entries(grown).sort(([, a], [, b]) => b.lastAt - a.lastAt).slice(0, MAX_FILES))
230 })
231 }
232
233 if (e.tool === 'Bash' && typeof input.command === 'string') {
234 const key = commandKey(input.command)
235 await update($, commands, all => {
236 const was: CommandStat = all[key] ?? { calls: 0, errors: 0, totalMs: 0 }
237 const grown = { ...all, [key]: { calls: was.calls + 1, errors: was.errors + (isError || isDenied ? 1 : 0), totalMs: was.totalMs + ms } }
238
239 return Object.keys(grown).length <= MAX_COMMANDS
240 ? grown
241 : Object.fromEntries(Object.entries(grown).sort(([, a], [, b]) => b.totalMs - a.totalMs).slice(0, MAX_COMMANDS))
242 })
243 }
244 } catch {
245 // The panel misses one call; the call itself already ran and stands.
246 }
247
248 return ran
249 })
250
251 on('agent.spawn', async ($, e, next) => {
252 const spawned = await next(e)
253 const { agentId } = spawned
254
255 if (agentId !== undefined) {
256 try {
257 const row: AgentRow = {
258 id: agentId,
259 type: e.subagentType,
260 description: e.description,
261 model: spawned.model ?? '',
262 status: 'running',
263 startedAt: await $.clock.now(),
264 }
265 await update($, agents, list => [...list.filter(one => one.id !== agentId), row].slice(-MAX_AGENTS))
266 } catch {
267 // Same as above: the spawn stands whatever the panel does.
268 }
269 }
270
271 return spawned
272 })
273
274 on('turn.complete', async ($, e, next) => {
275 const done = await next(e)
276
277 try {
278 const spent = e.usage
279 if (spent !== undefined) {
280 await update($, tokens, total => ({
281 input: total.input + spent.input_tokens,
282 output: total.output + spent.output_tokens,
283 cacheRead: total.cacheRead + spent.cache_read_input_tokens,
284 cacheWrite: total.cacheWrite + spent.cache_creation_input_tokens,
285 turns: total.turns + (e.agentId === undefined ? 1 : 0),
286 }))
287 }
288
289 if (e.agentId !== undefined) {
290 if (spent !== undefined) {
291 await recordDay($, {
292 tokensIn: spent.input_tokens + spent.cache_read_input_tokens + spent.cache_creation_input_tokens,
293 tokensOut: spent.output_tokens,
294 })
295 }
296 const status = agentState(e.reason)
297 await update($, agents, list =>
298 list.map(one => (one.id === e.agentId ? { ...one, status, durationMs: e.durationMs } : one)),
299 )
300 } else {
301 const now = await $.session.usage()
302 const percent = now.context.percent
303 if (percent !== undefined) await update($, history, list => [...list, percent].slice(-HISTORY))
304 const usd = now.cost?.usd
305 if (usd !== undefined) await update($, costs, list => [...list, usd].slice(-HISTORY))
306 if (spent !== undefined) await update($, model, () => spent.model)
307 await update($, timing, was => ({ turnMs: was.turnMs + e.durationMs, toolMs: was.toolMs, turns: was.turns + 1 }))
308
309 await recordDay($, {
310 usd,
311 tokensIn: spent === undefined ? 0 : spent.input_tokens + spent.cache_read_input_tokens + spent.cache_creation_input_tokens,
312 tokensOut: spent?.output_tokens ?? 0,
313 turns: 1,
314 })
315 }
316 } catch {
317 // A missed sample only thins the graphs.
318 }
319
320 return done
321 })
322
323 on('ui.message', { requestId: PANE }, async ($, e, next) => {
324 const action = parseAction(e.data)
325 if (action !== undefined) await change($, action)
326
327 return next(e)
328 })
329
330 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
331 const snapshot: Snapshot = {
332 now: await $.clock.now(),
333 startedAt: await read($, startedAt),
334 model: await read($, model),
335 columns: e.props.bodyColumns,
336 rows: e.props.scroll.bodyRows,
337 usage: await read($, usage),
338 history: await read($, history),
339 costs: await read($, costs),
340 tokens: await read($, tokens),
341 tools: await read($, tools),
342 agents: await read($, agents),
343 prefs: await read($, prefs),
344 cwd: await read($, cwd),
345 files: await read($, files),
346 commands: await read($, commands),
347 timing: await read($, timing),
348 samples: await read($, samples),
349 days: await read($, days),
350 }
351
352 if (e.surface === 'terminal') {
353 const { Client } = $.ui.resolve(e)
354
355 return <Client key="top" module="./tui.tsx" props={snapshot} flexGrow={1} />
356 }
357
358 const { Box, Text, Button, Svg } = $.ui.resolve(e)
359
360 return drawDesktop({ Box, Text, Button, Svg }, snapshot, snapshot.now, action => void change($, action))
361 })
362}
363hooks/desktop.tsx 632 lines1import type { Elements, RenderElement } from 'claude-code'
2
3import type { PanelId } from '../types'
4import { cacheHit, compact, duration, limitLabel, resetsIn } from './format'
5import type { Action, Snapshot } from './model'
6import {
7 busyCommands,
8 dayKey,
9 deltas,
10 elapsedShare,
11 forecastLimits,
12 hotFiles,
13 pace,
14 PANELS,
15 perHour,
16 recentDays,
17 shortModel,
18 shortPath,
19 sortedTools,
20 timeSplit,
21} from './model'
22
23// The desktop app's drawing of cctop: cards, native buttons and SVG charts.
24//
25// Every Svg is drawn as a plain image: `isInteractive` puts it in a sandboxed
26// frame with a white page behind it, which shows on a dark theme. Text never
27// goes inside an SVG either, since an image cannot follow the theme's colour.
28
29type Kit = Pick<Elements['desktop'], 'Box' | 'Text' | 'Button' | 'Svg'>
30
31// Mid tones that read on a light and a dark page alike.
32const C = {
33 accent: '#d97757',
34 low: '#3fb950',
35 mid: '#d29922',
36 high: '#f85149',
37 blue: '#58a6ff',
38 violet: '#a371f7',
39 grey: '#8b949e',
40 track: 'rgba(127,127,127,0.22)',
41}
42
43// Pixels per cell of the code font, to size an SVG from the pane's width in cells.
44const PX = 7.2
45
46const heat = (percent: number): string => (percent >= 85 ? C.high : percent >= 60 ? C.mid : C.low)
47
48const LABELS: Record<PanelId, string> = {
49 context: 'Context',
50 limits: 'Limits',
51 cost: 'Cost',
52 tools: 'Tools',
53 agents: 'Agents',
54 forecast: 'Forecast',
55 time: 'Time',
56 files: 'Files',
57 bash: 'Bash',
58 week: 'Week',
59}
60
61const svg = (w: number, h: number, body: string): string =>
62 `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${body}</svg>`
63
64/** The smallest of 10, 25, 50 and 100 that holds the values with some headroom: the chart's top. */
65export const scaleTop = (values: readonly number[]): number => {
66 const high = Math.max(0, ...values) * 1.15
67
68 return [10, 25, 50, 100].find(top => high <= top) ?? 100
69}
70
71/** An area chart of percentages from 0 to `top`, one point a turn, the newest on the right. */
72export const areaChart = (values: readonly number[], w: number, h: number, top = 100): string => {
73 const shown = values.slice(-120)
74 const color = heat(shown[shown.length - 1] ?? 0)
75 const step = shown.length > 1 ? w / (shown.length - 1) : w
76 const y = (v: number) => (h - 2 - (Math.min(top, Math.max(0, v)) / top) * (h - 4)).toFixed(1)
77 const line = shown.map((v, i) => `${i === 0 ? 'M' : 'L'}${(i * step).toFixed(1)} ${y(v)}`).join(' ')
78 const grid = [0.5, 1]
79 .map(share => `<line x1="0" x2="${w}" y1="${y(top * share)}" y2="${y(top * share)}" stroke="${C.track}" stroke-dasharray="3 4"/>`)
80 .join('')
81 const dots = shown.map((v, i) => `<circle cx="${(i * step).toFixed(1)}" cy="${y(v)}" r="2" fill="${color}"/>`).join('')
82
83 return svg(
84 w,
85 h,
86 `<defs><linearGradient id="fill" x1="0" y1="0" x2="0" y2="1">` +
87 `<stop offset="0" stop-color="${color}" stop-opacity="0.45"/><stop offset="1" stop-color="${color}" stop-opacity="0.02"/>` +
88 `</linearGradient></defs>${grid}` +
89 `<path d="${line} L${w} ${h} L0 ${h} Z" fill="url(#fill)"/>` +
90 `<path d="${line}" fill="none" stroke="${color}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>` +
91 (shown.length <= 30 ? dots : ''),
92 )
93}
94
95/** A flat meter; `marker` draws a tick at a share of the width. */
96export const meter = (share: number, color: string, w: number, marker?: number): string => {
97 const tick =
98 marker === undefined
99 ? ''
100 : `<rect x="${Math.min(w - 2, Math.max(0, marker * w - 1)).toFixed(1)}" y="0" width="2" height="10" rx="1" fill="${C.grey}"/>`
101
102 return svg(
103 w,
104 10,
105 `<rect y="1" width="${w}" height="8" rx="4" fill="${C.track}"/>` +
106 `<rect y="1" width="${(Math.min(1, Math.max(0, share)) * w).toFixed(1)}" height="8" rx="4" fill="${color}"/>${tick}`,
107 )
108}
109
110/** Columns, one a value; an empty value leaves its slot to the track alone. */
111export const columns = (values: readonly number[], w: number, h: number, color: string): string => {
112 const shown = values.slice(-60)
113 const top = Math.max(...shown, 1e-9)
114 // A few turns keep slim bars from the left rather than stretching into blocks.
115 const slot = Math.min(16, w / Math.max(1, shown.length))
116 const bars = shown
117 .map((v, i) => {
118 const x = (i * slot + slot * 0.15).toFixed(1)
119 const width = (slot * 0.7).toFixed(1)
120 if (v <= 0) return `<rect x="${x}" y="${h - 2}" width="${width}" height="2" rx="1" fill="${C.track}"/>`
121 const bar = Math.max(3, (v / top) * (h - 2))
122
123 return `<rect x="${x}" y="${(h - bar).toFixed(1)}" width="${width}" height="${bar.toFixed(1)}" rx="2" fill="${color}"/>`
124 })
125 .join('')
126
127 return svg(w, h, bars)
128}
129
130/** One bar in parts side by side: the session's time split. */
131export const stackedBar = (parts: readonly { share: number; color: string }[], w: number): string => {
132 let x = 0
133 const rects = parts
134 .map(part => {
135 const width = Math.max(0, part.share * w)
136 const rect = `<rect x="${x.toFixed(1)}" y="1" width="${width.toFixed(1)}" height="8" fill="${part.color}"/>`
137 x += width
138
139 return rect
140 })
141 .join('')
142
143 return svg(w, 10, `<clipPath id="r"><rect y="1" width="${w}" height="8" rx="4"/></clipPath><g clip-path="url(#r)">${rects}</g>`)
144}
145
146export const drawDesktop = (kit: Kit, p: Snapshot, now: number, act: (action: Action) => void): RenderElement => {
147 const { Box, Text, Button, Svg } = kit
148 // Cells inside a card: the pane's body less the card's border and padding.
149 const inner = Math.max(20, p.columns - 4)
150 const px = (cells: number) => Math.max(40, Math.floor(cells * PX))
151 const chartWidth = px(inner - 1)
152 const shown = PANELS.map(panel => panel.id).filter(id => !p.prefs.hidden.includes(id))
153
154 /** A fixed-width cell, so rows and their header line up whatever the font. */
155 const cell = (width: number, child: RenderElement, align: 'left' | 'right' = 'right') => (
156 <Box width={width} flexShrink={0} justifyContent={align === 'right' ? 'flex-end' : 'flex-start'}>
157 {child}
158 </Box>
159 )
160
161 /** The stretchy cell of a row: it takes what the fixed cells leave, so a row spans the card. */
162 const grow = (child: RenderElement) => (
163 <Box flexGrow={1} flexShrink={1} minWidth={4}>
164 {child}
165 </Box>
166 )
167
168 /** A label on the left, its value on the right, one line that never wraps. */
169 const pair = (label: string, value: RenderElement) => (
170 <Box flexDirection="row" columnGap={1}>
171 {cell(9, <Text dimColor>{label}</Text>, 'left')}
172 {grow(value)}
173 </Box>
174 )
175
176 const card = (id: PanelId, summary: RenderElement, body: () => RenderElement[]) => {
177 const isFolded = p.prefs.collapsed.includes(id)
178
179 return (
180 <Box key={`card-${id}`} flexDirection="column" borderStyle="round" borderDimColor paddingX={1} marginTop={1}>
181 <Box flexDirection="row" justifyContent="space-between">
182 <Button
183 key={`fold-${id}`}
184 plain
185 label={`${isFolded ? '▸' : '▾'} ${LABELS[id]}`}
186 onPress={() => act({ kind: 'collapse', panel: id })}
187 />
188 {summary}
189 </Box>
190 {isFolded ? null : body()}
191 </Box>
192 )
193 }
194
195 const clockAt = (time: number) => {
196 const date = new Date(time)
197 const at = `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
198
199 return time - now < 20 * 3_600_000 ? at : `${['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'][date.getDay()] ?? ''} ${at}`
200 }
201
202 // The fill now as a bar, how fast it grows, and the turn-by-turn chart once there are turns to compare.
203 const context = () => {
204 const percent = p.usage?.percent
205 const history = p.history
206 const recent = history.slice(-6)
207 const growth = recent.length >= 2 ? ((recent[recent.length - 1] ?? 0) - (recent[0] ?? 0)) / (recent.length - 1) : undefined
208 const top = scaleTop(history)
209
210 return card(
211 'context',
212 percent === undefined ? <Text dimColor>waiting</Text> : <Text bold color={heat(percent)}>{`${Math.round(percent)}%`}</Text>,
213 () =>
214 percent === undefined || p.usage === null
215 ? [<Text dimColor>waiting for the first response</Text>]
216 : [
217 <Box flexDirection="row" columnGap={1} alignItems="center">
218 {cell(9, <Text dimColor>used</Text>, 'left')}
219 {grow(<Svg source={meter(percent / 100, heat(percent), px(inner - 17))} alt={`Context ${Math.round(percent)}% full`} />)}
220 {cell(6, <Text bold color={heat(percent)}>{`${Math.round(percent)}%`}</Text>)}
221 </Box>,
222 pair('tokens', <Text wrap="truncate-end">{`${compact(p.usage.tokens ?? 0)} of ${compact(p.usage.window)}`}</Text>),
223 ...(growth === undefined
224 ? []
225 : [
226 pair(
227 'per turn',
228 <Text wrap="truncate-end">
229 {growth > 0.05
230 ? `+${growth.toFixed(1)}% · full in ~${Math.ceil((100 - percent) / growth)} turns`
231 : growth < -0.05
232 ? `${growth.toFixed(1)}% (compacted)`
233 : 'flat'}
234 </Text>,
235 ),
236 ]),
237 ...(history.length >= 3
238 ? [
239 <Box flexDirection="row" justifyContent="space-between" marginTop={1}>
240 <Text dimColor>{`last ${Math.min(history.length, 120)} turns`}</Text>
241 <Text dimColor>{`scale 0-${top}%`}</Text>
242 </Box>,
243 <Svg source={areaChart(history, chartWidth, 56, top)} alt={`Context over the last ${history.length} turns, from 0 to ${top}%`} />,
244 ]
245 : [<Text dimColor>the chart appears after 3 turns</Text>]),
246 ],
247 )
248 }
249
250 // Each window as two bars, so the comparison reads at a glance: how much is used, how much of its time has passed.
251 const limits = () => {
252 const rows = p.usage?.rateLimits ?? []
253 // Label 6 + value 6 + two gaps take 14 cells; the bar gets the rest.
254 const barWidth = px(inner - 14)
255
256 return card(
257 'limits',
258 <Text dimColor>{rows.map(row => `${limitLabel(row.kind)} ${Math.round(row.percentUsed)}%`).join(' ') || 'no reading'}</Text>,
259 () =>
260 rows.length === 0
261 ? [<Text dimColor>no reading yet</Text>]
262 : rows.map(row => {
263 const elapsed = elapsedShare(row.kind, row.resetsAt, now)
264 const ahead = pace(row.kind, row.percentUsed, row.resetsAt, now)
265
266 return (
267 <Box key={`limit-${row.kind}`} flexDirection="column" marginTop={1}>
268 <Box flexDirection="row" justifyContent="space-between">
269 <Text bold>{limitLabel(row.kind)}</Text>
270 <Text dimColor>{`resets in ${resetsIn(row.resetsAt, now)}`}</Text>
271 </Box>
272 <Box flexDirection="row" columnGap={1} alignItems="center">
273 {cell(6, <Text dimColor>used</Text>, 'left')}
274 {grow(<Svg source={meter(row.percentUsed / 100, heat(row.percentUsed), barWidth)} alt={`${Math.round(row.percentUsed)}% of the window used`} />)}
275 {cell(6, <Text bold color={heat(row.percentUsed)}>{`${Math.round(row.percentUsed)}%`}</Text>)}
276 </Box>
277 {elapsed === undefined ? null : (
278 <Box flexDirection="row" columnGap={1} alignItems="center">
279 {cell(6, <Text dimColor>time</Text>, 'left')}
280 {grow(<Svg source={meter(elapsed, C.grey, barWidth)} alt={`${Math.round(elapsed * 100)}% of the window's time passed`} />)}
281 {cell(6, <Text dimColor>{`${Math.round(elapsed * 100)}%`}</Text>)}
282 </Box>
283 )}
284 {ahead === undefined ? null : (
285 <Text color={ahead > 10 ? C.high : ahead > 0 ? C.mid : C.low}>
286 {Math.abs(ahead) < 1
287 ? 'spending evenly'
288 : ahead > 0
289 ? `spending ${Math.round(ahead)} pts faster than even: runs out before the reset`
290 : `spending ${Math.round(-ahead)} pts slower than even`}
291 </Text>
292 )}
293 </Box>
294 )
295 }),
296 )
297 }
298
299 const cost = () => {
300 const usd = p.usage?.costUsd
301 const rate = perHour(usd, p.startedAt, now)
302 const hit = cacheHit(p.tokens)
303 const spent = deltas(p.costs)
304 const last = spent[spent.length - 1]
305 const average = spent.length === 0 ? undefined : spent.reduce((sum, v) => sum + v, 0) / spent.length
306 const dollars = (v: number | undefined) => (v === undefined ? '-' : `$${v.toFixed(2)}`)
307
308 return card(
309 'cost',
310 <Text bold color={C.accent}>{dollars(usd)}</Text>,
311 () => [
312 pair('total', <Text bold color={C.accent} wrap="truncate-end">{`${dollars(usd)} · ${p.tokens.turns} ${p.tokens.turns === 1 ? 'turn' : 'turns'}`}</Text>),
313 pair('per hour', <Text wrap="truncate-end">{dollars(rate)}</Text>),
314 ...(spent.length > 0
315 ? [
316 pair('per turn', <Text wrap="truncate-end">{`last ${dollars(last)} · avg ${dollars(average)} · max ${dollars(Math.max(...spent))}`}</Text>),
317 <Svg source={columns(spent, px(inner - 1), 24, C.violet)} alt="Cost of each turn, oldest on the left" />,
318 ]
319 : []),
320 pair('tokens', <Text wrap="truncate-end">{`in ${compact(p.tokens.input + p.tokens.cacheRead + p.tokens.cacheWrite)} · out ${compact(p.tokens.output)}`}</Text>),
321 ...(hit === undefined
322 ? []
323 : [
324 <Box flexDirection="row" columnGap={1} alignItems="center">
325 {cell(9, <Text dimColor>cache</Text>, 'left')}
326 {grow(<Svg source={meter(hit, hit >= 0.8 ? C.low : hit >= 0.5 ? C.mid : C.high, px(inner - 17))} alt={`${Math.round(hit * 100)}% of input reread from the cache`} />)}
327 {cell(6, <Text bold>{`${Math.round(hit * 100)}%`}</Text>)}
328 </Box>,
329 <Text dimColor wrap="truncate-end">reread input, billed at ~1/10</Text>,
330 ]),
331 ],
332 )
333 }
334
335 // Fixed columns after the name: calls 7, err 5, avg 8, total 8, max 8, and a gap before each.
336 const TOOL_COLUMNS = 7 + 5 + 8 + 8 + 8 + 5
337
338 const tools = () => {
339 const rows = sortedTools(p.tools, p.prefs.sort, p.prefs.reverse)
340 const calls = rows.reduce((sum, row) => sum + row.calls, 0)
341 const errors = rows.reduce((sum, row) => sum + row.errors + row.denied, 0)
342 const isTable = inner - TOOL_COLUMNS >= 10
343 const sortButton = (key: (typeof p.prefs)['sort'], label: string) => (
344 <Button
345 key={`sort-${key}`}
346 plain
347 dimColor={p.prefs.sort !== key}
348 label={`${label}${p.prefs.sort === key ? (p.prefs.reverse ? '▲' : '▼') : ''}`}
349 onPress={() => act({ kind: 'sort', key })}
350 />
351 )
352 const failed = (row: (typeof rows)[number]) => row.errors + row.denied
353
354 const table = () => [
355 <Box flexDirection="row" columnGap={1}>
356 {grow(sortButton('name', 'tool'))}
357 {cell(7, sortButton('calls', 'calls'))}
358 {cell(5, sortButton('errors', 'err'))}
359 {cell(8, sortButton('avg', 'avg'))}
360 {cell(8, sortButton('time', 'total'))}
361 {cell(8, sortButton('max', 'max'))}
362 </Box>,
363 ...rows.slice(0, 12).map(row => (
364 <Box key={`tool-${row.name}`} flexDirection="row" columnGap={1}>
365 {grow(<Text color={C.blue} wrap="truncate-end">{row.name}</Text>)}
366 {cell(7, <Text>{String(row.calls)}</Text>)}
367 {cell(5, failed(row) > 0 ? <Text color={C.high}>{String(failed(row))}</Text> : <Text dimColor>0</Text>)}
368 {cell(8, <Text dimColor>{duration(row.totalMs / Math.max(1, row.calls))}</Text>)}
369 {cell(8, <Text>{duration(row.totalMs)}</Text>)}
370 {cell(8, <Text dimColor>{duration(row.maxMs)}</Text>)}
371 </Box>
372 )),
373 ]
374
375 // Too narrow for six columns: the sort keys wrap on a row of their own, a tool takes two rows.
376 const stacked = () => [
377 <Box flexDirection="row" columnGap={1} flexWrap="wrap">
378 <Text dimColor>sort</Text>
379 {sortButton('name', 'tool')}
380 {sortButton('calls', 'calls')}
381 {sortButton('errors', 'err')}
382 {sortButton('avg', 'avg')}
383 {sortButton('time', 'total')}
384 {sortButton('max', 'max')}
385 </Box>,
386 ...rows.slice(0, 12).map(row => (
387 <Box key={`tool-${row.name}`} flexDirection="column" marginTop={1}>
388 <Box flexDirection="row" columnGap={1}>
389 {grow(<Text color={C.blue} wrap="truncate-end">{row.name}</Text>)}
390 <Text bold>{`${row.calls}×`}</Text>
391 {failed(row) > 0 ? <Text color={C.high}>{`${failed(row)} err`}</Text> : null}
392 </Box>
393 <Text dimColor>
394 {`avg ${duration(row.totalMs / Math.max(1, row.calls))} · total ${duration(row.totalMs)} · max ${duration(row.maxMs)}`}
395 </Text>
396 </Box>
397 )),
398 ]
399
400 return card(
401 'tools',
402 <Text dimColor>{`${calls} calls${errors > 0 ? ` · ${errors} errors` : ''}`}</Text>,
403 () => (rows.length === 0 ? [<Text dimColor>no tool calls yet</Text>] : isTable ? table() : stacked()),
404 )
405 }
406
407 // Two rows a subagent: its type, model and time on top, what it was asked below, so nothing runs past the card.
408 const agents = () => {
409 const running = p.agents.filter(agent => agent.status === 'running').length
410 const list = [...p.agents]
411 .sort((a, b) => Number(b.status === 'running') - Number(a.status === 'running') || b.startedAt - a.startedAt)
412 .slice(0, 8)
413 const color = { running: C.mid, completed: C.low, failed: C.high, killed: C.grey }
414
415 return card(
416 'agents',
417 <Text dimColor>{`${running} running · ${p.agents.length - running} done`}</Text>,
418 () =>
419 list.length === 0
420 ? [<Text dimColor>no subagents yet</Text>]
421 : list.map(agent => (
422 <Box key={`agent-${agent.id}`} flexDirection="column" marginTop={1}>
423 <Box flexDirection="row" columnGap={1}>
424 {cell(2, <Text color={color[agent.status]}>●</Text>, 'left')}
425 {grow(<Text bold wrap="truncate-end">{agent.type}</Text>)}
426 <Text dimColor>{shortModel(agent.model)}</Text>
427 {cell(7, <Text dimColor>{duration(agent.status === 'running' ? now - agent.startedAt : (agent.durationMs ?? 0))}</Text>)}
428 </Box>
429 <Box flexDirection="row" columnGap={1}>
430 {cell(2, <Text> </Text>, 'left')}
431 {grow(<Text dimColor wrap="truncate-end">{agent.description}</Text>)}
432 </Box>
433 </Box>
434 )),
435 )
436 }
437
438 // A window per block of short label-value lines: nothing wraps, and the verdict sits on its own line.
439 const forecast = () => {
440 const rows = forecastLimits(p.usage, p.samples, now)
441 const worst = rows.find(row => row.isShort)
442 const usesAverage = rows.some(row => row.rate > 0 && row.basis === 'window')
443
444 return card(
445 'forecast',
446 worst === undefined ? <Text color={C.low}>lasts</Text> : <Text color={C.high}>{`${limitLabel(worst.kind)} runs out`}</Text>,
447 () =>
448 rows.length === 0
449 ? [<Text dimColor>no rate-limit reading yet</Text>]
450 : [
451 ...rows.map(row => (
452 <Box key={`forecast-${row.kind}`} flexDirection="column" marginTop={1}>
453 <Box flexDirection="row" columnGap={1}>
454 <Text bold>{limitLabel(row.kind)}</Text>
455 <Text bold color={heat(row.percent)}>{`${Math.round(row.percent)}% used`}</Text>
456 </Box>
457 {pair('speed', <Text wrap="truncate-end">{row.rate > 0 ? `${row.basis === 'window' ? '~' : ''}${row.rate.toFixed(1)}% / hour` : 'not climbing'}</Text>)}
458 {row.fullAt === undefined ? null : pair('full at', <Text bold={row.isShort} wrap="truncate-end">{clockAt(row.fullAt)}</Text>)}
459 {row.resetAt === undefined ? null : pair('reset', <Text wrap="truncate-end">{clockAt(row.resetAt)}</Text>)}
460 {row.fullAt === undefined ? null : row.isShort && row.resetAt !== undefined ? (
461 <Text color={C.high} wrap="truncate-end">{`✕ runs out ${duration(row.resetAt - row.fullAt)} early`}</Text>
462 ) : (
463 <Text color={C.low} wrap="truncate-end">✓ lasts until the reset</Text>
464 )}
465 </Box>
466 )),
467 ...(usesAverage ? [<Text dimColor wrap="truncate-end">~ average since the window opened</Text>] : []),
468 ],
469 )
470 }
471
472 const time = () => {
473 const split = timeSplit(p.timing, p.startedAt, now)
474 const share = (ms: number) => (split.total > 0 ? ms / split.total : 0)
475 const parts = [
476 { label: 'model', ms: split.model, color: C.violet },
477 { label: 'tools', ms: split.tools, color: C.blue },
478 { label: 'you', ms: split.idle, color: C.grey },
479 ]
480
481 return card(
482 'time',
483 <Text dimColor>{split.total > 0 ? `model ${Math.round(share(split.model) * 100)}%` : 'no turns'}</Text>,
484 () =>
485 split.total <= 0
486 ? [<Text dimColor>no finished turn yet</Text>]
487 : [
488 <Svg source={stackedBar(parts.map(part => ({ share: share(part.ms), color: part.color })), chartWidth)} alt="Where the session's time went" />,
489 <Box flexDirection="row" columnGap={2} flexWrap="wrap">
490 {parts.map(part => (
491 <Box key={`time-${part.label}`} flexDirection="row" columnGap={1}>
492 <Text color={part.color}>■</Text>
493 <Text dimColor>{part.label}</Text>
494 <Text bold>{`${Math.round(share(part.ms) * 100)}%`}</Text>
495 <Text dimColor>{duration(part.ms)}</Text>
496 </Box>
497 ))}
498 </Box>,
499 ],
500 )
501 }
502
503 const files = () => {
504 const rows = hotFiles(p.files)
505
506 return card(
507 'files',
508 <Text dimColor>{`${rows.length} ${rows.length === 1 ? 'file' : 'files'}`}</Text>,
509 () =>
510 rows.length === 0
511 ? [<Text dimColor>no file read or changed yet</Text>]
512 : [
513 <Box flexDirection="row" columnGap={1}>
514 {cell(5, <Text dimColor>read</Text>)}
515 {cell(5, <Text dimColor>edit</Text>)}
516 {grow(<Text dimColor>path</Text>)}
517 </Box>,
518 ...rows.slice(0, 10).map(row => (
519 <Box key={`file-${row.path}`} flexDirection="row" columnGap={1}>
520 {cell(5, <Text color={C.blue}>{row.reads === 0 ? '·' : String(row.reads)}</Text>)}
521 {cell(5, <Text color={C.accent} bold={row.edits > 0}>{row.edits === 0 ? '·' : String(row.edits)}</Text>)}
522 {grow(<Text wrap="truncate-start">{shortPath(row.path, p.cwd)}</Text>)}
523 </Box>
524 )),
525 ],
526 )
527 }
528
529 const bash = () => {
530 const rows = busyCommands(p.commands)
531
532 return card(
533 'bash',
534 <Text dimColor>{`${rows.reduce((sum, row) => sum + row.calls, 0)} runs`}</Text>,
535 () =>
536 rows.length === 0
537 ? [<Text dimColor>no shell command yet</Text>]
538 : [
539 <Box flexDirection="row" columnGap={1}>
540 {grow(<Text dimColor>command</Text>)}
541 {cell(6, <Text dimColor>runs</Text>)}
542 {cell(5, <Text dimColor>err</Text>)}
543 {cell(8, <Text dimColor>time</Text>)}
544 </Box>,
545 ...rows.slice(0, 10).map(row => (
546 <Box key={`cmd-${row.name}`} flexDirection="row" columnGap={1}>
547 {grow(<Text color={C.blue} wrap="truncate-end">{row.name}</Text>)}
548 {cell(6, <Text>{String(row.calls)}</Text>)}
549 {cell(5, row.errors > 0 ? <Text color={C.high}>{String(row.errors)}</Text> : <Text dimColor>0</Text>)}
550 {cell(8, <Text dimColor>{duration(row.totalMs)}</Text>)}
551 </Box>
552 )),
553 ],
554 )
555 }
556
557 const week = () => {
558 const list = recentDays(p.days, now, 7)
559 const total = list.reduce((sum, { day }) => sum + day.usd, 0)
560 const top = Math.max(1e-9, ...list.map(({ day }) => day.usd))
561 const today = dayKey(now)
562 // Day 7 + $ 9 + tokens 7 + three gaps take 26 cells; the bar gets the rest.
563 const barWidth = px(inner - 26)
564
565 return card('week', <Text bold color={C.accent}>{`7d $${total.toFixed(2)}`}</Text>, () =>
566 list.map(({ key, day }) => {
567 const isToday = key === today
568
569 return (
570 <Box key={`day-${key}`} flexDirection="row" columnGap={1} alignItems="center">
571 {cell(
572 7,
573 <Text bold={isToday} dimColor={!isToday}>
574 {`${['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'][new Date(`${key}T12:00:00`).getDay()] ?? ''} ${key.slice(8)}`}
575 </Text>,
576 'left',
577 )}
578 {grow(<Svg source={meter(day.usd / top, isToday ? C.accent : C.violet, barWidth)} alt={`${key}: $${day.usd.toFixed(2)}`} />)}
579 {cell(9, <Text bold={isToday}>{day.usd > 0 ? `$${day.usd.toFixed(2)}` : '-'}</Text>)}
580 {cell(7, <Text dimColor>{day.tokensIn + day.tokensOut > 0 ? compact(day.tokensIn + day.tokensOut) : ''}</Text>)}
581 </Box>
582 )
583 }),
584 )
585 }
586
587 const draw: Record<PanelId, () => RenderElement> = { context, limits, cost, tools, agents, forecast, time, files, bash, week }
588
589 return (
590 <Box flexDirection="column">
591 <Box flexDirection="row" columnGap={1}>
592 <Text bold color={C.accent}>cctop</Text>
593 <Text dimColor>{[shortModel(p.model), p.startedAt > 0 ? `up ${duration(Math.max(0, now - p.startedAt))}` : ''].filter(Boolean).join(' · ')}</Text>
594 </Box>
595 <Box flexDirection="row" columnGap={3} flexWrap="wrap" marginTop={1}>
596 {[true, false].map(isDefault => (
597 <Box key={`group-${isDefault ? 'default' : 'optional'}`} flexDirection="column">
598 <Text dimColor>{isDefault ? 'default' : 'optional'}</Text>
599 {PANELS.filter(panel => panel.isDefault === isDefault).map(panel => {
600 const isOn = !p.prefs.hidden.includes(panel.id)
601
602 return (
603 <Box key={`row-${panel.id}`} flexDirection="row" columnGap={1}>
604 {cell(
605 12,
606 <Button
607 key={`show-${panel.id}`}
608 plain
609 dimColor={!isOn}
610 label={`${isOn ? '●' : '○'} ${LABELS[panel.id]}`}
611 onPress={() => act({ kind: 'visible', panel: panel.id, on: !isOn })}
612 />,
613 'left',
614 )}
615 <Text dimColor>{panel.about}</Text>
616 </Box>
617 )
618 })}
619 </Box>
620 ))}
621 </Box>
622 <Box flexDirection="row" columnGap={2} flexWrap="wrap" marginTop={1}>
623 <Button key="reset" plain dimColor label="reset layout" onPress={() => act({ kind: 'reset' })} />
624 <Text dimColor>context alert</Text>
625 <Button key="alert" plain label={`speaks as ${p.prefs.alert}`} onPress={() => act({ kind: 'alert' })} />
626 <Button key="threshold" plain label={`past ${p.prefs.threshold}%`} onPress={() => act({ kind: 'threshold' })} />
627 </Box>
628 {shown.length === 0 ? <Text dimColor>every panel is hidden: pick one above</Text> : shown.map(id => draw[id]())}
629 </Box>
630 )
631}
632hooks/format.ts 111 lines1import type { Tokens } from '../types'
2
3const SPARK = '▁▂▃▄▅▆▇█'
4
5export type Level = 'green' | 'yellow' | 'red'
6
7export const level = (percent: number): Level =>
8 percent >= 85 ? 'red' : percent >= 60 ? 'yellow' : 'green'
9
10export const bar = (fraction: number, width: number): [string, string] => {
11 const share = Math.min(1, Math.max(0, fraction))
12 const filled = Math.round(share * width)
13
14 return ['█'.repeat(filled), '░'.repeat(Math.max(0, width - filled))]
15}
16
17export const spark = (values: readonly number[], width: number, max = 100): string =>
18 values
19 .slice(-width)
20 .map(value => {
21 const share = Math.min(1, Math.max(0, value / max))
22
23 return SPARK[Math.min(SPARK.length - 1, Math.floor(share * SPARK.length))]
24 })
25 .join('')
26
27export const compact = (n: number): string => {
28 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
29 if (n >= 10_000) return `${Math.round(n / 1000)}k`
30 if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
31
32 return String(Math.round(n))
33}
34
35export const duration = (ms: number): string => {
36 if (ms < 1000) return `${Math.round(ms)}ms`
37 if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`
38 const two = (n: number) => String(n).padStart(2, '0')
39 if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m${two(Math.floor((ms % 60_000) / 1000))}s`
40 if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h${two(Math.floor((ms % 3_600_000) / 60_000))}m`
41
42 return `${Math.floor(ms / 86_400_000)}d${two(Math.floor((ms % 86_400_000) / 3_600_000))}h`
43}
44
45export const resetsIn = (iso: string | undefined, now: number): string => {
46 if (iso === undefined) return ''
47 const left = Date.parse(iso) - now
48 if (Number.isNaN(left)) return ''
49 if (left <= 0) return 'now'
50 const hours = Math.floor(left / 3_600_000)
51 const minutes = Math.floor((left % 3_600_000) / 60_000)
52 if (hours >= 24) return `${Math.floor(hours / 24)}d${hours % 24}h`
53
54 return hours > 0 ? `${hours}h${String(minutes).padStart(2, '0')}m` : `${minutes}m`
55}
56
57export const limitLabel = (kind: string): string =>
58 kind === 'five_hour' ? '5h' : kind === 'seven_day' ? '7d' : kind === 'spend_limit' ? '$' : kind
59
60export const toolLabel = (tool: string): string => {
61 const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
62
63 return mcp ? `${mcp[1]}:${mcp[2]}` : tool
64}
65
66export const cacheHit = (tokens: Tokens): number | undefined => {
67 const total = tokens.input + tokens.cacheRead + tokens.cacheWrite
68
69 return total === 0 ? undefined : tokens.cacheRead / total
70}
71
72// Braille dots of one cell, top to bottom: the left column, then the right.
73const LEFT_DOTS = [0x01, 0x02, 0x04, 0x40]
74const RIGHT_DOTS = [0x08, 0x10, 0x20, 0x80]
75
76/**
77 * A filled area graph in braille, two samples per cell and four levels per
78 * row, as btop draws its graphs: the newest sample on the right edge.
79 *
80 * @returns `height` strings of `width` cells each, the top row first
81 */
82export const brailleGraph = (values: readonly number[], width: number, height: number, max = 100): string[] => {
83 const samples = values.slice(-width * 2)
84 const padded = [...Array<number>(width * 2 - samples.length).fill(-1), ...samples]
85 const levels = padded.map(value =>
86 value < 0 ? 0 : Math.max(value > 0 ? 1 : 0, Math.round((Math.min(value, max) / Math.max(max, 1e-9)) * height * 4)),
87 )
88
89 return Array.from({ length: height }, (_, row) => {
90 let line = ''
91 for (let col = 0; col < width; col += 1) {
92 let bits = 0
93 for (let dot = 0; dot < 4; dot += 1) {
94 // How high this dot sits, counted from the graph's floor: 1 is the lowest.
95 const from = (height - row) * 4 - dot
96 if ((levels[col * 2] ?? 0) >= from) bits |= LEFT_DOTS[dot] ?? 0
97 if ((levels[col * 2 + 1] ?? 0) >= from) bits |= RIGHT_DOTS[dot] ?? 0
98 }
99 line += String.fromCodePoint(0x2800 + bits)
100 }
101
102 return line
103 })
104}
105
106export const cell = (text: string, width: number, align: 'left' | 'right' = 'left'): string => {
107 const cut = text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text
108
109 return align === 'left' ? cut.padEnd(width) : cut.padStart(width)
110}
111hooks/model.ts 420 lines1import type {
2 AgentRow,
3 AlertMode,
4 CommandStat,
5 DayStat,
6 FileStat,
7 LimitSample,
8 PanelId,
9 Prefs,
10 SortKey,
11 Timing,
12 Tokens,
13 ToolStat,
14 UsageSnap,
15} from '../types'
16
17/**
18 * Every panel in its order: the five shown by default first (keys 1-5), the
19 * optional ones after (6-9 and 0). `about` is the menu's one-line explanation.
20 */
21export const PANELS: readonly { id: PanelId; title: string; about: string; isDefault: boolean }[] = [
22 { id: 'context', title: 'context', about: 'window fill by turn', isDefault: true },
23 { id: 'limits', title: 'limits', about: '5h and 7d windows', isDefault: true },
24 { id: 'tools', title: 'tools', about: 'calls, errors, timing', isDefault: true },
25 { id: 'agents', title: 'agents', about: 'running subagents', isDefault: true },
26 { id: 'week', title: 'week', about: 'cost per day', isDefault: true },
27 { id: 'cost', title: 'cost', about: '$, tokens, cache', isDefault: false },
28 { id: 'forecast', title: 'forecast', about: 'when limits run out', isDefault: false },
29 { id: 'time', title: 'time', about: 'model, tools, you', isDefault: false },
30 { id: 'files', title: 'files', about: 'most touched files', isDefault: false },
31 { id: 'bash', title: 'bash', about: 'shell commands', isDefault: false },
32]
33
34/** The panel's number and key: its place in PANELS, 10 written as 0. */
35export const panelNumber = (id: PanelId): number => PANELS.findIndex(panel => panel.id === id) + 1
36
37/** Bumped when the default set changes, so stored layouts made against the old one start over. */
38export const LAYOUT_VERSION = 2
39
40/** In two columns (from 100 cells) these panels go left, the rest right. */
41export const LEFT_PANELS: readonly PanelId[] = ['context', 'limits', 'week', 'cost', 'forecast', 'time']
42
43export const ALERT_MODES: readonly AlertMode[] = ['toast', 'status', 'both', 'off']
44
45export const THRESHOLDS: readonly number[] = [60, 70, 80, 90]
46
47export const SORTS: readonly SortKey[] = ['time', 'calls', 'errors', 'avg', 'max', 'name']
48
49export const DEFAULT_PREFS: Prefs = {
50 layout: LAYOUT_VERSION,
51 hidden: PANELS.filter(panel => !panel.isDefault).map(panel => panel.id),
52 collapsed: [],
53 sort: 'time',
54 reverse: false,
55 alert: 'toast',
56 threshold: 70,
57}
58
59/** Everything a drawing needs, as plain data: what a `Client` takes as props. */
60export type Snapshot = {
61 now: number
62 startedAt: number
63 model: string
64 columns: number
65 rows: number
66 usage: UsageSnap | null
67 history: number[]
68 costs: number[]
69 tokens: Tokens
70 tools: Record<string, ToolStat>
71 agents: AgentRow[]
72 prefs: Prefs
73 cwd: string
74 files: Record<string, FileStat>
75 commands: Record<string, CommandStat>
76 timing: Timing
77 samples: Record<string, LimitSample[]>
78 days: Record<string, DayStat>
79}
80
81/** What the drawing asks the hooks to change, posted from a `Client` or pressed on a Button. */
82export type Action =
83 | { kind: 'collapse'; panel: PanelId }
84 | { kind: 'visible'; panel: PanelId; on: boolean }
85 | { kind: 'sort'; key: SortKey }
86 | { kind: 'alert' }
87 | { kind: 'threshold' }
88 | { kind: 'reset' }
89
90const isPanel = (value: unknown): value is PanelId => PANELS.some(panel => panel.id === value)
91
92const isSort = (value: unknown): value is SortKey => SORTS.some(key => key === value)
93
94/** A posted message comes from code, so it is checked before it is trusted. */
95export const parseAction = (data: unknown): Action | undefined => {
96 if (typeof data !== 'object' || data === null) return undefined
97 const { kind, panel, on, key } = data as Record<string, unknown>
98 if (kind === 'collapse' && isPanel(panel)) return { kind, panel }
99 if (kind === 'visible' && isPanel(panel) && typeof on === 'boolean') return { kind, panel, on }
100 if (kind === 'sort' && isSort(key)) return { kind, key }
101 if (kind === 'reset' || kind === 'alert' || kind === 'threshold') return { kind }
102
103 return undefined
104}
105
106const toggle = (list: readonly PanelId[], panel: PanelId, on: boolean): PanelId[] =>
107 on ? [...new Set([...list, panel])] : list.filter(one => one !== panel)
108
109export const applyAction = (prefs: Prefs, action: Action): Prefs => {
110 switch (action.kind) {
111 case 'collapse':
112 return { ...prefs, collapsed: toggle(prefs.collapsed, action.panel, !prefs.collapsed.includes(action.panel)) }
113 case 'visible':
114 return { ...prefs, hidden: toggle(prefs.hidden, action.panel, !action.on) }
115 case 'sort':
116 return action.key === prefs.sort
117 ? { ...prefs, reverse: !prefs.reverse }
118 : { ...prefs, sort: action.key, reverse: false }
119 case 'alert':
120 return { ...prefs, alert: ALERT_MODES[(ALERT_MODES.indexOf(prefs.alert) + 1) % ALERT_MODES.length] ?? 'toast' }
121 case 'threshold':
122 return { ...prefs, threshold: THRESHOLDS[(THRESHOLDS.indexOf(prefs.threshold) + 1) % THRESHOLDS.length] ?? 70 }
123 case 'reset':
124 // The layout goes back to the defaults; the alert settings are not layout and stay.
125 return { ...DEFAULT_PREFS, alert: prefs.alert, threshold: prefs.threshold }
126 }
127}
128
129/** A stored value from an older build or a hand edit falls back field by field. */
130export const parsePrefs = (stored: unknown): Prefs => {
131 if (typeof stored !== 'object' || stored === null) return DEFAULT_PREFS
132 const { layout, hidden, collapsed, sort, reverse, alert, threshold } = stored as Record<string, unknown>
133 const panels = (value: unknown) => (Array.isArray(value) ? value.filter(isPanel) : [])
134 const isCurrent = layout === LAYOUT_VERSION
135
136 return {
137 layout: LAYOUT_VERSION,
138 hidden: isCurrent ? panels(hidden) : DEFAULT_PREFS.hidden,
139 collapsed: isCurrent ? panels(collapsed) : [],
140 sort: isSort(sort) ? sort : DEFAULT_PREFS.sort,
141 reverse: reverse === true,
142 alert: ALERT_MODES.find(mode => mode === alert) ?? DEFAULT_PREFS.alert,
143 threshold: THRESHOLDS.find(value => value === threshold) ?? DEFAULT_PREFS.threshold,
144 }
145}
146
147export type ToolRow = { name: string } & ToolStat
148
149export const sortedTools = (tools: Record<string, ToolStat>, sort: SortKey, reverse: boolean): ToolRow[] => {
150 const value = (row: ToolRow): number | string => {
151 switch (sort) {
152 case 'time':
153 return row.totalMs
154 case 'calls':
155 return row.calls
156 case 'errors':
157 return row.errors + row.denied
158 case 'avg':
159 return row.totalMs / Math.max(1, row.calls)
160 case 'max':
161 return row.maxMs
162 case 'name':
163 return row.name.toLowerCase()
164 }
165 }
166 const rows = Object.entries(tools).map(([name, stat]) => ({ name, ...stat }))
167 // Numbers read biggest first, names from A; `reverse` flips either.
168 const direction = (sort === 'name' ? 1 : -1) * (reverse ? -1 : 1)
169
170 return rows.sort((a, b) => {
171 const left = value(a)
172 const right = value(b)
173
174 return (left < right ? -1 : left > right ? 1 : 0) * direction
175 })
176}
177
178const WINDOW_MS: Record<string, number> = {
179 five_hour: 5 * 3_600_000,
180 seven_day: 7 * 86_400_000,
181}
182
183/**
184 * How far ahead of an even spend a window is, in percentage points: used share
185 * minus the elapsed share. Undefined where the window's length is unknown.
186 */
187export const pace = (kind: string, percentUsed: number, resetsAt: string | undefined, now: number): number | undefined => {
188 const length = WINDOW_MS[kind]
189 if (length === undefined || resetsAt === undefined) return undefined
190 const left = Date.parse(resetsAt) - now
191 if (Number.isNaN(left)) return undefined
192 const elapsed = Math.min(1, Math.max(0, 1 - left / length))
193
194 return percentUsed - elapsed * 100
195}
196
197/** Where an even spend would stand now, 0 to 1, for the marker on a limit's gauge. */
198export const elapsedShare = (kind: string, resetsAt: string | undefined, now: number): number | undefined => {
199 const value = pace(kind, 0, resetsAt, now)
200
201 return value === undefined ? undefined : -value / 100
202}
203
204export const perHour = (usd: number | undefined, startedAt: number, now: number): number | undefined => {
205 const hours = (now - startedAt) / 3_600_000
206 if (usd === undefined || startedAt <= 0 || hours < 1 / 60) return undefined
207
208 return usd / hours
209}
210
211/**
212 * Cost of each turn from the running totals sampled after each one. The first
213 * sample has no turn before it to subtract: its total is everything spent
214 * before the mod was watching, so it starts the series and is not a turn.
215 */
216export const deltas = (totals: readonly number[]): number[] =>
217 totals.slice(1).map((total, i) => Math.max(0, total - (totals[i] ?? 0)))
218
219export const shortModel = (model: string): string =>
220 model
221 .replace(/^claude-/, '')
222 .replace(/-\d{8}$/, '')
223 .replace(/-(\d+)-(\d+)$/, ' $1.$2')
224 .replace(/-(\d+)$/, ' $1')
225
226export const visiblePanels = (prefs: Prefs): PanelId[] =>
227 PANELS.map(panel => panel.id).filter(id => !prefs.hidden.includes(id))
228
229// Analytics: pure functions over what the hooks collected.
230
231/** Programs whose first argument names what they did (`git status`, `docker compose`). */
232const WITH_SUBCOMMAND = new Set([
233 'git', 'docker', 'gh', 'npm', 'pnpm', 'yarn', 'bun', 'cargo', 'go', 'composer', 'claude',
234 'systemctl', 'kubectl', 'pacman', 'yay', 'brew', 'apt', 'pip', 'uv', 'php', 'artisan',
235])
236
237/**
238 * The family a shell command belongs to: its first program, with the
239 * subcommand for tools that have them. `cd dir && make` counts as `make`,
240 * `sudo` and `VAR=1` prefixes are skipped.
241 */
242export const commandKey = (command: string): string => {
243 const parts = command.split(/&&|\|\||;|\|/).map(part => part.trim()).filter(Boolean)
244 const main = parts.find(part => !/^cd\s/.test(part) && part !== 'cd') ?? parts[0] ?? ''
245 const words = main.split(/\s+/).filter(word => !/^\w+=/.test(word))
246 while (words[0] === 'sudo' || words[0] === 'env' || words[0] === 'time' || words[0] === 'command') words.shift()
247 const program = (words[0] ?? '').split('/').pop() ?? ''
248 if (program === '') return '?'
249 const sub = words.slice(1).find(word => !word.startsWith('-'))
250
251 return WITH_SUBCOMMAND.has(program) && sub !== undefined ? `${program} ${sub}` : program
252}
253
254const LIMIT_WINDOW_MS: Record<string, number> = { five_hour: 5 * 3_600_000, seven_day: 7 * 86_400_000 }
255
256export type LimitForecast = {
257 kind: string
258 percent: number
259 /** Percentage points per hour. */
260 rate: number
261 /** When the window reaches 100% at that rate; undefined when it is not climbing. */
262 fullAt?: number
263 resetAt?: number
264 /** True when the window fills before it resets. */
265 isShort: boolean
266 /** Where the rate came from: recent readings, or the window's average so far. */
267 basis: 'recent' | 'window'
268}
269
270/**
271 * Projects each rate-limit window to 100%: from the readings of the last hour
272 * when they moved, else from the window's average since it opened.
273 */
274export const forecastLimits = (usage: UsageSnap | null, samples: Record<string, LimitSample[]>, now: number): LimitForecast[] =>
275 (usage?.rateLimits ?? []).map(limit => {
276 const resetAt = limit.resetsAt === undefined ? undefined : Date.parse(limit.resetsAt)
277 const recent = (samples[limit.kind] ?? []).filter(sample => now - sample.at <= 3_600_000)
278 const first = recent[0]
279 const last = recent[recent.length - 1]
280 let rate = 0
281 let basis: LimitForecast['basis'] = 'window'
282 if (first !== undefined && last !== undefined && last.at - first.at >= 5 * 60_000 && last.percent > first.percent) {
283 rate = ((last.percent - first.percent) / (last.at - first.at)) * 3_600_000
284 basis = 'recent'
285 } else {
286 const length = LIMIT_WINDOW_MS[limit.kind]
287 if (length !== undefined && resetAt !== undefined && !Number.isNaN(resetAt)) {
288 const elapsed = length - (resetAt - now)
289 if (elapsed > 60_000) rate = (limit.percentUsed / elapsed) * 3_600_000
290 }
291 }
292 const fullAt = rate > 0 ? now + ((100 - limit.percentUsed) / rate) * 3_600_000 : undefined
293 const isShort = fullAt !== undefined && resetAt !== undefined && !Number.isNaN(resetAt) && fullAt < resetAt
294
295 return { kind: limit.kind, percent: limit.percentUsed, rate, fullAt, resetAt, isShort, basis }
296 })
297
298export type TimeSplit = { model: number; tools: number; idle: number; total: number }
299
300/** Session time in three parts: the model working, its tools running, and the wait for the person. */
301export const timeSplit = (timing: Timing, startedAt: number, now: number): TimeSplit => {
302 const total = startedAt > 0 ? Math.max(now - startedAt, timing.turnMs) : timing.turnMs
303 const tools = Math.min(timing.toolMs, timing.turnMs)
304
305 return { model: Math.max(0, timing.turnMs - tools), tools, idle: Math.max(0, total - timing.turnMs), total }
306}
307
308/** The local calendar day of a moment, as `YYYY-MM-DD`. */
309export const dayKey = (time: number): string => {
310 const date = new Date(time)
311 const two = (n: number) => String(n).padStart(2, '0')
312
313 return `${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())}`
314}
315
316const ZERO_DAY: DayStat = { usd: 0, tokensIn: 0, tokensOut: 0, turns: 0 }
317
318/** The last `count` days ending today, oldest first, a zero day where nothing was recorded. */
319export const recentDays = (days: Record<string, DayStat>, now: number, count: number): { key: string; day: DayStat }[] =>
320 Array.from({ length: count }, (_, i) => {
321 const key = dayKey(now - (count - 1 - i) * 86_400_000)
322
323 return { key, day: days[key] ?? ZERO_DAY }
324 })
325
326/**
327 * One session's share of one day. `base` is what the session had cost when it
328 * was first seen that day: 0 for a session started that day, so cost spent
329 * before the mod loaded still counts; its running total at midnight otherwise.
330 */
331export type SessionDay = DayStat & { base: number }
332
333/** Every session's share of every day, keyed by day then session id: the shared file's shape. */
334export type DayBook = Record<string, Record<string, SessionDay>>
335
336const isDayStat = (value: unknown): value is SessionDay =>
337 typeof value === 'object' &&
338 value !== null &&
339 ['usd', 'tokensIn', 'tokensOut', 'turns', 'base'].every(key => typeof (value as Record<string, unknown>)[key] === 'number')
340
341/** Reads the shared file's text, dropping anything malformed rather than trusting it. */
342export const parseDayBook = (text: string | undefined): DayBook => {
343 try {
344 const raw: unknown = JSON.parse(text ?? '{}')
345 if (typeof raw !== 'object' || raw === null) return {}
346 const book: DayBook = {}
347 for (const [day, sessions] of Object.entries(raw)) {
348 if (!/^\d{4}-\d{2}-\d{2}$/.test(day) || typeof sessions !== 'object' || sessions === null) continue
349 const kept = Object.entries(sessions).filter(([, stat]) => isDayStat(stat))
350 if (kept.length > 0) book[day] = Object.fromEntries(kept) as Record<string, SessionDay>
351 }
352
353 return book
354 } catch {
355 return {}
356 }
357}
358
359export type SessionChange = {
360 /** The session's running cost now, when known: its day's usd is set from it. */
361 usd?: number
362 /** Whether the session started on this day, so its whole cost belongs to it. */
363 isStartDay: boolean
364 tokensIn?: number
365 tokensOut?: number
366 turns?: number
367}
368
369/** Updates one session's share of a day and keeps the newest 31 days. */
370export const touchDay = (book: DayBook, day: string, session: string, change: SessionChange): DayBook => {
371 const sessions = book[day] ?? {}
372 const was = sessions[session]
373 const base = was?.base ?? (change.isStartDay || change.usd === undefined ? 0 : change.usd)
374 const now: SessionDay = {
375 base,
376 usd: change.usd === undefined ? (was?.usd ?? 0) : Math.max(0, change.usd - base),
377 tokensIn: (was?.tokensIn ?? 0) + (change.tokensIn ?? 0),
378 tokensOut: (was?.tokensOut ?? 0) + (change.tokensOut ?? 0),
379 turns: (was?.turns ?? 0) + (change.turns ?? 0),
380 }
381 const next = { ...book, [day]: { ...sessions, [session]: now } }
382
383 return Object.fromEntries(Object.entries(next).sort(([a], [b]) => (a < b ? -1 : 1)).slice(-31))
384}
385
386/** Adds every session's share up, day by day. */
387export const sumDays = (book: DayBook): Record<string, DayStat> =>
388 Object.fromEntries(
389 Object.entries(book).map(([day, sessions]) => [
390 day,
391 Object.values(sessions).reduce(
392 (sum, one) => ({
393 usd: sum.usd + one.usd,
394 tokensIn: sum.tokensIn + one.tokensIn,
395 tokensOut: sum.tokensOut + one.tokensOut,
396 turns: sum.turns + one.turns,
397 }),
398 ZERO_DAY,
399 ),
400 ]),
401 )
402
403/** A path as the person thinks of it: relative to the session's folder when inside it. */
404export const shortPath = (path: string, cwd: string): string =>
405 cwd !== '' && path.startsWith(`${cwd}/`) ? path.slice(cwd.length + 1) : path.replace(/^\/home\/[^/]+/, '~')
406
407export type FileRow = { path: string } & FileStat
408
409export const hotFiles = (files: Record<string, FileStat>): FileRow[] =>
410 Object.entries(files)
411 .map(([path, stat]) => ({ path, ...stat }))
412 .sort((a, b) => b.reads + b.edits * 2 - (a.reads + a.edits * 2) || b.lastAt - a.lastAt)
413
414export type CommandRow = { name: string } & CommandStat
415
416export const busyCommands = (commands: Record<string, CommandStat>): CommandRow[] =>
417 Object.entries(commands)
418 .map(([name, stat]) => ({ name, ...stat }))
419 .sort((a, b) => b.totalMs - a.totalMs || b.calls - a.calls)
420hooks/tui.tsx 850 lines1import type { ClientModule, ClientPointerEvent, JsonValue } from 'claude-code'
2
3import type { PanelId, SortKey } from '../types'
4import { brailleGraph, cacheHit, compact, duration, limitLabel, resetsIn, spark } from './format'
5import type { Action, Snapshot } from './model'
6import {
7 busyCommands,
8 dayKey,
9 deltas,
10 elapsedShare,
11 forecastLimits,
12 hotFiles,
13 LEFT_PANELS,
14 pace,
15 PANELS,
16 panelNumber,
17 perHour,
18 recentDays,
19 shortModel,
20 shortPath,
21 sortedTools,
22 SORTS,
23 timeSplit,
24} from './model'
25
26// The terminal's drawing of cctop: btop's boxes, braille graphs, the mouse and keys.
27
28const C = {
29 frame: '#5c6370',
30 frameHot: '#abb2bf',
31 accent: '#d97757',
32 low: '#98c379',
33 mid: '#e5c07b',
34 high: '#e06c75',
35 blue: '#61afef',
36 violet: '#c678dd',
37 cyan: '#56b6c2',
38}
39
40const SUPERSCRIPT = ['⁰', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹']
41const SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
42const WIDE = 100
43const MAX_TOOLS = 12
44const MAX_AGENTS = 8
45
46type Seg = { t: string; c?: string; b?: boolean; d?: boolean; hit?: string }
47type Line = Seg[]
48type Local = { hover?: string; menu: boolean; tick: number }
49type Hit = { id: string; y: number; from: number; to: number }
50
51const heat = (percent: number): string => (percent >= 85 ? C.high : percent >= 60 ? C.mid : C.low)
52
53const width = (line: Line): number => line.reduce((sum, seg) => sum + [...seg.t].length, 0)
54
55/** Cuts or pads a line to exactly `size` cells, an ellipsis where it was cut. */
56const fit = (line: Line, size: number): Line => {
57 const out: Line = []
58 let left = size
59 for (const seg of line) {
60 if (left <= 0) break
61 const chars = [...seg.t]
62 if (chars.length <= left) {
63 out.push(seg)
64 left -= chars.length
65 } else {
66 out.push({ ...seg, t: `${chars.slice(0, Math.max(0, left - 1)).join('')}…` })
67 left = 0
68 }
69 }
70 if (left > 0) out.push({ t: ' '.repeat(left) })
71
72 return out
73}
74
75const pad = (text: string, size: number, align: 'left' | 'right' = 'left'): string => {
76 const chars = [...text]
77 if (chars.length > size) return `${chars.slice(0, Math.max(0, size - 1)).join('')}…`
78
79 return align === 'left' ? text.padEnd(size) : text.padStart(size)
80}
81
82/**
83 * Packs segments into rows of at most `size` cells, breaking only between
84 * segments; a leading space is dropped where a row breaks.
85 */
86const wrapLine = (line: Line, size: number): Line[] => {
87 const rows: Line[] = [[]]
88 let used = 0
89 for (const seg of line) {
90 const span = [...seg.t].length
91 if (used > 0 && used + span > size) {
92 rows.push([])
93 used = 0
94 const trimmed = seg.t.replace(/^ +/, '')
95 if (trimmed.length === 0) continue
96 rows[rows.length - 1]?.push({ ...seg, t: trimmed })
97 used = [...trimmed].length
98 continue
99 }
100 rows[rows.length - 1]?.push(seg)
101 used += span
102 }
103
104 return rows.map(row => fit(row, size))
105}
106
107/** Packs whole items into rows of at most `size` cells, `gap` cells apart; an item never splits. */
108const wrapItems = (items: readonly Line[], size: number, gap = 2): Line[] => {
109 const rows: Line[] = []
110 let row: Line = []
111 for (const item of items) {
112 const needed = width(row) === 0 ? width(item) : width(row) + gap + width(item)
113 if (width(row) > 0 && needed > size) {
114 rows.push(fit(row, size))
115 row = []
116 }
117 row = width(row) === 0 ? [...item] : [...row, { t: ' '.repeat(gap) }, ...item]
118 }
119 if (width(row) > 0) rows.push(fit(row, size))
120
121 return rows
122}
123
124/** A gauge of `size` cells: the used share in its heat colour, an optional tick where an even spend would be. */
125const gauge = (share: number, size: number, color: string, marker?: number): Line => {
126 const filled = Math.round(Math.min(1, Math.max(0, share)) * size)
127 const at = marker === undefined ? -1 : Math.min(size - 1, Math.max(0, Math.round(marker * size)))
128 const line: Line = []
129 for (let i = 0; i < size; i += 1) {
130 const seg: Seg = i === at ? { t: '│', c: '#dcdfe4', b: true } : i < filled ? { t: '■', c: color } : { t: '■', c: '#3e4451' }
131 const last = line[line.length - 1]
132 if (last !== undefined && last.c === seg.c && last.b === seg.b) last.t += seg.t
133 else line.push(seg)
134 }
135
136 return line
137}
138
139type Panel = {
140 id: PanelId
141 /** The superscript in the title; `layout` sets it from the panel's place in PANELS. */
142 num: number
143 right: Line
144 summary: Line
145 body: Line[]
146 footer?: Line
147}
148
149const frame = (panel: Panel, size: number, local: Local, collapsed: boolean): Line[] => {
150 const isHot = local.hover !== undefined && local.hover.endsWith(`:${panel.id}`)
151 const edge = isHot ? C.frameHot : C.frame
152 const hit = `collapse:${panel.id}`
153 const title: Line = [
154 { t: sup(panel.num), c: C.accent },
155 { t: panel.id, b: true, hit },
156 { t: collapsed ? ' ▸' : ' ▾', d: true, hit },
157 ]
158 let right = collapsed ? panel.summary : panel.right
159 if (size - 4 - width(title) - (width(right) + 2) < 1) right = []
160 const rightPart: Line = right.length > 0 ? [{ t: ' ' }, ...right, { t: ' ' }] : []
161 const fill = Math.max(0, size - 4 - width(title) - width(rightPart))
162 const [open, close] = collapsed ? ['╶─', '─╴'] : ['╭─', '─╮']
163 const top: Line = fit([{ t: open, c: edge }, ...title, { t: '─'.repeat(fill), c: edge }, ...rightPart, { t: close, c: edge }], size)
164 if (collapsed) return [top]
165
166 const body = panel.body.map(line => [{ t: '│ ', c: edge }, ...fit(line, size - 4), { t: ' │', c: edge }])
167 const footer = panel.footer ?? []
168 const bottom: Line = fit(
169 [{ t: '╰─', c: edge }, ...footer, { t: '─'.repeat(Math.max(0, size - 4 - width(footer))), c: edge }, { t: '─╯', c: edge }],
170 size,
171 )
172
173 return [top, ...body, bottom]
174}
175
176const contextPanel = (p: Snapshot, inner: number, graphRows: number): Panel => {
177 const usage = p.usage
178 const percent = usage?.percent
179 const amount = usage === null ? '' : `${compact(usage.tokens ?? 0)}/${compact(usage.window)}`
180 if (percent === undefined) {
181 return { id: 'context', num: 0, right: [], summary: [{ t: 'waiting', d: true }], body: [[{ t: 'waiting for the first response', d: true }]] }
182 }
183 // The graph grows a row per four turns up to its full height, so a fresh session has no empty block.
184 const rows = Math.min(graphRows, 1 + Math.floor(p.history.length / 4))
185 const graph = p.history.length < 2 ? [] : brailleGraph(p.history, inner, rows)
186 const body: Line[] = graph.map((row, i) => [{ t: row, c: heat(((rows - i) / rows) * 100) }])
187 const label = pad(`${Math.round(percent)}%`, 5, 'right')
188 body.push([...gauge(percent / 100, inner - 6, heat(percent)), { t: label, b: true }])
189
190 return {
191 id: 'context',
192 num: 0,
193 right: [{ t: amount, d: true }],
194 summary: [{ t: `${Math.round(percent)}%`, c: heat(percent), b: true }, { t: ` ${amount}`, d: true }],
195 body,
196 }
197}
198
199const limitsPanel = (p: Snapshot, inner: number, now: number): Panel => {
200 const limits = p.usage?.rateLimits ?? []
201 if (limits.length === 0) {
202 return { id: 'limits', num: 0, right: [], summary: [{ t: 'no reading', d: true }], body: [[{ t: 'no reading yet', d: true }]] }
203 }
204 const body: Line[] = limits.map(limit => {
205 const ahead = pace(limit.kind, limit.percentUsed, limit.resetsAt, now)
206 const paceText = ahead === undefined ? '' : Math.abs(ahead) < 1 ? '=' : ahead > 0 ? `▲${Math.round(ahead)}` : `▼${Math.round(-ahead)}`
207 const percentSeg: Seg = { t: pad(`${Math.round(limit.percentUsed)}%`, 5, 'right'), b: true }
208 const resetSeg: Seg = { t: pad(`↻${resetsIn(limit.resetsAt, now)}`, 8, 'right'), d: true }
209 const paceSeg: Seg = { t: pad(paceText, 5, 'right'), c: ahead === undefined || ahead <= 0 ? C.low : ahead > 10 ? C.high : C.mid }
210 // The gauge keeps 8 cells at least: pace goes first, then the reset time.
211 const tails: Line[] = [[percentSeg, resetSeg, paceSeg], [percentSeg, resetSeg], [percentSeg]]
212 const tail = tails.find(one => inner - 4 - width(one) >= 8) ?? [percentSeg]
213 const size = Math.max(4, inner - 4 - width(tail))
214
215 return [
216 { t: pad(limitLabel(limit.kind), 4), c: C.cyan, b: true },
217 ...gauge(limit.percentUsed / 100, size, heat(limit.percentUsed), elapsedShare(limit.kind, limit.resetsAt, now)),
218 ...tail,
219 ]
220 })
221 const summary: Line = limits.flatMap((limit, i) => [
222 { t: `${i === 0 ? '' : ' '}${limitLabel(limit.kind)} `, d: true },
223 { t: `${Math.round(limit.percentUsed)}%`, c: heat(limit.percentUsed), b: true },
224 ])
225
226 return { id: 'limits', num: 0, right: [{ t: '│ = even pace', d: true }], summary, body }
227}
228
229const costPanel = (p: Snapshot, inner: number, now: number): Panel => {
230 const usd = p.usage?.costUsd
231 const rate = perHour(usd, p.startedAt, now)
232 const hit = cacheHit(p.tokens)
233 const body: Line[] = [
234 [
235 { t: usd === undefined ? '$-' : `$${usd.toFixed(2)}`, c: C.accent, b: true },
236 { t: rate === undefined ? '' : ` $${rate.toFixed(2)}/h`, d: true },
237 { t: ` ${p.tokens.turns} ${p.tokens.turns === 1 ? 'turn' : 'turns'}`, d: true },
238 ],
239 ]
240 // One bar a turn from the left edge, the newest on the right; the scale is the dearest turn shown.
241 const spent = deltas(p.costs)
242 if (spent.length > 0) {
243 const size = Math.max(4, inner - 20)
244 const shown = spent.slice(-size)
245 const top = Math.max(...shown, 1e-9)
246 body.push([
247 { t: 'per turn ', d: true },
248 { t: spark(shown, size, top).padEnd(size), c: C.violet },
249 { t: pad(`max $${top.toFixed(2)}`, 11, 'right'), d: true },
250 ])
251 }
252 body.push([
253 { t: 'in ', d: true },
254 { t: compact(p.tokens.input + p.tokens.cacheRead + p.tokens.cacheWrite), b: true },
255 { t: ' out ', d: true },
256 { t: compact(p.tokens.output), b: true },
257 ])
258 body.push(
259 hit === undefined
260 ? [{ t: 'cache ', d: true }, { t: '-', d: true }]
261 : [
262 { t: 'cache ', d: true },
263 ...gauge(hit, Math.max(4, inner - 11), hit >= 0.8 ? C.low : hit >= 0.5 ? C.mid : C.high),
264 { t: pad(`${Math.round(hit * 100)}%`, 5, 'right'), b: true },
265 ],
266 )
267
268 return {
269 id: 'cost',
270 num: 0,
271 right: rate === undefined ? [] : [{ t: `$${rate.toFixed(2)}/h`, d: true }],
272 summary: [
273 { t: usd === undefined ? '$-' : `$${usd.toFixed(2)}`, c: C.accent, b: true },
274 { t: hit === undefined ? '' : ` cache ${Math.round(hit * 100)}%`, d: true },
275 ],
276 body,
277 }
278}
279
280type Column = { key: (typeof SORTS)[number]; label: string; size: number; value: (row: ReturnType<typeof sortedTools>[number]) => string }
281
282// Every duration `duration` prints is 5 cells at most, so 6 holds it with a gap.
283const COLUMNS: Column[] = [
284 { key: 'calls', label: 'calls', size: 7, value: row => String(row.calls) },
285 { key: 'errors', label: 'err', size: 4, value: row => String(row.errors + row.denied) },
286 { key: 'avg', label: 'avg', size: 6, value: row => duration(row.totalMs / Math.max(1, row.calls)) },
287 { key: 'time', label: 'total', size: 7, value: row => duration(row.totalMs) },
288 { key: 'max', label: 'max', size: 6, value: row => duration(row.maxMs) },
289]
290
291const COLUMNS_SIZE = COLUMNS.reduce((sum, column) => sum + column.size, 0)
292
293// Narrower than this, a tool takes two rows instead of losing columns.
294const MIN_NAME = 8
295
296const toolsPanel = (p: Snapshot, inner: number, local: Local): Panel => {
297 const rows = sortedTools(p.tools, p.prefs.sort, p.prefs.reverse)
298 const calls = rows.reduce((sum, row) => sum + row.calls, 0)
299 const errors = rows.reduce((sum, row) => sum + row.errors + row.denied, 0)
300 const right: Line = [{ t: `${calls} calls`, d: true }]
301 const summary: Line = [
302 { t: `${calls} calls`, b: true },
303 { t: ` ${errors} err`, c: errors > 0 ? C.high : undefined, d: errors === 0 },
304 ]
305 if (rows.length === 0) {
306 return { id: 'tools', num: 0, right, summary, body: [[{ t: 'no tool calls yet', d: true }]] }
307 }
308
309 const arrow = (key: string) => (p.prefs.sort === key ? (p.prefs.reverse ? '▲' : '▼') : '')
310 const header = (key: string, label: string, size: number, align: 'left' | 'right'): Seg => {
311 const id = `sort:${key}`
312 const isOn = p.prefs.sort === key
313
314 return { t: pad(`${label}${arrow(key)}`, size, align), hit: id, c: isOn ? C.accent : undefined, d: !isOn && local.hover !== id, b: isOn }
315 }
316 const body: Line[] = []
317 const shown = rows.slice(0, MAX_TOOLS)
318
319 if (inner - COLUMNS_SIZE >= MIN_NAME) {
320 const nameSize = inner - COLUMNS_SIZE
321 body.push([header('name', 'tool', nameSize, 'left'), ...COLUMNS.map(column => header(column.key, column.label, column.size, 'right'))])
322 for (const row of shown) {
323 const failed = row.errors + row.denied
324 body.push([
325 { t: pad(row.name, nameSize), c: C.blue },
326 ...COLUMNS.map(column => ({
327 t: pad(column.value(row), column.size, 'right'),
328 c: column.key === 'errors' && failed > 0 ? C.high : undefined,
329 d: column.key === 'errors' && failed === 0,
330 })),
331 ])
332 }
333 } else {
334 // Too narrow for a table: the sort keys wrap as a row of their own, each tool takes two rows.
335 const keys: Seg[] = [
336 { t: 'sort ', d: true },
337 header('name', 'name', 4 + arrow('name').length, 'left'),
338 ...COLUMNS.flatMap(column => [{ t: ' ' }, header(column.key, column.label, column.label.length + arrow(column.key).length, 'left')]),
339 ]
340 body.push(...wrapLine(keys, inner))
341 for (const row of shown) {
342 const failed = row.errors + row.denied
343 const count = `${row.calls}×`
344 body.push([
345 { t: pad(row.name, Math.max(1, inner - count.length - 1)), c: C.blue },
346 { t: ` ${count}`, b: true },
347 ])
348 body.push([
349 { t: ' ' },
350 ...(failed > 0 ? [{ t: `err ${failed} `, c: C.high }] : []),
351 { t: 'avg ', d: true },
352 { t: duration(row.totalMs / Math.max(1, row.calls)) },
353 { t: ' Σ ', d: true },
354 { t: duration(row.totalMs) },
355 { t: ' max ', d: true },
356 { t: duration(row.maxMs) },
357 ])
358 }
359 }
360
361 return {
362 id: 'tools',
363 num: 0,
364 right,
365 summary,
366 body,
367 footer: rows.length > MAX_TOOLS ? [{ t: ` +${rows.length - MAX_TOOLS} more `, d: true }] : undefined,
368 }
369}
370
371const agentsPanel = (p: Snapshot, inner: number, now: number, tick: number): Panel => {
372 const running = p.agents.filter(agent => agent.status === 'running').length
373 const done = p.agents.length - running
374 const right: Line = [
375 { t: `${running} running`, c: running > 0 ? C.mid : undefined, d: running === 0 },
376 { t: ` · ${done} done`, d: true },
377 ]
378 if (p.agents.length === 0) {
379 return { id: 'agents', num: 0, right: [], summary: [{ t: 'none', d: true }], body: [[{ t: 'no subagents yet', d: true }]] }
380 }
381 const shown = [...p.agents]
382 .sort((a, b) => Number(b.status === 'running') - Number(a.status === 'running') || b.startedAt - a.startedAt)
383 .slice(0, MAX_AGENTS)
384 const glyph = { running: SPINNER[tick % SPINNER.length] ?? '•', completed: '●', failed: '✕', killed: '○' }
385 const color = { running: C.mid, completed: C.low, failed: C.high, killed: C.frame }
386 // Narrow panes lose the model column first; the description takes what is left.
387 const typeSize = Math.max(6, Math.min(16, inner - 2 - 7 - (inner >= 44 ? 10 : 0)))
388 const body: Line[] = shown.map(agent => [
389 { t: `${glyph[agent.status]} `, c: color[agent.status] },
390 { t: pad(agent.type, typeSize), b: true },
391 ...(inner >= 44 ? [{ t: pad(shortModel(agent.model), 10), c: C.cyan }] : []),
392 {
393 t: pad(duration(agent.status === 'running' ? now - agent.startedAt : (agent.durationMs ?? 0)), 7, 'right'),
394 d: true,
395 },
396 { t: ` ${agent.description}`, d: true },
397 ])
398
399 return { id: 'agents', num: 0, right, summary: right, body: inner > 0 ? body : [] }
400}
401
402const sup = (n: number): string => SUPERSCRIPT[n % 10] ?? ''
403
404const WEEKDAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
405
406const hhmm = (time: number, now: number): string => {
407 const date = new Date(time)
408 const at = `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
409
410 return time - now < 20 * 3_600_000 ? at : `${WEEKDAYS[date.getDay()] ?? ''} ${at}`
411}
412
413/** Keeps the end of a path, where its name is, when it does not fit. */
414const tail = (text: string, size: number): string => {
415 const chars = [...text]
416
417 return chars.length <= size ? text.padEnd(size) : `…${chars.slice(chars.length - size + 1).join('')}`
418}
419
420// Forecast columns after the window's name: what the table shows once there is room.
421const FORECAST_COLUMNS = [
422 { label: 'used', size: 6 },
423 { label: 'rate/h', size: 8 },
424 { label: 'full at', size: 10 },
425 { label: 'reset', size: 10 },
426]
427const FORECAST_SIZE = 4 + FORECAST_COLUMNS.reduce((sum, column) => sum + column.size, 0)
428const STATUS_SIZE = 14
429
430const forecastPanel = (p: Snapshot, inner: number, now: number): Panel => {
431 const rows = forecastLimits(p.usage, p.samples, now)
432 if (rows.length === 0) {
433 return { id: 'forecast', num: 0, right: [], summary: [{ t: 'no reading', d: true }], body: [[{ t: 'no rate-limit reading yet', d: true }]] }
434 }
435 // `~` marks a rate taken from the window's average, when the last hour did not move.
436 const rateOf = (row: (typeof rows)[number]) => (row.rate > 0 ? `${row.basis === 'window' ? '~' : ''}${row.rate.toFixed(1)}%` : 'flat')
437 const status = (row: (typeof rows)[number]): Seg =>
438 row.fullAt === undefined
439 ? { t: '· flat', d: true }
440 : row.isShort && row.resetAt !== undefined
441 ? { t: `✕ ${duration(row.resetAt - row.fullAt)} early`, c: C.high, b: true }
442 : { t: '✓ lasts', c: C.low }
443 const body: Line[] = []
444
445 if (inner >= FORECAST_SIZE + STATUS_SIZE) {
446 body.push([
447 { t: pad('', 4) },
448 ...FORECAST_COLUMNS.map(column => ({ t: pad(column.label, column.size, 'right'), d: true })),
449 { t: pad('', 2) },
450 { t: 'status', d: true },
451 ])
452 for (const row of rows) {
453 body.push([
454 { t: pad(limitLabel(row.kind), 4), c: C.cyan, b: true },
455 { t: pad(`${Math.round(row.percent)}%`, 6, 'right'), c: heat(row.percent), b: true },
456 { t: pad(rateOf(row), 8, 'right') },
457 { t: pad(row.fullAt === undefined ? '-' : hhmm(row.fullAt, now), 10, 'right'), b: row.isShort },
458 { t: pad(row.resetAt === undefined ? '-' : hhmm(row.resetAt, now), 10, 'right'), d: true },
459 { t: pad('', 2) },
460 status(row),
461 ])
462 }
463 } else {
464 // Narrow: a window takes two rows, as a tool does in the tools panel.
465 for (const row of rows) {
466 const state = status(row)
467 body.push([
468 { t: pad(limitLabel(row.kind), 4), c: C.cyan, b: true },
469 { t: `${Math.round(row.percent)}% `, c: heat(row.percent), b: true },
470 { t: pad(`${rateOf(row)}/h`, Math.max(1, inner - 4 - `${Math.round(row.percent)}% `.length - [...state.t].length)) },
471 state,
472 ])
473 body.push([
474 { t: ' full ', d: true },
475 { t: row.fullAt === undefined ? '-' : hhmm(row.fullAt, now), b: row.isShort },
476 { t: ' reset ', d: true },
477 { t: row.resetAt === undefined ? '-' : hhmm(row.resetAt, now) },
478 ])
479 }
480 }
481 const worst = rows.find(row => row.isShort)
482
483 return {
484 id: 'forecast',
485 num: 0,
486 right: [{ t: '~ window avg', d: true }],
487 summary:
488 worst === undefined
489 ? [{ t: '✓ lasts', c: C.low }]
490 : [{ t: `✕ ${limitLabel(worst.kind)} full ${worst.fullAt === undefined ? '' : hhmm(worst.fullAt, now)}`, c: C.high }],
491 body,
492 }
493}
494
495const timePanel = (p: Snapshot, inner: number, now: number): Panel => {
496 const split = timeSplit(p.timing, p.startedAt, now)
497 if (split.total <= 0) {
498 return { id: 'time', num: 0, right: [], summary: [{ t: 'no turns', d: true }], body: [[{ t: 'no finished turn yet', d: true }]] }
499 }
500 const parts = [
501 { label: 'model', ms: split.model, color: C.violet },
502 { label: 'tools', ms: split.tools, color: C.blue },
503 { label: 'you', ms: split.idle, color: C.frame },
504 ]
505 const cells = parts.map(part => Math.round((part.ms / split.total) * inner))
506 // Rounding may leave the bar a cell short or long; the waiting part absorbs it.
507 cells[2] = Math.max(0, inner - (cells[0] ?? 0) - (cells[1] ?? 0))
508 const share = (ms: number) => `${Math.round((ms / split.total) * 100)}%`
509 const avg = p.timing.turns > 0 ? p.timing.turnMs / p.timing.turns : 0
510
511 return {
512 id: 'time',
513 num: 0,
514 right: [{ t: `avg turn ${duration(avg)}`, d: true }],
515 summary: [
516 { t: `model ${share(split.model)}`, c: C.violet },
517 { t: ` tools ${share(split.tools)}`, c: C.blue },
518 ],
519 body: [
520 parts.map((part, i) => ({ t: '■'.repeat(cells[i] ?? 0), c: part.color })),
521 ...wrapItems(
522 parts.map(part => [
523 { t: '■ ', c: part.color },
524 { t: `${part.label} `, d: true },
525 { t: `${share(part.ms)} ${duration(part.ms)}`, b: true },
526 ]),
527 inner,
528 ),
529 ],
530 }
531}
532
533const filesPanel = (p: Snapshot, inner: number): Panel => {
534 const rows = hotFiles(p.files)
535 const right: Line = [{ t: `${rows.length} files`, d: true }]
536 if (rows.length === 0) {
537 return { id: 'files', num: 0, right: [], summary: [{ t: 'none', d: true }], body: [[{ t: 'no file read or changed yet', d: true }]] }
538 }
539 const pathSize = Math.max(4, inner - 11)
540 const body: Line[] = [[{ t: pad('read', 4, 'right'), d: true }, { t: pad('edit', 5, 'right'), d: true }, { t: ' path', d: true }]]
541 for (const row of rows.slice(0, 10)) {
542 body.push([
543 { t: pad(row.reads === 0 ? '·' : String(row.reads), 4, 'right'), c: C.blue },
544 { t: pad(row.edits === 0 ? '·' : String(row.edits), 5, 'right'), c: C.accent, b: row.edits > 0 },
545 { t: ' ' },
546 { t: tail(shortPath(row.path, p.cwd), pathSize) },
547 ])
548 }
549 const top = rows[0]
550
551 return {
552 id: 'files',
553 num: 0,
554 right,
555 summary: top === undefined ? [] : [{ t: shortPath(top.path, p.cwd).split('/').pop() ?? '', c: C.blue }],
556 body,
557 footer: rows.length > 10 ? [{ t: ` +${rows.length - 10} more `, d: true }] : undefined,
558 }
559}
560
561const bashPanel = (p: Snapshot, inner: number): Panel => {
562 const rows = busyCommands(p.commands)
563 const calls = rows.reduce((sum, row) => sum + row.calls, 0)
564 if (rows.length === 0) {
565 return { id: 'bash', num: 0, right: [], summary: [{ t: 'none', d: true }], body: [[{ t: 'no shell command yet', d: true }]] }
566 }
567 const nameSize = Math.max(6, inner - 17)
568 const body: Line[] = [
569 [{ t: pad('command', nameSize), d: true }, { t: pad('runs', 6, 'right'), d: true }, { t: pad('err', 4, 'right'), d: true }, { t: pad('time', 7, 'right'), d: true }],
570 ]
571 for (const row of rows.slice(0, 10)) {
572 body.push([
573 { t: pad(row.name, nameSize), c: C.cyan },
574 { t: pad(String(row.calls), 6, 'right') },
575 { t: pad(String(row.errors), 4, 'right'), c: row.errors > 0 ? C.high : undefined, d: row.errors === 0 },
576 { t: pad(duration(row.totalMs), 7, 'right'), d: true },
577 ])
578 }
579
580 return {
581 id: 'bash',
582 num: 0,
583 right: [{ t: `${calls} runs`, d: true }],
584 summary: [{ t: rows[0]?.name ?? '', c: C.cyan }],
585 body,
586 footer: rows.length > 10 ? [{ t: ` +${rows.length - 10} more `, d: true }] : undefined,
587 }
588}
589
590const weekPanel = (p: Snapshot, inner: number, now: number): Panel => {
591 const list = recentDays(p.days, now, 7)
592 const total = list.reduce((sum, { day }) => sum + day.usd, 0)
593 const byCost = list.some(({ day }) => day.usd > 0)
594 const value = (day: (typeof list)[number]['day']) => (byCost ? day.usd : day.tokensIn + day.tokensOut)
595 const top = Math.max(1e-9, ...list.map(({ day }) => value(day)))
596 const showTokens = inner >= 36
597 const barSize = Math.max(4, inner - 6 - 8 - (showTokens ? 7 : 0))
598 const today = dayKey(now)
599 const body: Line[] = list.map(({ key, day }) => {
600 const isToday = key === today
601 const filled = Math.round((value(day) / top) * barSize)
602
603 return [
604 { t: `${WEEKDAYS[new Date(`${key}T12:00:00`).getDay()] ?? ''} ${key.slice(8)} `, c: isToday ? C.accent : undefined, b: isToday, d: !isToday },
605 { t: '■'.repeat(filled), c: isToday ? C.accent : C.violet },
606 { t: '■'.repeat(barSize - filled), c: '#3e4451' },
607 { t: pad(day.usd > 0 ? `$${day.usd.toFixed(2)}` : '-', 8, 'right'), b: isToday },
608 ...(showTokens ? [{ t: pad(day.tokensIn + day.tokensOut > 0 ? compact(day.tokensIn + day.tokensOut) : '', 7, 'right'), d: true }] : []),
609 ]
610 })
611
612 return {
613 id: 'week',
614 num: 0,
615 right: [{ t: `7d $${total.toFixed(2)}`, d: true }],
616 summary: [{ t: `7d $${total.toFixed(2)}`, c: C.accent }],
617 body,
618 }
619}
620
621const menuPanel = (p: Snapshot, local: Local, inner: number): Line[] => {
622 const item = (panel: (typeof PANELS)[number], size: number): Line => {
623 const id = `visible:${panel.id}`
624 const isOn = !p.prefs.hidden.includes(panel.id)
625 const isHot = local.hover === id
626
627 return fit(
628 [
629 { t: isOn ? '[x] ' : '[ ] ', c: isOn ? C.low : C.frame, hit: id },
630 { t: sup(panelNumber(panel.id)), c: C.accent, hit: id },
631 { t: pad(panel.title, 9), b: isHot, hit: id },
632 { t: panel.about, d: !isHot, hit: id },
633 ],
634 size,
635 )
636 }
637 const defaults = PANELS.filter(panel => panel.isDefault)
638 const optional = PANELS.filter(panel => !panel.isDefault)
639 const rows: Line[] = []
640
641 // Two columns, read top to bottom: the default set on the left, the optional panels on the right.
642 if (inner >= 64) {
643 const half = Math.floor(inner / 2)
644 rows.push([{ t: pad('default', half), d: true }, { t: 'optional', d: true }])
645 for (let i = 0; i < Math.max(defaults.length, optional.length); i += 1) {
646 const left = defaults[i]
647 const right = optional[i]
648 rows.push([...(left === undefined ? [{ t: ' '.repeat(half) }] : item(left, half)), ...(right === undefined ? [] : item(right, inner - half))])
649 }
650 } else {
651 rows.push([{ t: 'default', d: true }], ...defaults.map(panel => item(panel, inner)))
652 rows.push([{ t: 'optional', d: true }], ...optional.map(panel => item(panel, inner)))
653 }
654
655 const option = (id: string, label: string, value: string): Line => [
656 { t: pad(label, 14), d: true },
657 { t: `[ ${value} ]`, c: C.accent, b: local.hover === id, hit: id },
658 ]
659 rows.push([{ t: ' ' }], [{ t: 'context alert', d: true }])
660 rows.push(option('alert', ' speaks as', p.prefs.alert))
661 rows.push(option('threshold', ' past', `${p.prefs.threshold}%`))
662 rows.push([{ t: ' ' }], [{ t: 'reset layout', c: C.accent, hit: 'reset', b: local.hover === 'reset' }, { t: ' default panels, open, sorted by time', d: true }])
663
664 return rows
665}
666
667/** Lays the panels out in one column, or two from 100 cells, and returns the lines top to bottom. */
668const layout = (p: Snapshot, size: number, now: number, local: Local): Line[] => {
669 const shown = PANELS.map(panel => panel.id).filter(id => !p.prefs.hidden.includes(id))
670 const leftIds = shown.filter(id => LEFT_PANELS.includes(id))
671 const rightIds = shown.filter(id => !LEFT_PANELS.includes(id))
672 const isWide = size >= WIDE && leftIds.length > 0 && rightIds.length > 0
673 const leftSize = isWide ? Math.floor(size / 2) : size
674 const rightSize = isWide ? size - leftSize : size
675 const graphRows = Math.min(8, Math.max(2, Math.floor((p.rows - 18) / (isWide ? 1 : 3))))
676 const build = (id: PanelId, columnSize: number): Line[] => {
677 const inner = columnSize - 4
678 const panels: Record<PanelId, () => Panel> = {
679 context: () => contextPanel(p, inner, graphRows),
680 limits: () => limitsPanel(p, inner, now),
681 cost: () => costPanel(p, inner, now),
682 tools: () => toolsPanel(p, inner, local),
683 agents: () => agentsPanel(p, inner, now, local.tick),
684 forecast: () => forecastPanel(p, inner, now),
685 time: () => timePanel(p, inner, now),
686 files: () => filesPanel(p, inner),
687 bash: () => bashPanel(p, inner),
688 week: () => weekPanel(p, inner, now),
689 }
690
691 return frame({ ...panels[id](), num: panelNumber(id) }, columnSize, local, p.prefs.collapsed.includes(id))
692 }
693
694 const lines: Line[] = []
695 if (local.menu) {
696 const menu: Panel = { id: 'context', num: 0, right: [], summary: [], body: menuPanel(p, local, size - 4) }
697 const drawn = frame(menu, size, { menu: true, tick: 0 }, false)
698 drawn[0] = fit([{ t: '╭─', c: C.accent }, { t: 'menu', b: true, c: C.accent, hit: 'menu' }, { t: '─'.repeat(Math.max(0, size - 8)), c: C.accent }, { t: '─╮', c: C.accent }], size)
699 lines.push(...drawn)
700 }
701
702 if (!isWide) {
703 for (const id of shown) lines.push(...build(id, size))
704
705 return lines
706 }
707
708 const left = leftIds.flatMap(id => build(id, leftSize))
709 const right = rightIds.flatMap(id => build(id, rightSize))
710 for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
711 lines.push([...(left[i] ?? [{ t: ' '.repeat(leftSize) }]), ...(right[i] ?? [{ t: ' '.repeat(rightSize) }])])
712 }
713
714 return lines
715}
716
717const clock = (time: number): string => {
718 const date = new Date(time)
719
720 return [date.getHours(), date.getMinutes(), date.getSeconds()].map(n => String(n).padStart(2, '0')).join(':')
721}
722
723const topBar = (p: Snapshot, size: number, now: number, local: Local): Line => {
724 const left: Line = [
725 { t: ' cctop ', c: C.accent, b: true },
726 { t: shortModel(p.model), c: C.cyan },
727 { t: p.startedAt > 0 ? ` up ${duration(Math.max(0, now - p.startedAt))}` : '', d: true },
728 ]
729 const menu: Seg = { t: local.menu ? '≡ close' : '≡ menu', hit: 'menu', c: local.hover === 'menu' || local.menu ? C.accent : undefined, b: local.hover === 'menu' }
730 const right: Line = [{ t: clock(now), b: true }, { t: ' ' }, menu, { t: ' ' }]
731 // What a narrow pane drops, in order: the uptime, the model, the clock; the menu stays.
732 const lefts = [left, left.slice(0, 2), left.slice(0, 1)]
733 const rights = [right, [menu, { t: ' ' }]]
734 for (const r of rights) {
735 for (const l of lefts) {
736 if (width(l) + width(r) + 1 <= size) return fit([...l, { t: ' '.repeat(size - width(l) - width(r)) }, ...r], size)
737 }
738 }
739
740 return fit([menu], size)
741}
742
743const HINT: Line[] = [
744 [{ t: '1-0', c: C.accent }, { t: ' show/hide', d: true }],
745 [{ t: 'click title', c: C.accent }, { t: ' fold', d: true }],
746 [{ t: 'm', c: C.accent }, { t: ' menu', d: true }],
747 [{ t: 's r', c: C.accent }, { t: ' sort', d: true }],
748]
749
750// The last drawing's clickable regions and props, for the pointer and key listeners.
751let hits: Hit[] = []
752let latest: Snapshot | undefined
753let base = { props: 0, local: 0 }
754
755const hitAt = (x: number, y: number): string | undefined =>
756 hits.find(hit => hit.y === y && x >= hit.from && x < hit.to)?.id
757
758const Top: ClientModule<JsonValue, Local> = (raw, surface) => {
759 const p = raw as unknown as Snapshot
760 latest = p
761 const { Box, Text } = surface.elements
762 const local: Local = surface.state ?? { menu: false, tick: 0 }
763
764 // The hooks' clock drives the drawing; the frame clock only moves it on between their redraws.
765 if (base.props !== p.now) base = { props: p.now, local: Date.now() }
766 const now = p.now + Math.max(0, Date.now() - base.local)
767
768 const act = (action: Action) => surface.post(action as unknown as JsonValue)
769 const press = (id: string) => {
770 const current = surface.state ?? local
771 const [kind, arg] = id.split(':')
772 if (kind === 'menu') surface.setState({ ...current, menu: !current.menu })
773 else if (kind === 'reset' || kind === 'alert' || kind === 'threshold') act({ kind })
774 else if (kind === 'collapse' && arg !== undefined) act({ kind: 'collapse', panel: arg as PanelId })
775 else if (kind === 'visible' && arg !== undefined && latest !== undefined) {
776 act({ kind: 'visible', panel: arg as PanelId, on: latest.prefs.hidden.includes(arg as PanelId) })
777 } else if (kind === 'sort' && arg !== undefined) act({ kind: 'sort', key: arg as SortKey })
778 }
779
780 if (surface.state === undefined) {
781 surface.every(1000, () => {
782 const current = surface.state ?? local
783 surface.setState({ ...current, tick: current.tick + 1 })
784 })
785 surface.onPointer((event: ClientPointerEvent) => {
786 const current = surface.state ?? local
787 const id = hitAt(event.x, event.y)
788 if (event.type === 'leave') {
789 if (current.hover !== undefined) surface.setState({ ...current, hover: undefined })
790 } else if (event.type === 'move' || event.type === 'enter') {
791 if (id !== current.hover) surface.setState({ ...current, hover: id })
792 } else if (event.type === 'down' && event.button === 'left' && id !== undefined) {
793 press(id)
794 }
795 })
796 surface.onKey(event => {
797 const current = surface.state ?? local
798 // Digits 1-9 and 0 stand for the ten panels, as in btop.
799 const panel = /^[0-9]$/.test(event.key) ? PANELS[(Number(event.key) + 9) % 10] : undefined
800 if (panel !== undefined && latest !== undefined) {
801 act({ kind: 'visible', panel: panel.id, on: latest.prefs.hidden.includes(panel.id) })
802 } else if (event.key === 'm') surface.setState({ ...current, menu: !current.menu })
803 else if (event.key === 's' && latest !== undefined) {
804 const next = SORTS[(SORTS.indexOf(latest.prefs.sort) + 1) % SORTS.length] ?? 'time'
805 act({ kind: 'sort', key: next })
806 } else if (event.key === 'r' && latest !== undefined) act({ kind: 'sort', key: latest.prefs.sort })
807 })
808 surface.setState(local)
809 }
810
811 const size = Math.max(16, surface.columns > 0 ? surface.columns : p.columns)
812 const lines = [topBar(p, size, now, local), ...layout(p, size, now, local), ...wrapItems(HINT, size - 1).map(row => [{ t: ' ' }, ...row])]
813
814 hits = []
815 lines.forEach((line, y) => {
816 let x = 0
817 for (const seg of line) {
818 const span = [...seg.t].length
819 if (seg.hit !== undefined) {
820 const last = hits[hits.length - 1]
821 if (last !== undefined && last.id === seg.hit && last.y === y && last.to === x) last.to = x + span
822 else hits.push({ id: seg.hit, y, from: x, to: x + span })
823 }
824 x += span
825 }
826 })
827
828 return (
829 <Box flexDirection="column">
830 {lines.map(line => (
831 <Box height={1} overflow="hidden">
832 {line.map(seg => {
833 const isHover = seg.hit !== undefined && seg.hit === local.hover
834 const style = {
835 ...(seg.c === undefined ? {} : { color: seg.c }),
836 ...(seg.b === true ? { bold: true } : {}),
837 ...(seg.d === true ? { dimColor: true } : {}),
838 ...(isHover ? { inverse: true } : {}),
839 }
840
841 return <Text {...style} wrap="truncate-end">{seg.t}</Text>
842 })}
843 </Box>
844 ))}
845 </Box>
846 )
847}
848
849export default Top
850types/index.d.ts 105 lines1export type RateLimitRow = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type UsageSnap = {
4 tokens?: number
5 window: number
6 percent?: number
7 rateLimits: RateLimitRow[]
8 costUsd?: number
9}
10
11export type Tokens = {
12 input: number
13 output: number
14 cacheRead: number
15 cacheWrite: number
16 turns: number
17}
18
19export type ToolStat = {
20 calls: number
21 errors: number
22 denied: number
23 totalMs: number
24 maxMs: number
25}
26
27export type AgentState = 'running' | 'completed' | 'failed' | 'killed'
28
29export type AgentRow = {
30 id: string
31 type: string
32 description: string
33 model: string
34 status: AgentState
35 startedAt: number
36 durationMs?: number
37}
38
39export type PanelId =
40 | 'context'
41 | 'limits'
42 | 'cost'
43 | 'tools'
44 | 'agents'
45 | 'forecast'
46 | 'time'
47 | 'files'
48 | 'bash'
49 | 'week'
50
51export type SortKey = 'time' | 'calls' | 'errors' | 'avg' | 'max' | 'name'
52
53/** How the context alert speaks: a toast, a status line entry, both, or not at all. */
54export type AlertMode = 'off' | 'toast' | 'status' | 'both'
55
56export type Prefs = {
57 /** Which default set the layout was made against; an older one starts from the new defaults. */
58 layout: number
59 hidden: PanelId[]
60 collapsed: PanelId[]
61 sort: SortKey
62 reverse: boolean
63 alert: AlertMode
64 /** Context fill, in percent, at which the alert fires. */
65 threshold: number
66}
67
68/** How often a file was read and changed this session. */
69export type FileStat = { reads: number; edits: number; lastAt: number }
70
71/** One Bash command family (`git status`, `docker compose`): its calls, failures and time. */
72export type CommandStat = { calls: number; errors: number; totalMs: number }
73
74/** Where the session's time went: inside turns, and inside the tools those turns ran. */
75export type Timing = { turnMs: number; toolMs: number; turns: number }
76
77/** A rate-limit reading at a moment, for the burn rate. */
78export type LimitSample = { at: number; percent: number }
79
80/** One day's totals across every session on this machine. */
81export type DayStat = { usd: number; tokensIn: number; tokensOut: number; turns: number }
82
83declare module 'claude-code' {
84 interface PluginState {
85 cctop: {
86 usage: UsageSnap | null
87 history: number[]
88 costs: number[]
89 tokens: Tokens
90 tools: Record<string, ToolStat>
91 agents: AgentRow[]
92 prefs: Prefs
93 model: string
94 startedAt: number
95 cwd: string
96 files: Record<string, FileStat>
97 commands: Record<string, CommandStat>
98 timing: Timing
99 samples: Record<string, LimitSample[]>
100 days: Record<string, DayStat>
101 isAlerted: boolean
102 }
103 }
104}
105