htop for Claude Code: context, rate limits, speed, cost and subagents, live.

htop for Claude Code — see your context, rate limits and subagents live.

cctop 0.1 · opus-5-5 · ~/proj/api · up 42:17 ● streaming
CTX [|||||||||||||||||||||||||||| ] 112k/200k 56% compact @ 80%
5H [|||||||||| ] 27% resets 2h14m
WEEK [|||||||||||||||| ] 41% resets Thu
SPD [||||||||||||||||||||| ] 84 tok/s
$3.42 ▁▂▂▃▅▇▆▃▂▁▂▄▆█▅ last turn $0.31 · cache 91%
ID AGENT STATE TIME TOKENS TOOLS NOW
▶ main claude run 12:04 84.1k 37 Edit src/auth/AuthService.cs
a1 Explore run 0:41 12.7k 9 Grep "JwtOptions"
a2 code-reviewer wait 1:12 8.3k 4 Read tests/AuthTests.cs
cctop is a Claude Code mod: it runs inside Claude Code and reads the engine's own numbers (the /context breakdown, rate-limit windows, subagent ids, the live token stream). Nothing leaves your machine and nothing is written to disk.
Requires a Claude Code build with function-hooks mods (built and tested on 2.1.287).
From the marketplace (CLI or Claude Code on the web):
/plugin install cctop --marketplace ilkayGkbdk/cctop
Or from a local clone:
git clone https://github.com/ilkayGkbdk/cctop ~/.claude/cctop
claude --plugin-dir ~/.claude/cctop
To load it in every session, add it to the env block of ~/.claude/settings.json:
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/cctop" } }
/cctop | open the panel (docked beside the transcript in fullscreen, above the prompt otherwise) |
/cctop band | hide / show the one-line band above the prompt |

