SLOPSHOPPER

cctop

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

newpanebandguardcommandtoast
v0.1.1MITupdated 2026-10-09ilkayGkbdk/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 │ ┃ cctop 0.1.1 · … · /work/app · up 30:00○ idl │ ┃ CTX [|||||||| ] 97k/200k 49% ⏺ Read(src/auth.ts) │ ┃ 5H [||||| ] 31% resets ⎿ Read 6 lines │ ┃ SPD [ ] 0 tok/s ⏺ Update(src/auth.ts) │ ┃ $0.42 ▁ last turn $0.00 · cache 93% ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ CONTEXT MAP measuring… ⎿ 3 pass, 1 fail │ ┃ │ ┃ ID AGENT STATE TIME TOKENS ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ TOOLS NOW │ ┃ ▶ main claude wait 0:00 0 ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ Tab Select s: Sort:start x: Stop agent a: A › /cctop │ ⎿ cctop: cctop opened — Tab select · s sort · x stop agent · a abo │ │ cctop CTX 49% ████░░░░ 5H 31% $0.42 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
cctop CTX 49% ████░░░░ 5H 31% $0.42
Pane · cctop
cctop 0.1.1 · … · /work/app · up 30:00 ○ idle CTX [|||||||| ] 97k/200k 49% 5H [||||| ] 31% resets SPD [ ] 0 tok/s $0.42 ▁ last turn $0.00 · cache 93% CONTEXT MAP measuring… ID AGENT STATE TIME TOKENS TOOLS NOW ▶ main claude wait 0:00 0 0 — Tab Select s: Sort:start x: Stop agent a: Abort turn c: Com
README

cctop

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

cctop: three Explore subagents running in parallel, 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

Why

  • Compaction sneaks up on you. cctop shows how full the context window is, what fills it, and how far auto-compact is — and warns you at 80 %.
  • Rate limits hit mid-task. The 5-hour and weekly windows are right there, with their reset times.
  • Subagents are a black box. Every running agent gets a row: what it is doing now, how long, how big its context is — and you can stop one.

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.

Install

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" } }

Use

/cctopopen the panel (docked beside the transcript in fullscreen, above the prompt otherwise)
/cctop bandhide / show the one-line band above the prompt

cctop docked beside a Claude Code session

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:

KeyAction
Tab / clickselect an agent row; again to expand its last 20 tool calls
ssort: start → tokens → time
xstop the selected subagent (asks y/n)
aabort the current turn (asks y/n)
crun /compact (when no turn is running)
q / Escclose

Configure

Set with /config (or pluginConfigs.cctop.options in settings):

OptionDefault
bandtrueshow the band above the prompt
ctxWarnPercent80context toast, and where the bar turns red
rateWarnPercent905-hour window toast
mapPollMs2000context map refresh while the panel is open

How it works

Engine eventcctop uses it for
session.measurecontext fill, rate-limit windows, session cost (push, only on change)
turn.start / turn.completeturn id for abort, per-turn cost sparkline, cache share
turn.step (stream)live tok/s, ● streaming, per-agent context size
agent.spawna new row per subagent
tool.calleach 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.

Limitations

  • Stopping a subagent is a soft kill. The engine has no per-agent abort, so cctop denies that agent's next tool calls with "stopped by the user via cctop"; a response already streaming finishes. a aborts the whole turn.
  • The context map's categories are the engine's local estimate, so their total can differ slightly from the measured fill.
  • Rate-limit rows appear only on a Claude subscription; cost only where Claude Code keeps a cost ledger.
  • The mods API is early access and may change between Claude Code releases.

Contributing

See CONTRIBUTING.md. Good first issues: themes, a file-radar tab.

License

MIT

Source 14 files
hooks/register.tsx 337 lines
1// 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}
337
hooks/model/alerts.ts 28 lines
1// 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}
28
hooks/model/config.ts 14 lines
1// 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})
14
hooks/model/contextMap.ts 39 lines
1// 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}
39
hooks/model/agents.ts 102 lines
1// 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'
102
hooks/model/meters.ts 25 lines
1// 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})
25
hooks/model/speed.ts 30 lines
1// 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}
30
hooks/view/Band.tsx 34 lines
1import 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}
34
hooks/view/Panel.tsx 102 lines
1import 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}
102
hooks/model/format.ts 55 lines
1// 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}
55
hooks/view/ContextMap.tsx 45 lines
1import 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}
45
hooks/view/Meter.tsx 22 lines
1import 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