SLOPSHOPPER

cctop

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

newpaneguardcommandtoaststatus
v?no licenseupdated 2026-10-08sirrorist/lazanus/mods/cctop
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cctop
│ ┃ cctop ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./tui.tsx │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /cctop │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · cctop
▣ client module ./tui.tsx
README

lazanus

Моды для 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

/cctop открывает панель сбоку от переписки. Работает и пока Claude занят.

Панели

По умолчанию видны первые пять, остальные включаются в меню (≡ menu или m) или цифрой.

#ПанельЧто показывает
1contextзаполненность окна и её график по ходам
2limitsокна 5h и 7d; метка │ - где был бы ровный расход, ▲/▼ - насколько впереди или позади
3toolsвызовы, ошибки, среднее, суммарное и максимальное время по каждому тулу
4agentsсабагенты: тип, модель, сколько работают
5week$, токены и ходы по дням за неделю, по всем сессиям машины
6cost$, $ в час, стоимость каждого хода, токены, доля кэша
7forecastтаблица по окнам лимитов: расход в час, когда дойдёт до 100%, когда сброс, успеет ли
8timeкуда ушло время: модель, тулы, ожидание тебя
9filesкакие файлы Claude читал и правил чаще всего
0bashкоманды по семействам (git status, docker compose): запуски, ошибки, время

reset layout в меню возвращает этот набор: первые пять видны, все развёрнуты, тулы по времени. Настройки оповещения сброс не трогает.

Управление

ТерминалДесктоп
Свернуть панельклик по заголовку▾ Name
Показать или скрыть1...9, 0 или ≡ menu● Name
Сортировка туловклик по колонке, s - следующая, r - обратнокнопки sort
Оповещение о контексте≡ menu -> context alertspeaks 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.log
Source 6 files
hooks/register.tsx 363 lines
1import { 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}
363
hooks/desktop.tsx 632 lines
1import 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}
632
hooks/format.ts 111 lines
1import 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}
111
hooks/model.ts 420 lines
1import 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)
420
hooks/tui.tsx 850 lines
1import 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
850
types/index.d.ts 105 lines
1export 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