The band, while the panel is closed:
cctop CTX 56% ████░░░░ 5H 27% WK 41% 84 tok/s $3.42 ⑂ 3 agents
Keys in the panel:
| Key | Action |
|---|---|
Tab / click | select an agent row; again to expand its last 20 tool calls |
s | sort: start → tokens → time |
x | stop the selected subagent (asks y/n) |
a | abort the current turn (asks y/n) |
c | run /compact (when no turn is running) |
q / Esc | close |
Set with /config (or pluginConfigs.cctop.options in settings):
| Option | Default | |
|---|---|---|
band | true | show the band above the prompt |
ctxWarnPercent | 80 | context toast, and where the bar turns red |
rateWarnPercent | 90 | 5-hour window toast |
mapPollMs | 2000 | context map refresh while the panel is open |
| Engine event | cctop uses it for |
|---|---|
session.measure | context fill, rate-limit windows, session cost (push, only on change) |
turn.start / turn.complete | turn id for abort, per-turn cost sparkline, cache share |
turn.step (stream) | live tok/s, ● streaming, per-agent context size |
agent.spawn | a new row per subagent |
tool.call | each agent's current activity and history; denies a stopped subagent's calls |
$.session.usage({ breakdown: 'summary' }) | the context map, only while the panel is open (local estimate, no API request) |
Every hook passes the event through unchanged — except tool calls of a subagent you stopped.
a aborts the whole turn.See CONTRIBUTING.md. Good first issues: themes, a file-radar tab.
MIT
hooks/register.tsx 337 lines1// cctop: htop for Claude Code. Only this file touches `$`: it turns engine
2// events into model updates held in $.state, and wires the views' buttons.
3// Every hook passes the event on unchanged, except tool calls of a subagent
4// the user stopped.
5
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register } from 'claude-code'
8
9import type { AgentRow, AlertState, MapSnapshot, Meters, UiState } from '../types'
10import { checkAlerts } from './model/alerts'
11import { configOf } from './model/config'
12import type { Config } from './model/config'
13import { markStale, toSnapshot } from './model/contextMap'
14import { MAIN, canStop, clearsFinished, denyFor, mainRow, reduce, targetOf } from './model/agents'
15import type { AgentAction } from './model/agents'
16import { applyMeasure, emptyMeters } from './model/meters'
17import { addSample, estimateTokens, pushSpark, tokPerSec } from './model/speed'
18import type { SpeedSample } from './model/speed'
19import { Band } from './view/Band'
20import { Panel } from './view/Panel'
21
22type $ = EngineInterface
23
24const PANE = 'cctop'
25const FLUSH_MS = 250
26
27const metersA = atom({ plugin: 'cctop', key: 'meters' } as const, emptyMeters())
28const agentsA = atom({ plugin: 'cctop', key: 'agents' } as const, [] as AgentRow[])
29const sparkA = atom({ plugin: 'cctop', key: 'spark' } as const, [] as number[])
30const mapA = atom({ plugin: 'cctop', key: 'map' } as const, null as MapSnapshot | null)
31const uiA = atom({ plugin: 'cctop', key: 'ui' } as const, { selected: MAIN, sort: 'start', expanded: null, confirm: null } as UiState)
32const stoppedA = atom({ plugin: 'cctop', key: 'stopped' } as const, [] as string[])
33const turnIdA = atom({ plugin: 'cctop', key: 'turnId' } as const, null as string | null)
34const alertsA = atom({ plugin: 'cctop', key: 'alerts' } as const, { ctx: false, rate: false } as AlertState)
35const startedAtA = atom({ plugin: 'cctop', key: 'startedAt' } as const, 0)
36const bandHiddenA = atom({ plugin: 'cctop', key: 'bandHidden' } as const, false)
37
38// Module variables reset on a hot reload; they only buffer what the next
39// flush writes to $.state.
40let cfg: Config = configOf({})
41let samples: SpeedSample[] = []
42let pendingActions: AgentAction[] = []
43let pendingStreaming: boolean | undefined
44let pendingModel: string | undefined
45let flushTimer: { cancel: () => void } | undefined
46let mapTimer: { cancel: () => void } | undefined
47let costAtTurnStart: number | undefined
48let lastColumns = 100
49
50async function flush($: $) {
51 flushTimer = undefined
52 const now = await $.clock.now()
53 const actions = pendingActions
54 pendingActions = []
55 if (actions.length > 0) await update($, agentsA, rows => actions.reduce(reduce, rows.length > 0 ? rows : [mainRow(now)]))
56 const speed = tokPerSec(samples, now)
57 const streaming = pendingStreaming
58 const model = pendingModel
59 pendingStreaming = undefined
60 await update($, metersA, m => ({
61 ...m,
62 tokPerSec: speed,
63 peakTokPerSec: Math.max(m.peakTokPerSec, speed),
64 ...(streaming !== undefined ? { isStreaming: streaming } : {}),
65 ...(model !== undefined ? { model } : {}),
66 }))
67 if (speed > 0 || streaming === true) await scheduleFlush($)
68}
69
70async function scheduleFlush($: $) {
71 if (flushTimer !== undefined) return
72 flushTimer = await $.clock.after(FLUSH_MS, () => void flush($).catch(() => undefined))
73}
74
75async function act($: $, action: AgentAction) {
76 pendingActions.push(action)
77 await scheduleFlush($)
78}
79
80async function seed($: $) {
81 const usage = await $.session.usage()
82 await update($, startedAtA, () => usage.startedAt)
83 await update($, metersA, m => applyMeasure(m, usage.context ? { context: usage.context, rateLimits: usage.rateLimits, ...(usage.cost ? { cost: usage.cost } : {}) } : { context: { window: 0 }, rateLimits: [] }))
84 const now = await $.clock.now()
85 await update($, agentsA, rows => (rows.length > 0 ? rows : [mainRow(now)]))
86}
87
88async function pollMap($: $) {
89 try {
90 const usage = await $.session.usage({ breakdown: 'summary', columns: Math.max(80, lastColumns - 34) })
91 const b = usage.context.breakdown
92 if (b === undefined) {
93 await update($, mapA, markStale)
94 return
95 }
96 const now = await $.clock.now()
97 await update($, mapA, () => toSnapshot(b, now))
98 } catch {
99 await update($, mapA, markStale)
100 }
101}
102
103async function ensureMapTimer($: $) {
104 if (mapTimer !== undefined) return
105 mapTimer = await $.clock.every(cfg.mapPollMs, () => void pollMap($).catch(() => undefined))
106}
107
108async function openPanel($: $) {
109 await pollMap($)
110 await $.ui.open({ id: PANE, title: 'cctop', focus: true, closeOnEscape: true, rows: 30, columns: 100 })
111}
112
113async function doSelect($: $, id: string) {
114 await update($, uiA, ui => (ui.selected === id ? { ...ui, expanded: ui.expanded === id ? null : id } : { ...ui, selected: id, confirm: null }))
115}
116
117async function selectRow($: $, id: string) {
118 await update($, uiA, (ui): UiState => (ui.selected === id ? ui : { ...ui, selected: id, confirm: null }))
119}
120
121async function doSort($: $) {
122 await update($, uiA, (ui): UiState => ({ ...ui, sort: ui.sort === 'start' ? 'tokens' : ui.sort === 'tokens' ? 'time' : 'start' }))
123}
124
125async function askStop($: $) {
126 const ui = await read($, uiA)
127 const rows = await read($, agentsA)
128 if (!canStop(rows.find(r => r.id === ui.selected))) {
129 $.ui.toast('cctop: select a running subagent first (Tab), then x.')
130 return
131 }
132 await update($, uiA, (u): UiState => ({ ...u, confirm: { kind: 'stop', agentId: ui.selected } }))
133}
134
135async function askAbort($: $) {
136 if ((await read($, turnIdA)) === null) {
137 $.ui.toast('cctop: no turn is running.')
138 return
139 }
140 await update($, uiA, (u): UiState => ({ ...u, confirm: { kind: 'abort' } }))
141}
142
143async function doConfirm($: $) {
144 const { confirm } = await read($, uiA)
145 await update($, uiA, u => ({ ...u, confirm: null }))
146 if (confirm === null) return
147 if (confirm.kind === 'stop') {
148 await update($, stoppedA, s => [...s, confirm.agentId])
149 await act($, { type: 'stop', id: confirm.agentId, at: await $.clock.now() })
150 return
151 }
152 const turnId = await read($, turnIdA)
153 if (turnId !== null) await $.turn.abort({ turnId }).catch(() => $.ui.toast('cctop: that turn already ended.'))
154}
155
156async function doCancel($: $) {
157 await update($, uiA, u => ({ ...u, confirm: null }))
158}
159
160async function doCompact($: $) {
161 if ((await read($, turnIdA)) !== null) {
162 $.ui.toast('cctop: wait for the turn to finish, then c.')
163 return
164 }
165 await $.command.run({ command: 'compact' })
166}
167
168async function doClose($: $) {
169 mapTimer?.cancel()
170 mapTimer = undefined
171 await $.ui.close({ id: PANE })
172}
173
174export const register: Register = (on, options) => {
175 cfg = configOf(options)
176
177 on('session.start', async ($, e, next) => {
178 await $.command.register({ name: 'cctop', description: 'htop for Claude Code: context, limits, speed, cost, agents', argumentHint: '[band]' })
179 await seed($).catch(() => undefined)
180 return next(e)
181 })
182
183 on('session.measure', async ($, e, next) => {
184 try {
185 const meters = await update($, metersA, m => applyMeasure(m, e))
186 const { next: alerts, fire } = checkAlerts(await read($, alertsA), meters, cfg)
187 await update($, alertsA, () => alerts)
188 for (const text of fire) $.ui.toast(text)
189 } catch {
190 // never stand in the engine's way
191 }
192 return next(e)
193 })
194
195 on('prompt.submit', async ($, e, next) => {
196 if (clearsFinished(e.origin)) await act($, { type: 'prompt', at: await $.clock.now() }).catch(() => undefined)
197 return next(e)
198 })
199
200 on('turn.start', async ($, e, next) => {
201 await update($, turnIdA, () => e.turnId).catch(() => undefined)
202 costAtTurnStart = (await read($, metersA).catch(() => emptyMeters())).costUsd
203 return next(e)
204 })
205
206 on('turn.step', async function* ($, e, next) {
207 const id = e.agentId ?? MAIN
208 try {
209 pendingStreaming = true
210 pendingModel = e.agentId === undefined ? e.model : pendingModel
211 await act($, { type: 'step', id, at: await $.clock.now() })
212 } catch {
213 // bookkeeping only; the stream goes on regardless
214 }
215 const stream = next(e)
216 while (true) {
217 const item = await stream.next()
218 if (item.done === true) return item.value
219 const chunk = item.value
220 try {
221 if (chunk.kind === 'text' || chunk.kind === 'thinking') {
222 samples = addSample(samples, await $.clock.now(), estimateTokens(chunk.text))
223 } else if (chunk.kind === 'input') {
224 samples = addSample(samples, await $.clock.now(), estimateTokens(chunk.json))
225 } else if (chunk.kind === 'stop' && chunk.usage !== null) {
226 const u = chunk.usage
227 pendingActions.push({ type: 'usage', id, tokens: u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens + u.output_tokens })
228 }
229 } catch {
230 // as above
231 }
232 yield chunk
233 }
234 })
235
236 on('turn.complete', async ($, e, next) => {
237 try {
238 const id = e.agentId ?? MAIN
239 await act($, { type: 'complete', id, at: await $.clock.now() })
240 if (e.agentId !== undefined) await update($, stoppedA, s => s.filter(one => one !== e.agentId))
241 if (e.agentId === undefined) {
242 pendingStreaming = false
243 await update($, turnIdA, () => null)
244 const usage = await $.session.usage()
245 const cost = usage.cost?.usd
246 const turnCost = cost !== undefined && costAtTurnStart !== undefined ? Math.max(0, cost - costAtTurnStart) : undefined
247 if (turnCost !== undefined) await update($, sparkA, ring => pushSpark(ring, turnCost))
248 const u = e.usage
249 const input = u === undefined ? 0 : u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
250 await update($, metersA, m => ({
251 ...m,
252 ...(turnCost !== undefined ? { turnCostUsd: turnCost } : {}),
253 ...(u !== undefined && input > 0 ? { cacheShare: Math.round((u.cache_read_input_tokens / input) * 100) } : {}),
254 }))
255 }
256 } catch {
257 // as above
258 }
259 return next(e)
260 })
261
262 on('agent.spawn', async ($, e, next) => {
263 const result = await next(e)
264 if (result.deny === undefined && result.agentId !== undefined) {
265 await act($, { type: 'spawn', id: result.agentId, subagentType: e.subagentType, description: e.description, model: result.model, at: await $.clock.now() }).catch(() => undefined)
266 }
267 return result
268 })
269
270 on('tool.call', async ($, e, next) => {
271 const deny = denyFor(await read($, stoppedA).catch(() => [] as string[]), e.agentId)
272 if (deny !== undefined) return { deny }
273 const id = e.agentId ?? MAIN
274 await act($, { type: 'tool', id, entry: { id: e.tool_use_id, tool: e.tool, target: targetOf(e.tool, e as unknown as Record<string, unknown>), at: await $.clock.now() } }).catch(() => undefined)
275 try {
276 return await next(e)
277 } finally {
278 await act($, { type: 'toolDone', id }).catch(() => undefined)
279 }
280 })
281
282 on('command.run', { command: 'cctop' }, async ($, e) => {
283 if (e.args.trim() === 'band') {
284 const hidden = await update($, bandHiddenA, h => !h)
285 return { text: `cctop band ${hidden ? 'hidden' : 'shown'}.` }
286 }
287 await openPanel($)
288 return { text: 'cctop opened — Tab select · s sort · x stop agent · a abort turn · c compact · q close' }
289 })
290
291 on('ui.close', { id: PANE }, async ($, e, next) => {
292 mapTimer?.cancel()
293 mapTimer = undefined
294 return next(e)
295 })
296
297 on('ui.focus', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
298 if (e.element?.startsWith('row-') === true) await selectRow($, e.element.slice(4)).catch(() => undefined)
299 return next(e)
300 })
301
302 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
303 lastColumns = e.props.bodyColumns
304 await ensureMapTimer($)
305 const now = await $.clock.now()
306 return Panel($.ui.resolve(e), {
307 meters: await read($, metersA),
308 agents: await read($, agentsA),
309 spark: await read($, sparkA),
310 map: await read($, mapA),
311 ui: await read($, uiA),
312 startedAt: await read($, startedAtA),
313 cwd: await $.session.cwd(),
314 now,
315 columns: e.props.bodyColumns,
316 cfg,
317 }, {
318 onSelect: id => void doSelect($, id),
319 onSort: () => void doSort($),
320 onStop: () => void askStop($),
321 onAbort: () => void askAbort($),
322 onConfirm: () => void doConfirm($),
323 onCancel: () => void doCancel($),
324 onCompact: () => void doCompact($),
325 onClose: () => void doClose($),
326 })
327 })
328
329 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
330 const meters = await read($, metersA)
331 const isQuiet = !cfg.band || e.props.hasSurvey || meters.ctxWindow === undefined || meters.ctxWindow === 0 || (await read($, bandHiddenA))
332 if (isQuiet) return next(e)
333 const agents = await read($, agentsA)
334 return Band($.ui.resolve(e), { meters, agents, columns: e.props.bodyColumns, cfg })
335 })
336}
337hooks/model/alerts.ts 28 lines1// Threshold toasts: fire once per crossing, re-arm when the value drops back.
2
3import type { AlertState, Meters } from '../../types'
4
5export const checkAlerts = (
6 prev: AlertState,
7 m: Meters,
8 cfg: { ctxWarnPercent: number; rateWarnPercent: number },
9): { next: AlertState; fire: string[] } => {
10 const fire: string[] = []
11 const next = { ...prev }
12
13 if (m.ctxPercent !== undefined) {
14 const isHot = m.ctxPercent >= cfg.ctxWarnPercent
15 if (isHot && !prev.ctx) fire.push(`cctop: context ${m.ctxPercent}% — compaction is near. /compact now, or /cctop to see what fills it.`)
16 next.ctx = isHot
17 }
18
19 const five = m.rate.find(r => r.kind === 'five_hour')
20 if (five !== undefined) {
21 const isHot = five.percent >= cfg.rateWarnPercent
22 if (isHot && !prev.rate) fire.push(`cctop: 5-hour limit ${five.percent}% used.`)
23 next.rate = isHot
24 }
25
26 return { next, fire }
27}
28hooks/model/config.ts 14 lines1// userConfig values with defaults and sane bounds.
2
3export type Config = { band: boolean; ctxWarnPercent: number; rateWarnPercent: number; mapPollMs: number }
4
5const num = (v: unknown, fallback: number, min: number, max: number) =>
6 typeof v === 'number' && Number.isFinite(v) ? Math.max(min, Math.min(max, v)) : fallback
7
8export const configOf = (options: Readonly<Record<string, unknown>>): Config => ({
9 band: typeof options.band === 'boolean' ? options.band : true,
10 ctxWarnPercent: num(options.ctxWarnPercent, 80, 1, 100),
11 rateWarnPercent: num(options.rateWarnPercent, 90, 1, 100),
12 mapPollMs: num(options.mapPollMs, 2000, 500, 60_000),
13})
14hooks/model/contextMap.ts 39 lines1// The /context breakdown as the panel draws it.
2
3import type { MapCell, MapSnapshot } from '../../types'
4
5export type BreakdownLike = {
6 categories: readonly { name: string; tokens: number; color: string; kind: string }[]
7 totalTokens: number
8 rawMaxTokens: number
9 autoCompactThreshold?: number
10 gridRows: readonly (readonly { color: string; isFilled: boolean }[])[]
11}
12
13export const toSnapshot = (b: BreakdownLike, at: number): MapSnapshot => {
14 const max = Math.max(1, b.rawMaxTokens)
15 return {
16 categories: b.categories
17 .filter(c => c.tokens > 0)
18 .map(c => ({ name: c.name, tokens: c.tokens, percent: Math.round((c.tokens / max) * 100), color: c.color, kind: c.kind })),
19 grid: b.gridRows.map(row => row.map(cell => ({ color: cell.color, isFilled: cell.isFilled }))),
20 totalTokens: b.totalTokens,
21 maxTokens: b.rawMaxTokens,
22 ...(b.autoCompactThreshold !== undefined ? { autoCompactPercent: Math.round((b.autoCompactThreshold / max) * 100) } : {}),
23 at,
24 isStale: false,
25 }
26}
27
28export const markStale = (m: MapSnapshot | null): MapSnapshot | null => (m === null ? null : { ...m, isStale: true })
29
30export const runsOf = (row: readonly MapCell[]): { color: string; isFilled: boolean; count: number }[] => {
31 const runs: { color: string; isFilled: boolean; count: number }[] = []
32 for (const cell of row) {
33 const last = runs[runs.length - 1]
34 if (last !== undefined && last.color === cell.color && last.isFilled === cell.isFilled) last.count += 1
35 else runs.push({ color: cell.color, isFilled: cell.isFilled, count: 1 })
36 }
37 return runs
38}
39hooks/model/agents.ts 102 lines1// The process list: main + subagents, driven by engine events.
2
3import type { AgentRow, SortKey, ToolEntry } from '../../types'
4
5export const MAIN = 'main'
6export const HISTORY = 20
7export const STOP_MESSAGE = 'Stopped by the user via cctop. Do not call more tools; reply with what you have so far.'
8
9export type AgentAction =
10 | { type: 'spawn'; id: string; subagentType: string; description: string; model?: string; at: number }
11 | { type: 'step'; id: string; at: number }
12 | { type: 'usage'; id: string; tokens: number }
13 | { type: 'tool'; id: string; entry: ToolEntry }
14 | { type: 'toolDone'; id: string }
15 | { type: 'complete'; id: string; at: number }
16 | { type: 'stop'; id: string; at: number }
17 | { type: 'prompt'; at: number }
18
19export const mainRow = (now: number): AgentRow => ({
20 id: MAIN, type: 'claude', description: 'main', state: 'wait', startedAt: now, tokens: 0, tools: 0, now: '—', history: [],
21})
22
23const blank = (id: string, at: number): AgentRow => ({
24 id, type: 'agent', description: '', state: 'run', startedAt: at, tokens: 0, tools: 0, now: 'starting…', history: [],
25})
26
27const isOver = (row: AgentRow) => row.state === 'done' || row.state === 'stopped'
28
29const upsert = (rows: AgentRow[], id: string, at: number, fn: (row: AgentRow) => AgentRow): AgentRow[] =>
30 rows.some(r => r.id === id) ? rows.map(r => (r.id === id ? fn(r) : r)) : [...rows, fn(blank(id, at))]
31
32export const reduce = (rows: AgentRow[], a: AgentAction): AgentRow[] => {
33 switch (a.type) {
34 case 'spawn':
35 return upsert(rows, a.id, a.at, r => ({
36 ...r, type: a.subagentType, description: a.description, startedAt: Math.min(r.startedAt, a.at),
37 ...(a.model !== undefined ? { model: a.model } : {}),
38 }))
39 case 'step':
40 return upsert(rows, a.id, a.at, r => (isOver(r) ? r : { ...r, state: 'run', now: 'thinking…' }))
41 case 'usage':
42 return upsert(rows, a.id, 0, r => ({ ...r, tokens: a.tokens }))
43 case 'tool':
44 return upsert(rows, a.id, a.entry.at, r => ({
45 ...r, state: isOver(r) ? r.state : 'run', tools: r.tools + 1,
46 now: `${a.entry.tool} ${a.entry.target}`.trim(), history: [...r.history, a.entry].slice(-HISTORY),
47 }))
48 case 'toolDone':
49 return rows.map(r => (r.id === a.id && !isOver(r) ? { ...r, state: 'wait' } : r))
50 case 'complete':
51 return rows.map(r => {
52 if (r.id !== a.id) return r
53 if (r.id === MAIN) return { ...r, state: 'wait', now: '—' }
54 return r.state === 'stopped' ? { ...r, isClosed: true } : { ...r, state: 'done', endedAt: a.at, now: '—', isClosed: true }
55 })
56 case 'stop':
57 return rows.map(r => (r.id === a.id ? { ...r, state: 'stopped', endedAt: a.at, now: 'stopped' } : r))
58 case 'prompt':
59 return rows.filter(r => r.id === MAIN || r.isClosed !== true).map(r => (r.id === MAIN ? { ...r, state: 'run', now: 'thinking…' } : r))
60 }
61}
62
63const elapsed = (r: AgentRow, now: number) => (r.endedAt ?? now) - r.startedAt
64
65export const sortRows = (rows: AgentRow[], key: SortKey, now: number): AgentRow[] => {
66 const main = rows.filter(r => r.id === MAIN)
67 const rest = rows.filter(r => r.id !== MAIN)
68 const by =
69 key === 'tokens' ? (a: AgentRow, b: AgentRow) => b.tokens - a.tokens
70 : key === 'time' ? (a: AgentRow, b: AgentRow) => elapsed(b, now) - elapsed(a, now)
71 : (a: AgentRow, b: AgentRow) => a.startedAt - b.startedAt
72 return [...main, ...[...rest].sort(by)]
73}
74
75export const labelOf = (rows: AgentRow[], id: string): string => {
76 if (id === MAIN) return 'main'
77 const order = rows.filter(r => r.id !== MAIN).sort((a, b) => a.startedAt - b.startedAt)
78 return `a${order.findIndex(r => r.id === id) + 1}`
79}
80
81export const canStop = (row: AgentRow | undefined): boolean =>
82 row !== undefined && row.id !== MAIN && (row.state === 'run' || row.state === 'wait')
83
84export const denyFor = (stopped: readonly string[], agentId: string | undefined): string | undefined =>
85 agentId !== undefined && stopped.includes(agentId) ? STOP_MESSAGE : undefined
86
87const str = (v: unknown) => (typeof v === 'string' ? v : undefined)
88
89export const targetOf = (tool: string, args: Record<string, unknown>): string => {
90 const path = str(args.file_path) ?? str(args.notebook_path) ?? str(args.path)
91 if (path !== undefined) return path
92 if (tool === 'Bash') return str(args.command)?.split('\n')[0] ?? ''
93 if (tool === 'Grep' || tool === 'Glob') return `"${str(args.pattern) ?? ''}"`
94 if (tool === 'WebFetch') return str(args.url) ?? ''
95 if (tool === 'WebSearch') return str(args.query) ?? ''
96 if (tool === 'Agent' || tool === 'Task') return str(args.description) ?? ''
97 return ''
98}
99
100/** Whether a submitted prompt starts a new task (a background agent's report does not). */
101export const clearsFinished = (origin: { kind: string }): boolean => origin.kind !== 'task-notification'
102hooks/model/meters.ts 25 lines1// The header meters: engine measurements → plain values.
2
3import type { Level, Meters } from '../../types'
4
5export const COLOR: Record<Level, string> = { ok: 'green', warn: 'yellow', crit: 'red' }
6
7export const levelOf = (percent: number, warnAt: number): Level => (percent >= warnAt ? 'crit' : percent >= 60 ? 'warn' : 'ok')
8
9export const emptyMeters = (): Meters => ({ rate: [], tokPerSec: 0, peakTokPerSec: 0, isStreaming: false })
10
11export type MeasureLike = {
12 context: { tokens?: number; window: number; percent?: number }
13 rateLimits: readonly { kind: string; percentUsed: number; resetsAt?: string }[]
14 cost?: { usd: number }
15}
16
17export const applyMeasure = (m: Meters, e: MeasureLike): Meters => ({
18 ...m,
19 ctxWindow: e.context.window,
20 ...(e.context.tokens !== undefined ? { ctxTokens: e.context.tokens } : {}),
21 ...(e.context.percent !== undefined ? { ctxPercent: e.context.percent } : {}),
22 rate: e.rateLimits.map(r => ({ kind: r.kind, percent: r.percentUsed, ...(r.resetsAt !== undefined ? { resetsAt: r.resetsAt } : {}) })),
23 ...(e.cost !== undefined ? { costUsd: e.cost.usd } : {}),
24})
25hooks/model/speed.ts 30 lines1// Live speed (tok/s over a sliding window) and the per-turn cost sparkline.
2
3export type SpeedSample = { at: number; tokens: number }
4
5export const WINDOW_MS = 3000
6const BLOCKS = '▁▂▃▄▅▆▇█'
7
8export const estimateTokens = (text: string): number => Math.max(1, Math.round(text.length / 4))
9
10export const addSample = (samples: SpeedSample[], at: number, tokens: number): SpeedSample[] => [
11 ...samples.filter(s => at - s.at <= WINDOW_MS),
12 { at, tokens },
13]
14
15export const tokPerSec = (samples: SpeedSample[], now: number): number => {
16 const live = samples.filter(s => now - s.at <= WINDOW_MS)
17 const first = live[0]
18 if (first === undefined) return 0
19 const total = live.reduce((n, s) => n + s.tokens, 0)
20 const seconds = Math.max(1000, now - first.at) / 1000
21 return Math.round(total / seconds)
22}
23
24export const pushSpark = (ring: number[], value: number, size = 30): number[] => [...ring, value].slice(-size)
25
26export const sparkline = (ring: readonly number[]): string => {
27 const max = Math.max(0, ...ring)
28 return ring.map(v => (max <= 0 ? BLOCKS[0] : BLOCKS[Math.min(7, Math.floor((v / max) * 7.999))])).join('')
29}
30hooks/view/Band.tsx 34 lines1import type { ElementTable } from 'claude-code'
2
3import type { AgentRow, Meters } from '../../types'
4import type { Config } from '../model/config'
5import { fmtUsd, rateLabel } from '../model/format'
6import { COLOR, levelOf } from '../model/meters'
7
8export type BandData = { meters: Meters; agents: AgentRow[]; columns: number; cfg: Config }
9
10const mini = (percent: number) => {
11 const filled = Math.max(0, Math.min(8, Math.round(percent / 12.5)))
12 return '█'.repeat(filled) + '░'.repeat(8 - filled)
13}
14
15export const Band = (ui: ElementTable, d: BandData) => {
16 const { Box, Text } = ui
17 const m = d.meters
18 const ctx = m.ctxPercent ?? 0
19 const running = d.agents.filter(a => a.id !== 'main' && (a.state === 'run' || a.state === 'wait')).length
20 const isWide = d.columns >= 60
21 return (
22 <Box flexDirection="row" gap={2}>
23 <Text bold>cctop</Text>
24 <Text color={COLOR[levelOf(ctx, d.cfg.ctxWarnPercent)]}>{`CTX ${ctx}%${isWide ? ` ${mini(ctx)}` : ''}`}</Text>
25 {m.rate.map(r => (
26 <Text color={COLOR[levelOf(r.percent, d.cfg.rateWarnPercent)]}>{`${rateLabel(r.kind) === 'WEEK' ? 'WK' : rateLabel(r.kind)} ${Math.round(r.percent)}%`}</Text>
27 ))}
28 {isWide && m.tokPerSec > 0 && <Text dimColor>{`${m.tokPerSec} tok/s`}</Text>}
29 {m.costUsd !== undefined && <Text>{fmtUsd(m.costUsd)}</Text>}
30 {running > 0 && <Text color="cyan">{`⑂ ${running} agent${running === 1 ? '' : 's'}`}</Text>}
31 </Box>
32 )
33}
34hooks/view/Panel.tsx 102 lines1import type { ElementTable } from 'claude-code'
2
3import type { AgentRow, MapSnapshot, Meters, UiState } from '../../types'
4import { labelOf } from '../model/agents'
5import type { Config } from '../model/config'
6import { fmtDuration, fmtReset, fmtTokens, fmtUsd, rateLabel, shortModel, shortPath } from '../model/format'
7import { levelOf } from '../model/meters'
8import { sparkline } from '../model/speed'
9import { ContextMapView } from './ContextMap'
10import { Meter } from './Meter'
11import { ProcessList } from './ProcessList'
12
13export type PanelData = {
14 meters: Meters
15 agents: AgentRow[]
16 spark: number[]
17 map: MapSnapshot | null
18 ui: UiState
19 startedAt: number
20 cwd: string
21 now: number
22 columns: number
23 cfg: Config
24}
25
26export type PanelHandlers = {
27 onSelect: (id: string) => void
28 onSort: () => void
29 onStop: () => void
30 onAbort: () => void
31 onConfirm: () => void
32 onCancel: () => void
33 onCompact: () => void
34 onClose: () => void
35}
36
37export const Panel = (ui: ElementTable, d: PanelData, act: PanelHandlers) => {
38 const { Box, Text, Button } = ui
39 const m = d.meters
40 const barWidth = Math.max(10, Math.min(36, d.columns - 40))
41 const ctxPercent = m.ctxPercent ?? 0
42 const confirmText =
43 d.ui.confirm === null ? undefined
44 : d.ui.confirm.kind === 'stop' ? `Stop ${labelOf(d.agents, d.ui.confirm.agentId)}? Its next tool calls will be denied.`
45 : 'Abort the current turn?'
46
47 return (
48 <Box flexDirection="column">
49 <Box flexDirection="row" justifyContent="space-between">
50 <Text>{` cctop 0.1.1 · ${m.model !== undefined ? shortModel(m.model) : '…'} · ${shortPath(d.cwd)} · up ${fmtDuration(d.now - d.startedAt)}`}</Text>
51 {m.isStreaming ? <Text color="green">● streaming </Text> : <Text dimColor>○ idle </Text>}
52 </Box>
53 {m.ctxWindow !== undefined && m.ctxWindow > 0 &&
54 Meter(ui, {
55 label: 'CTX', percent: ctxPercent, width: barWidth, level: levelOf(ctxPercent, d.cfg.ctxWarnPercent),
56 right: `${fmtTokens(m.ctxTokens ?? 0)}/${fmtTokens(m.ctxWindow)} ${ctxPercent}%${d.map?.autoCompactPercent !== undefined ? ` compact @ ${d.map.autoCompactPercent}%` : ''}`,
57 })}
58 {m.rate.map(r =>
59 Meter(ui, {
60 label: rateLabel(r.kind), percent: r.percent, width: barWidth,
61 level: levelOf(r.percent, r.kind === 'five_hour' ? d.cfg.rateWarnPercent : 90),
62 right: `${String(Math.round(r.percent)).padStart(3)}% ${r.resetsAt !== undefined ? `resets ${fmtReset(r.resetsAt, d.now)}` : ''}`,
63 }),
64 )}
65 {Meter(ui, {
66 label: 'SPD', percent: m.peakTokPerSec > 0 ? (m.tokPerSec / m.peakTokPerSec) * 100 : 0, width: barWidth, level: 'ok',
67 right: `${m.tokPerSec} tok/s`,
68 })}
69 {m.costUsd !== undefined && (
70 <Box flexDirection="row" gap={2}>
71 <Text bold>{` ${fmtUsd(m.costUsd)}`}</Text>
72 <Text color="cyan">{sparkline(d.spark)}</Text>
73 <Text dimColor>
74 {[m.turnCostUsd !== undefined ? `last turn ${fmtUsd(m.turnCostUsd)}` : '', m.cacheShare !== undefined ? `cache ${m.cacheShare}%` : ''].filter(Boolean).join(' · ')}
75 </Text>
76 </Box>
77 )}
78 <Text> </Text>
79 {ContextMapView(ui, d.map, d.columns)}
80 <Text> </Text>
81 {ProcessList(ui, { rows: d.agents, ui: d.ui, now: d.now, columns: d.columns }, act.onSelect)}
82 <Text> </Text>
83 {confirmText !== undefined ? (
84 <Box flexDirection="row" gap={1}>
85 <Text color="yellow" bold>{` ${confirmText}`}</Text>
86 <Button key="yes" hotkey="y" variant="primary" label="Yes" onPress={act.onConfirm} />
87 <Button key="no" hotkey="n" label="No" onPress={act.onCancel} />
88 </Box>
89 ) : (
90 <Box flexDirection="row" gap={1}>
91 <Text dimColor> Tab Select</Text>
92 <Button key="sort" hotkey="s" plain label={`Sort:${d.ui.sort}`} onPress={act.onSort} />
93 <Button key="stop" hotkey="x" plain label="Stop agent" onPress={act.onStop} />
94 <Button key="abort" hotkey="a" plain label="Abort turn" onPress={act.onAbort} />
95 <Button key="compact" hotkey="c" plain label="Compact" onPress={act.onCompact} />
96 <Button key="close" hotkey="q" plain role="dismiss" label="Quit" onPress={act.onClose} />
97 </Box>
98 )}
99 </Box>
100 )
101}
102hooks/model/format.ts 55 lines1// Text formatting shared by the panel, band and toasts.
2
3const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
4
5export const fmtTokens = (n: number): string => {
6 if (n < 1000) return String(Math.round(n))
7 if (n < 10_000) return `${(n / 1000).toFixed(1)}k`
8 if (n < 1_000_000) return `${Math.round(n / 1000)}k`
9 return `${(n / 1_000_000).toFixed(1)}M`
10}
11
12const two = (n: number) => String(n).padStart(2, '0')
13
14export const fmtDuration = (ms: number): string => {
15 const s = Math.max(0, Math.floor(ms / 1000))
16 const h = Math.floor(s / 3600)
17 const m = Math.floor((s % 3600) / 60)
18 return h > 0 ? `${h}:${two(m)}:${two(s % 60)}` : `${m}:${two(s % 60)}`
19}
20
21export const fmtReset = (iso: string | undefined, now: number): string => {
22 if (iso === undefined) return ''
23 const at = Date.parse(iso)
24 if (Number.isNaN(at)) return ''
25 const mins = Math.max(0, Math.round((at - now) / 60_000))
26 if (mins >= 24 * 60) return DAYS[new Date(at).getDay()] ?? ''
27 const h = Math.floor(mins / 60)
28 return h > 0 ? `${h}h${two(mins % 60)}m` : `${mins}m`
29}
30
31export const fmtUsd = (n: number): string => `$${n.toFixed(2)}`
32
33export const bar = (percent: number, width: number): string => {
34 const filled = Math.max(0, Math.min(width, Math.round((percent / 100) * width)))
35 return '|'.repeat(filled) + ' '.repeat(width - filled)
36}
37
38export const rateLabel = (kind: string): string => {
39 if (kind === 'five_hour') return '5H'
40 if (kind === 'seven_day') return 'WEEK'
41 if (kind === 'spend_limit') return 'SPEND'
42 return kind.slice(0, 5).toUpperCase()
43}
44
45/** `claude-haiku-4-5-20251001` → `haiku-4-5`. */
46export const shortModel = (model: string): string => model.replace(/^claude-/, '').replace(/-\d{8}$/, '')
47
48/** The last two folders of a path, `~`-relative when that is as short. */
49export const shortPath = (path: string): string => {
50 const home = /^\/(?:Users|home)\/[^/]+/.exec(path)?.[0]
51 const rel = home !== undefined ? `~${path.slice(home.length)}` : path
52 const parts = rel.split('/').filter(Boolean)
53 return parts.length <= 2 ? rel : `…/${parts.slice(-2).join('/')}`
54}
55hooks/view/ContextMap.tsx 45 lines1import type { ElementTable } from 'claude-code'
2
3import type { MapSnapshot } from '../../types'
4import { runsOf } from '../model/contextMap'
5import { fmtTokens } from '../model/format'
6
7const GRID_MIN_COLUMNS = 90
8
9export const ContextMapView = (ui: ElementTable, map: MapSnapshot | null, columns: number) => {
10 const { Box, Text } = ui
11 if (map === null) return <Text dimColor> CONTEXT MAP measuring…</Text>
12 const showGrid = columns >= GRID_MIN_COLUMNS
13 const nameWidth = columns >= 100 ? 24 : 18
14 return (
15 <Box flexDirection="column">
16 <Text bold>{` CONTEXT MAP${map.isStale ? ' (stale)' : ''}`}</Text>
17 <Box flexDirection="row" gap={2}>
18 {showGrid && (
19 <Box flexDirection="column" marginLeft={1}>
20 {map.grid.map(row => (
21 <Box flexDirection="row">
22 {runsOf(row).map(run => (
23 <Text color={run.color} dimColor={map.isStale}>
24 {(run.isFilled ? '█' : '░').repeat(run.count)}
25 </Text>
26 ))}
27 </Box>
28 ))}
29 </Box>
30 )}
31 <Box flexDirection="column" marginLeft={showGrid ? 0 : 1}>
32 {map.categories.map(c => (
33 <Box flexDirection="row" gap={1}>
34 <Text color={c.color}>{c.kind === 'free' ? '□' : '■'}</Text>
35 <Text>{c.name.slice(0, nameWidth).padEnd(nameWidth)}</Text>
36 <Text>{fmtTokens(c.tokens).padStart(6)}</Text>
37 <Text dimColor>{`${c.percent}%`.padStart(4)}</Text>
38 </Box>
39 ))}
40 </Box>
41 </Box>
42 </Box>
43 )
44}
45hooks/view/Meter.tsx 22 lines1import type { ElementTable } from 'claude-code'
2
3import type { Level } from '../../types'
4import { bar } from '../model/format'
5import { COLOR } from '../model/meters'
6
7export const Meter = (ui: ElementTable, p: { label: string; percent: number; width: number; right: string; level: Level }) => {
8 const { Box, Text } = ui
9 const fill = bar(p.percent, p.width)
10 const used = fill.trimEnd()
11 return (
12 <Box flexDirection="row">
13 <Text bold>{` ${p.label.padEnd(5)}`}</Text>
14 <Text>[</Text>
15 <Text color={COLOR[p.level]}>{used}</Text>
16 <Text>{' '.repeat(p.width - used.length)}</Text>
17 <Text>]</Text>
18 <Text>{` ${p.right}`}</Text>
19 </Box>
20 )
21}
22