Shows the model's real output speed, token and prompt-cache stats in a docked sidebar beside the transcript.

A live stats sidebar for Claude Code: context use, token counts, prompt-cache hit rate and expiry, the model's real output speed, and your git state, docked beside the conversation.
<img src="docs/terminal.png" alt="The sidebar docked beside a Claude Code session in the terminal" width="49%"> <img src="docs/desktop.png" alt="The sidebar in the Claude Code tab of the desktop app" width="49%">
On a subscription, see how much of your 5-hour and weekly limits a session used. On an API key, see it in dollars. The sidebar picks the right one for your plan automatically.
Follow the agent's plan as it works: every task with a progress bar, a spinner and timer on the one running, ✓ when done and ✗ when it fails.
claude plugin marketplace add FayequeP/claude-mod-sidebar
claude plugin install sidebar@claude-mod-sidebar
It needs Claude Code 2.1.269+ with function hooks turned on in ~/.claude/settings.json:
{ "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }
To dock it as a sidebar in the terminal, use the fullscreen renderer (/tui fullscreen). On the classic screen it shows as a compact strip above the prompt.
| Section | What you get |
|---|---|
| Tasks | The agent's to-do list: a progress bar and every task, marked ✓ done, a spinner and timer while in progress, ○ not started, or ✗ failed (Claude marks a failed task done with a title like FAILED: …). Hidden when there's no list |
| Context | How full the context window is, as a bar and 85.4k / 272k |
| Tokens | This session's input (uncached), output, cache read, cache write and total, subagents included, then what this session spent: on a subscription, how much of your 5-hour and weekly limits it used (e.g. +9%); on an API key, its cost in dollars, as /cost reports it |
| Cache | Hit rate (share of all input served from the cache), and a countdown to when the main conversation's prompt cache expires |
| Speed | Time to first token and output speed in tokens per second |
| Workspace | Folder, git branch, clean or changed, lines added and removed |
About Tasks: Claude Code turns its task tools off by default for newer models such as Opus 5.5, and without them Claude writes its plan as plain text the sidebar can't follow. The sidebar switches them back on for its own sessions (CLAUDE_CODE_ENABLE_TODO_TOOLS=1). If you'd rather keep them off, set "CLAUDE_CODE_ENABLE_TODO_TOOLS": "0" in the env block of ~/.claude/settings.json; the sidebar leaves your choice alone and Tasks stays hidden.
/sidebar, or press the Hide sidebar button at its foot.~/.claude/keybindings.json and press ctrl+x s: { "bindings": [ { "context": "Global", "bindings": { "ctrl+x s": "app:toggleReplTab" } } ] }
Plugins can't own a key yet, so the sidebar's toggle button borrows that engine action and the chord presses it.
ctrl+x ← to widen it or ctrl+x → to narrow it. These are Claude Code's own pane keys (pane:grow / pane:shrink), so you can rebind them in ~/.claude/keybindings.json.Claude Code remembers the width you choose; it takes priority over the sidebar's default of 38 columns.
Output tokens divided by the time from the first streamed piece of the response to the last one, for the main conversation only (a subagent may run another model). Text, thinking and tool-call arguments all count. Token counts come from the API's usage.output_tokens; while a response is still streaming, a live estimate of about 4 characters per token is shown instead.
Responses that arrive in under half a second are skipped, because one network burst would read as thousands of tokens per second. The previous reading stays on screen.
The prompt cache lives for a fixed time after each request. Send your next message before Expires in reaches zero and the conversation is read from cache, which is cheaper and faster. After that, the whole context is written to cache again.
The sidebar works out the time to live the way Claude Code does for the main conversation. The first rule that matches wins, and its source is shown under the countdown bar, for example 1h cache · subscription:
| Shown as | Rule | Lasts |
|---|---|---|
env | CLAUDE_CODE_PROMPT_CACHE_TTL set to 5m or 1h | as set |
env | FORCE_PROMPT_CACHING_5M=1 | 5 min |
env | ENABLE_PROMPT_CACHING_1H=1 | 1 hour |
setting | "promptCacheTtl": "5m" or "1h" in settings.json | as set |
subscription | Claude subscription within its usage limits | 1 hour |
over limit | Subscription with a usage window at 100% | 5 min |
API key | API key, Bedrock, Vertex or Foundry | 5 min |
These defaults come from Claude Code's own description of promptCacheTtl (v2.1.287). A subscription is recognised by Claude Code reporting usage-limit windows, which happens after the first reply of a session. Subagents and background helpers use 5 minutes by default on every plan; the countdown follows the main conversation only.
MIT. Contributions welcome, see CONTRIBUTING.md.
hooks/register.tsx 748 lines1/* @jsx h */
2import type { Register } from 'claude-code'
3
4// ---- stats sidebar: everything is this session's ----
5// Token rows sum the API's usage over every response this session (subagents
6// included: they are spend too). Speed, first token and the cache countdown
7// follow the main conversation only: a subagent runs its own model and its own
8// prompt cache. The countdown is the main conversation's cache TTL minus the
9// time since its last request was sent (when the cache was last used).
10
11type Stats = { tps: number; ttftMs: number | null; isWarmingUp?: boolean }
12type Last = Stats & { at: number }
13type Sums = { input: number; output: number; cacheRead: number; cacheWrite: number }
14
15let live: Stats | null = null // the step currently streaming
16let last: Last | null = null // the last finished step
17let sums: Sums = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
18let lastInvalidateAt = 0
19let tick: unknown
20let isOpen = false // ponytail: module var, a hot reload reopens the pane anyway
21
22const PANE = { id: 'meter', title: 'Claude Code Sidebar', columns: 38, rows: 3 } as const
23let git: {
24 branch: string | null
25 dirty: boolean | null
26 changed: number
27 added: number
28 removed: number
29} | null = null
30let gitAt = 0
31let sessionId = ''
32let taskStarts = new Map<string, number>() // in-progress task key -> when it started
33let limitUse = new Map<string, LimitUse>() // per window kind: what this session has used
34let tasks: Task[] = [] // the main conversation's task list, as its task tools last left it
35
36const round1 = (n: number) => Math.round(n * 10) / 10
37
38function formatTtft(ms: number | null): string {
39 if (ms === null) return '—'
40 return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${Math.round(ms)}ms`
41}
42
43function formatTps(n: number): string {
44 return n >= 100 ? `${Math.round(n)} tok/s` : `${n.toFixed(0)} tok/s`
45}
46
47function formatCountdown(ms: number): string {
48 if (ms <= 0) return 'expired'
49 const total = Math.round(ms / 1000)
50 const m = Math.floor(total / 60)
51 const s = total % 60
52 return `${m}:${String(s).padStart(2, '0')}`
53}
54
55// One accent on a neutral base. Green/amber/red appear only as status
56// (git state, cache about to expire, lines added/removed), never decoration.
57const C = {
58 accent: '#7aa2f7',
59 track: '#3b4048',
60 muted: '#8b919a',
61 ok: '#8fbf7a',
62 warn: '#d9a55b',
63 bad: '#e07a7a',
64}
65
66// Compact counts: 76, 16.8k, 3.68M. Calmer than 3,684,818 in a narrow column.
67function compact(n: number): string {
68 if (n < 1000) return String(n)
69 const k = (n / 1000).toFixed(n < 100_000 ? 1 : 0)
70 // 999,950 rounds to "1000" k: that is 1M, so fall through to millions.
71 if (+k < 1000) return `${+k}k`
72 return `${+(n / 1_000_000).toFixed(2)}M`
73}
74
75// Smooth block bar. Terminal: full cells █, then one partial cell in eighths
76// (▏..▉) so the end moves smoothly, then a dim ░ track; monospace, exact.
77// Desktop/remote: an SVG pill, since a proportional font makes glyph runs drift.
78const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
79
80function smoothBar(ratio: number, width: number): { fill: string; track: string } {
81 const eighths = Math.round(Math.max(0, Math.min(1, ratio)) * width * 8)
82 const full = Math.floor(eighths / 8)
83 const part = EIGHTHS[eighths % 8]!
84 return { fill: '█'.repeat(full) + part, track: '░'.repeat(width - full - (part ? 1 : 0)) }
85}
86
87function Bar(props: { ratio: number; color: string; width: number; surface: string; el: any }) {
88 const { ratio, color, width, surface, el } = props
89 const r = Math.max(0, Math.min(1, ratio))
90 if (surface === 'terminal') {
91 const { fill, track } = smoothBar(r, width)
92 return (
93 <el.Text>
94 <el.Text color={color}>{fill}</el.Text>
95 <el.Text color={C.track}>{track}</el.Text>
96 </el.Text>
97 )
98 }
99 const w = Math.round(r * 1000)
100 // 8px pill centred in 14px: the transparent margin spaces rows on desktop.
101 // rx is wider than ry because the 1000-wide viewBox is squeezed to the pane.
102 const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="14" viewBox="0 0 1000 14" preserveAspectRatio="none"><rect y="3" width="1000" height="8" rx="10" ry="4" fill="${C.track}"/>${w > 0 ? `<rect y="3" width="${Math.max(w, 20)}" height="8" rx="10" ry="4" fill="${color}"/>` : ''}</svg>`
103 return (
104 <el.Box width="100%">
105 <el.Svg source={svg} alt={`${Math.round(r * 100)}%`} height={14} />
106 </el.Box>
107 )
108}
109
110function Title(props: { label: string; right?: string; el: any }) {
111 const { label, right, el } = props
112 return (
113 <el.Box flexDirection="row" justifyContent="space-between" width="100%">
114 <el.Text bold>{label}</el.Text>
115 {right !== undefined ? <el.Text bold color={C.accent}>{right}</el.Text> : undefined}
116 </el.Box>
117 )
118}
119
120function Row(props: { label: string; value: string; el: any; color?: string; dim?: boolean }) {
121 const { label, value, el, color, dim } = props
122 return (
123 <el.Box flexDirection="row" justifyContent="space-between" width="100%">
124 <el.Text color={C.muted}>{label}</el.Text>
125 <el.Text color={dim ? C.muted : color}>{value}</el.Text>
126 </el.Box>
127 )
128}
129
130async function pollGit($: any): Promise<void> {
131 try {
132 const branchRun = await $.process.run(['git', 'branch', '--show-current'], { timeoutMs: 3000 })
133 const statusRun = await $.process.run(['git', 'status', '--porcelain'], { timeoutMs: 3000 })
134 // Staged and unstaged edits against HEAD, so Lines agrees with Status after
135 // a `git add`. A repo with no commit yet has no HEAD: compare the index then.
136 let diffRun = await $.process.run(['git', 'diff', 'HEAD', '--numstat'], { timeoutMs: 3000 })
137 if (diffRun.exitCode !== 0) diffRun = await $.process.run(['git', 'diff', '--cached', '--numstat'], { timeoutMs: 3000 })
138 if (branchRun.exitCode !== 0) {
139 git = null // not a repo: the sidebar shows its empty state
140 return
141 }
142 const branch = String(branchRun.stdout).trim() || null
143 const statusLines =
144 statusRun.exitCode === 0 ? String(statusRun.stdout).split('\n').filter(l => l.trim().length > 0) : []
145 let added = 0
146 let removed = 0
147 for (const line of String(diffRun.stdout).split('\n')) {
148 const parts = line.split(/\s+/)
149 if (parts.length >= 2 && /^\d+$/.test(parts[0]!) && /^\d+$/.test(parts[1]!)) {
150 added += Number(parts[0])
151 removed += Number(parts[1])
152 }
153 }
154 git = { branch, dirty: statusLines.length > 0, changed: statusLines.length, added, removed }
155 } catch {
156 // not a repo or git missing: keep last known
157 }
158}
159
160// Share of all input served from the cache: uncached input counts against it
161// as much as cache writes do.
162function hitRate(): number | null {
163 const { cacheRead, cacheWrite, input } = sums
164 const total = cacheRead + cacheWrite + input
165 return total > 0 ? cacheRead / total : null
166}
167
168export const register: Register = on => {
169 on('session.start', async ($, e, next) => {
170 // Newer models (Opus 5.5 and up) get no task tools unless this is set, and
171 // without them there is nothing for the Tasks section to follow. Turn them
172 // on for this process, before the session builds its tool list; a value
173 // the person set themselves (0 to keep them off) is left alone.
174 try {
175 if ((await $.env.get('CLAUDE_CODE_ENABLE_TODO_TOOLS')) === undefined) {
176 await $.env.set('CLAUDE_CODE_ENABLE_TODO_TOOLS', '1')
177 }
178 } catch {
179 // no env access: the sidebar still opens, Tasks just stays hidden
180 }
181 const result = await next(e)
182 limitUse = new Map() // a new session counts its share of the limits from here
183
184 // Counters are this session's. Start from zero, and pick saved ones back up
185 // only for this same session (a resume or a plugin reload), never another's.
186 sums = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
187 last = null
188 sessionId = await $.session.id().catch(() => '')
189 const [stored, storedSums, storedFor] = await Promise.all([
190 $.store.get('last').catch(() => undefined),
191 $.store.get('sums').catch(() => undefined),
192 $.store.get('session').catch(() => undefined),
193 ])
194 const isSameSession = sessionId !== '' && storedFor === sessionId
195 if (isSameSession && stored && typeof stored === 'object') {
196 const s = stored as { tps?: unknown; ttftMs?: unknown; at?: unknown }
197 if (typeof s.tps === 'number' && Number.isFinite(s.tps)) {
198 last = {
199 tps: s.tps,
200 ttftMs: typeof s.ttftMs === 'number' && Number.isFinite(s.ttftMs) ? s.ttftMs : null,
201 at: typeof s.at === 'number' && Number.isFinite(s.at) ? s.at : 0,
202 }
203 }
204 }
205 if (isSameSession && storedSums && typeof storedSums === 'object') {
206 const s = storedSums as Sums
207 for (const k of ['input', 'output', 'cacheRead', 'cacheWrite'] as const) {
208 if (typeof s[k] === 'number' && Number.isFinite(s[k])) sums[k] = s[k]
209 }
210 }
211
212 await $.command.register({ name: 'sidebar', description: 'Show or hide the sidebar' })
213
214 // Dock the sidebar beside the transcript (columns => docked).
215 await $.ui
216 .open(PANE)
217 .then(r => { isOpen = r?.isPlaced !== false })
218 .catch(err => $.ui.log(`sidebar: pane not opened: ${err}`))
219
220 // Redraw once a second for the cache countdown, and ~8 times a second
221 // while a task is in progress so its spinner turns.
222 ;(tick as { cancel(): void } | undefined)?.cancel?.()
223 let ticks = 0
224 tick = $.clock.every(SPIN_MS, () => {
225 ticks++
226 if (tasks.some(t => t.status === 'in_progress') || ticks % Math.round(1000 / SPIN_MS) === 0) {
227 $.ui.invalidate('ui.render')
228 }
229 })
230
231 return result
232 })
233
234 on('command.run', { command: 'sidebar' }, async $ => {
235 const shown = await toggle($)
236 return { text: shown ? 'Sidebar shown.' : 'Sidebar hidden. /sidebar or ctrl+x s shows it again.' }
237 })
238
239 // While hidden, a one-line "show" button above the prompt keeps the shortcut
240 // alive: a Button's `action` chord only fires while that Button is mounted.
241 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
242 if (isOpen || e.props.hasSurvey) return next(e)
243 const { Box, Button } = (await $.ui.resolve(e)) as any
244 return (
245 <Box>
246 <Button key="toggle" label="Show sidebar ctrl+x s" plain dimColor action={TOGGLE_ACTION} onPress={() => toggle($)} />
247 </Box>
248 )
249 })
250
251 // The person can also close it with the pane's own x; keep the toggle in step.
252 on('ui.close', async ($, e, next) => {
253 if (e.id === PANE.id) {
254 isOpen = false
255 $.ui.invalidate('ui.render')
256 }
257 return next(e)
258 })
259
260 // Tasks: follow the main conversation's task tools. Subagents keep lists of
261 // their own (agentId set); those would muddle the plan shown here.
262 on('tool.call', async ($, e, next) => {
263 const out = await next(e)
264 if (e.agentId || !TASK_TOOLS.has(e.tool) || 'deny' in out || out.isError) return out
265 const args = e as unknown as Record<string, unknown>
266 if (e.tool === 'TodoWrite') tasks = applyTodoWrite(args.todos)
267 else if (e.tool === 'TaskCreate') tasks = applyTaskCreate(tasks, args, out.result, out.text)
268 else tasks = applyTaskUpdate(tasks, args)
269 taskStarts = trackStarts(tasks, taskStarts, await $.clock.now())
270 $.ui.invalidate('ui.render')
271 return out
272 })
273
274 on('turn.step', async function* ($, e, next) {
275 const isMain = !e.agentId // speed and the cache clock are the main conversation's
276 const startedAt = await $.clock.now()
277 let firstTokenAt: number | null = null
278 let lastTokenAt: number | null = null
279 let chars = 0
280 const stream = next(e)
281 try {
282 for await (const chunk of stream) {
283 // Every streamed piece of output counts: visible text, thinking, and
284 // tool-call arguments (a file the model writes streams as `input`).
285 const piece =
286 chunk.kind === 'text' || chunk.kind === 'thinking' ? chunk.text : chunk.kind === 'input' ? chunk.json : null
287 if (piece !== null && isMain) {
288 const now = await $.clock.now()
289 if (firstTokenAt === null) firstTokenAt = now
290 lastTokenAt = now
291 chars += piece.length
292 const tps = speed(Math.round(chars / 4), firstTokenAt, now)
293 live = { tps: tps ?? live?.tps ?? last?.tps ?? 0, ttftMs: firstTokenAt - startedAt, isWarmingUp: tps === null }
294 if (now - lastInvalidateAt > 500) {
295 lastInvalidateAt = now
296 $.ui.invalidate('ui.render')
297 }
298 }
299 yield chunk
300 }
301 } finally {
302 live = null
303 }
304
305 const result = await stream.result
306 const usage = result.usage
307 if (usage) {
308 sums = {
309 input: sums.input + (usage.input_tokens ?? 0),
310 output: sums.output + (usage.output_tokens ?? 0),
311 cacheRead: sums.cacheRead + (usage.cache_read_input_tokens ?? 0),
312 cacheWrite: sums.cacheWrite + (usage.cache_creation_input_tokens ?? 0),
313 }
314 $.store.set('sums', sums).catch(err => {
315 $.ui.log(`sidebar: store write failed: ${err}`)
316 })
317 $.store.set('session', sessionId).catch(() => undefined)
318 }
319 if (isMain && (usage || chars > 0)) {
320 // A main-conversation response arrived: the cache clock restarts whether
321 // or not the speed reading below is trustworthy.
322 const tps = firstTokenAt === null ? null : speed(usage?.output_tokens ?? Math.round(chars / 4), firstTokenAt, lastTokenAt!)
323 last = {
324 tps: tps ?? last?.tps ?? 0,
325 ttftMs: firstTokenAt === null ? (last?.ttftMs ?? null) : firstTokenAt - startedAt,
326 // The cache was last used when this request was read, not when the
327 // reply finished: counting from the end would overstate time left.
328 at: startedAt,
329 }
330 $.store.set('last', last).catch(err => {
331 $.ui.log(`sidebar: store write failed: ${err}`)
332 })
333 }
334 live = null
335 $.ui.invalidate('ui.render')
336 return result
337 })
338
339 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
340 if (e.requestId !== 'meter') return next(e)
341 // Being drawn means it is open. An unasked open below 144 columns waits
342 // ({ isPlaced: false }) and is seated later, after session.start already
343 // recorded it closed; without this the band's "Show sidebar" lingers.
344 if (!isOpen) {
345 isOpen = true
346 $.ui.invalidate('ui.render') // drop the band's show button
347 }
348 const els = (await $.ui.resolve(e)) as any // Svg exists on desktop, not terminal
349 const { Box, Text } = els
350
351 // CONTEXT figures from the session usage (same source as the status line).
352 let contextPct: number | null = null
353 let contextLine = '— / —'
354 let rateLimits: { kind: string; percentUsed: number }[] = []
355 let costUsd: number | undefined
356 try {
357 const usage = await $.session.usage()
358 rateLimits = usage?.rateLimits ?? []
359 costUsd = usage?.cost?.usd
360 limitUse = trackLimitUse(limitUse, rateLimits)
361 if (usage && usage.context && usage.context.window) {
362 const used = usage.context.tokens ?? 0
363 contextPct = used / usage.context.window
364 contextLine = `${compact(used)} / ${compact(usage.context.window)}`
365 }
366 } catch {
367 // a test or a build without session.usage: draw without context
368 }
369
370 // Prompt-cache TTL of the main conversation, resolved as Claude Code does.
371 const [envTtl, force5m, enable1h, settings] = await Promise.all([
372 $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL').catch(() => undefined),
373 $.env.get('FORCE_PROMPT_CACHING_5M').catch(() => undefined),
374 $.env.get('ENABLE_PROMPT_CACHING_1H').catch(() => undefined),
375 $.settings.read().catch(() => ({}) as Record<string, unknown>),
376 ])
377 const ttl = resolveTtl({
378 envTtl,
379 force5m,
380 enable1h,
381 setting: settings.promptCacheTtl,
382 rateLimits,
383 })
384 const ttlMin = ttl.minutes
385
386 const now = await $.clock.now()
387 if (now - gitAt > 5000) {
388 gitAt = now
389 await pollGit($)
390 }
391 const remaining = last ? ttlMin * 60_000 - (now - last.at) : null
392 const remainingRatio = remaining !== null ? remaining / (ttlMin * 60_000) : 0
393
394 let workspace = ''
395 try {
396 workspace = await $.session.cwd()
397 } catch {
398 // keep defaults
399 }
400 const workspaceName = workspace ? workspace.replace(/\\/g, '/').split('/').filter(Boolean).pop() ?? '' : '—'
401
402 const liveStats = live ?? last
403 const rate = hitRate()
404 const contextBarRatio = contextPct ?? 0
405
406 const ctxPctText = contextPct === null ? '—' : `${(contextPct * 100).toFixed(1)}%`
407 const rateText = rate === null ? '—' : `${(rate * 100).toFixed(1)}%`
408 const validText = remaining === null ? '—' : formatCountdown(remaining)
409 const speedText = liveStats && liveStats.tps > 0 ? formatTps(liveStats.tps) : '—'
410 // Expiry is the one value that changes meaning near zero: color it then.
411 const expiryColor = remaining === null ? C.muted : remaining < 30_000 ? C.bad : remaining < 90_000 ? C.warn : undefined
412 const el = els
413 const barW = Math.max(10, (e.props.bodyColumns || 30) - 3) // minus paddingLeft 1 + paddingRight 2
414
415 // Main-screen terminal seats the pane inline above the prompt, full width:
416 // a sidebar layout there is a takeover, so draw a compact strip instead.
417 if (e.props.placement === 'inline') {
418 const dot = <Text color={C.track}> · </Text>
419 return (
420 <Box flexDirection="column" width="100%">
421 <Box flexDirection="row" width="100%" flexWrap="wrap">
422 <Text color={C.muted}>Context </Text>
423 <Bar ratio={contextBarRatio} color={C.accent} width={16} surface={e.surface} el={el} />
424 <Text bold color={C.accent}> {ctxPctText}</Text>
425 {dot}
426 <Text color={C.muted}>Cache </Text><Text>{rateText}</Text>
427 <Text color={C.muted}> expires </Text><Text color={expiryColor}>{validText}</Text>
428 {dot}
429 <Text color={C.muted}>Speed </Text><Text>{speedText}</Text>
430 {dot}
431 <Text color={C.muted}>Total </Text>
432 <Text>{compact(sums.input + sums.output + sums.cacheRead + sums.cacheWrite)}</Text>
433 </Box>
434 <Text color={C.muted}>Run /tui fullscreen to dock this as a sidebar.</Text>
435 </Box>
436 )
437 }
438
439 const gap = <Box height={1} />
440 return (
441 <Box flexDirection="column" width="100%" minHeight={e.props.scroll?.bodyRows} paddingLeft={1} paddingRight={2} paddingTop={1}>
442 {tasks.length > 0 ? (
443 <Box flexDirection="column" width="100%" marginBottom={1}>
444 <Title label="Tasks" right={`${taskDone(tasks)}/${tasks.length}${taskFailed(tasks) ? ` · ${taskFailed(tasks)} ✗` : ''}`} el={el} />
445 <Bar ratio={(taskDone(tasks) + taskFailed(tasks)) / tasks.length} color={C.accent} width={barW} surface={e.surface} el={el} />
446 <TaskRows tasks={tasks} starts={taskStarts} now={now} el={el} />
447 </Box>
448 ) : undefined}
449 <Title label="Context" right={ctxPctText} el={el} />
450 <Bar ratio={contextBarRatio} color={C.accent} width={barW} surface={e.surface} el={el} />
451 <Text color={C.muted}>{contextLine} tokens</Text>
452 {gap}
453
454 <Title label="Tokens" el={el} />
455 <Row label="Input" value={compact(sums.input)} el={el} />
456 <Row label="Output" value={compact(sums.output)} el={el} />
457 <Row label="Cache read" value={compact(sums.cacheRead)} el={el} />
458 <Row label="Cache write" value={compact(sums.cacheWrite)} el={el} />
459 <Box flexDirection="row" justifyContent="space-between" width="100%">
460 <Text>Total</Text>
461 <Text bold>{compact(sums.input + sums.output + sums.cacheRead + sums.cacheWrite)}</Text>
462 </Box>
463 {/* What this session spent, in the unit the person pays in: a share of
464 the plan's limits on a subscription, dollars on an API key. */}
465 {rateLimits.length > 0 ? (
466 <Box flexDirection="column" width="100%">
467 {LIMIT_ROWS.filter(([kind]) => limitUse.has(kind)).map(([kind, label]) => (
468 <Row key={`limit-${kind}`} label={label} value={formatShare(sessionShare(limitUse.get(kind)!))} el={el} />
469 ))}
470 </Box>
471 ) : costUsd !== undefined && costUsd > 0 ? ( // > 0: before the first reply, a subscriber has no readings yet
472 <Row label="Cost" value={formatUsd(costUsd)} el={el} />
473 ) : undefined}
474 {gap}
475
476 <Title label="Cache" el={el} />
477 <Row label="Hit rate" value={rateText} el={el} color={C.accent} />
478 <Bar ratio={rate ?? 0} color={C.accent} width={barW} surface={e.surface} el={el} />
479 {gap}
480 <Box flexDirection="row" justifyContent="space-between" width="100%">
481 <Text color={C.muted}>Expires in</Text>
482 <Text>
483 <Text color={expiryColor}>{validText}</Text>
484 <Text color={C.muted}> / {ttlMin === 60 ? '1h' : '5m'}</Text>
485 </Text>
486 </Box>
487 <Bar ratio={remainingRatio} color={expiryColor ?? C.accent} width={barW} surface={e.surface} el={el} />
488 {/* Which rule set the TTL, on its own line: beside the time it overflowed a narrow pane. */}
489 <Text color={C.muted}>{ttlMin === 60 ? '1h' : '5m'} cache · {ttl.source}</Text>
490 {gap}
491
492 <Title label="Speed" el={el} />
493 <Row label="First token" value={formatTtft(liveStats?.ttftMs ?? null)} el={el} />
494 <Row label="Output" value={speedText} el={el} />
495 {gap}
496
497 <Title label="Workspace" el={el} />
498 <Row label="Folder" value={workspaceName} el={el} />
499 {git ? (
500 <Box flexDirection="column" width="100%">
501 <Row label="Branch" value={git.branch ?? 'detached'} el={el} />
502 <Row label="Status" value={git.dirty ? `${git.changed} changed` : 'Clean'} el={el} color={git.dirty ? C.warn : C.ok} />
503 <Box flexDirection="row" justifyContent="space-between" width="100%">
504 <Text color={C.muted}>Lines</Text>
505 <Text>
506 <Text color={C.ok}>+{compact(git.added)}</Text>
507 <Text color={C.muted}> </Text>
508 <Text color={C.bad}>−{compact(git.removed)}</Text>
509 </Text>
510 </Box>
511 </Box>
512 ) : (
513 <Row label="Git" value="Not a repository" el={el} dim />
514 )}
515
516 {/* Spacer: the pane's body is bodyRows tall, so the toggle sits at its foot. */}
517 <Box flexGrow={1} minHeight={1} />
518 <els.Button key="toggle" label="Hide sidebar ctrl+x s" plain dimColor action={TOGGLE_ACTION} onPress={() => toggle($)} />
519 </Box>
520 )
521 })
522}
523
524// Borrowed engine action: the person binds a chord to it in keybindings.json
525// (ctrl+x s -> app:toggleReplTab), and that chord presses whichever toggle
526// button is mounted. ponytail: no custom-action API for plugins yet; swap the
527// name if the engine ever mounts its own handler for this action.
528const TOGGLE_ACTION = 'app:toggleReplTab'
529
530async function toggle($: any): Promise<boolean> {
531 if (isOpen) {
532 await $.ui.close({ id: PANE.id })
533 isOpen = false
534 } else {
535 await $.ui.open(PANE)
536 isOpen = true
537 }
538 $.ui.invalidate('ui.render') // the band's show button appears/disappears
539 return isOpen
540}
541
542// Prompt-cache TTL for the main conversation, per Claude Code's own
543// `promptCacheTtl` setting description: CLAUDE_CODE_PROMPT_CACHE_TTL wins,
544// then the setting, then automatic (1 hour on a Claude subscription within its
545// usage limits, 5 minutes on an API key, Bedrock, Vertex or Foundry).
546// rateLimits is empty off a subscription; any window at 100% means over limits.
547// ponytail: where FORCE_PROMPT_CACHING_5M / ENABLE_PROMPT_CACHING_1H sit
548// against the setting is not documented; they rank just under the env TTL.
549export function resolveTtl(i: {
550 envTtl?: string
551 force5m?: string
552 enable1h?: string
553 setting?: unknown
554 rateLimits: { percentUsed: number }[]
555}): { minutes: 5 | 60; source: string } {
556 if (i.envTtl === '1h') return { minutes: 60, source: 'env' }
557 if (i.envTtl === '5m') return { minutes: 5, source: 'env' }
558 if (i.force5m === '1') return { minutes: 5, source: 'env' }
559 if (i.enable1h === '1') return { minutes: 60, source: 'env' }
560 if (i.setting === '1h') return { minutes: 60, source: 'setting' }
561 if (i.setting === '5m') return { minutes: 5, source: 'setting' }
562 if (i.rateLimits.length === 0) return { minutes: 5, source: 'API key' }
563 if (i.rateLimits.some(r => r.percentUsed >= 100)) return { minutes: 5, source: 'over limit' }
564 return { minutes: 60, source: 'subscription' }
565}
566
567// Output tokens per second over the streaming span (first piece -> last piece).
568// Under MIN_SPAN_MS the span is mostly network burst, not generation: a reply
569// that lands in one or two chunks would read as thousands of tok/s, so no
570// reading is taken (null) and the previous one stays on screen.
571const MIN_SPAN_MS = 500
572export function speed(tokens: number, firstAt: number, lastAt: number): number | null {
573 const spanMs = lastAt - firstAt
574 if (tokens <= 0 || spanMs < MIN_SPAN_MS) return null
575 return round1((tokens / spanMs) * 1000)
576}
577
578// ---- Tasks ----
579// Two task tools exist: TodoWrite sends the whole list every call; TaskCreate /
580// TaskUpdate add or change one task at a time, by id.
581type TaskStatus = 'pending' | 'in_progress' | 'completed'
582type Task = { id: string; title: string; active?: string; status: TaskStatus }
583const TASK_TOOLS = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate'])
584const asStatus = (v: unknown): TaskStatus | null =>
585 v === 'pending' || v === 'in_progress' || v === 'completed' ? v : null
586const str = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : undefined)
587const taskDone = (list: Task[]) => list.filter(t => t.status === 'completed' && !failedTitle(t.title)).length
588const taskFailed = (list: Task[]) => list.filter(t => t.status === 'completed' && failedTitle(t.title)).length
589
590// The task tools have no "failed" state: Claude marks a failed task completed
591// and says so in its title ("FAILED: Run setup_db.py"). Read that prefix as a
592// failure; the rest of the title is the task. ponytail: a heuristic on Claude's
593// wording; extend FAIL_PREFIX if it starts using another word.
594const FAIL_PREFIX = /^\s*(?:failed|failure|error|blocked|skipped|cancell?ed)\b\s*[:\-–—]?\s*/i
595export function failedTitle(title: string): string | null {
596 const m = FAIL_PREFIX.exec(title)
597 return m && m[0].trim() ? title.slice(m[0].length) || title : null
598}
599
600export function applyTodoWrite(todos: unknown): Task[] {
601 if (!Array.isArray(todos)) return []
602 return todos.flatMap((t, i) => {
603 const title = str(t?.content)
604 return title ? [{ id: String(i), title, active: str(t?.activeForm), status: asStatus(t?.status) ?? 'pending' }] : []
605 })
606}
607
608export function applyTaskCreate(list: Task[], args: Record<string, unknown>, result: unknown, text?: string): Task[] {
609 const title = str(args.subject)
610 if (!title) return list
611 // The new id comes back in the result: its record, or "Task #12 created ...".
612 const rec = (result as { task?: { id?: unknown } } | null)?.task?.id
613 const id = rec !== undefined ? String(rec) : (/#(\w+)/.exec(text ?? '')?.[1] ?? `new-${list.length}`)
614 return [...list.filter(t => t.id !== id), { id, title, active: str(args.activeForm), status: 'pending' }]
615}
616
617export function applyTaskUpdate(list: Task[], args: Record<string, unknown>): Task[] {
618 const id = str(String(args.taskId ?? ''))
619 if (!id) return list
620 if (args.status === 'deleted') return list.filter(t => t.id !== id)
621 return list.map(t =>
622 t.id !== id
623 ? t
624 : {
625 ...t,
626 status: asStatus(args.status) ?? t.status,
627 title: str(args.subject) ?? t.title,
628 active: str(args.activeForm) ?? t.active,
629 },
630 )
631}
632
633// Every task, one row each, in the order Claude made them: ✓ done (dimmed),
634// ▸ in progress (accent, in its "-ing" form), ○ not started. The task tools
635// have no "failed" state, so none is shown. Past MAX_TASK_ROWS the rest fold
636// into "+N more", keeping the in-progress row in view.
637const MAX_TASK_ROWS = 8
638const TASK_MARK = { completed: '✓', in_progress: '▸', pending: '○' } as const
639
640export function visibleTasks(list: Task[], max = MAX_TASK_ROWS): { rows: Task[]; more: number } {
641 if (list.length <= max) return { rows: list, more: 0 }
642 // Start the window just before the task in progress (or the first not done).
643 const focus = list.findIndex(t => t.status === 'in_progress')
644 const pivot = focus >= 0 ? focus : Math.max(0, list.findIndex(t => t.status !== 'completed'))
645 const start = Math.min(Math.max(0, pivot - 2), list.length - (max - 1))
646 const rows = list.slice(start, start + max - 1)
647 return { rows, more: list.length - rows.length }
648}
649
650function TaskRows(props: { tasks: Task[]; starts: Map<string, number>; now: number; el: any }) {
651 const { tasks, starts, now, el } = props
652 const { rows, more } = visibleTasks(tasks)
653 return (
654 <el.Box flexDirection="column" width="100%">
655 {rows.map(t => {
656 const failed = t.status === 'completed' ? failedTitle(t.title) : null
657 return (
658 <el.Box key={`task-${t.id}`} flexDirection="row" width="100%">
659 <el.Text color={failed !== null ? C.bad : t.status === 'completed' ? C.ok : t.status === 'in_progress' ? C.accent : C.muted}>
660 {t.status === 'in_progress' ? spinnerFrame(now) : failed !== null ? '✗' : TASK_MARK[t.status]}{' '}
661 </el.Text>
662 <el.Text
663 wrap="truncate"
664 bold={t.status === 'in_progress'}
665 color={t.status === 'in_progress' ? undefined : C.muted}
666 >
667 {t.status === 'in_progress' ? (t.active ?? t.title) : (failed ?? t.title)}
668 </el.Text>
669 {t.status === 'in_progress' && starts.has(taskKey(t)) ? (
670 <el.Box flexGrow={1} justifyContent="flex-end">
671 <el.Text color={C.muted}> {formatElapsed(now - starts.get(taskKey(t))!)}</el.Text>
672 </el.Box>
673 ) : undefined}
674 </el.Box>
675 )
676 })}
677 {more > 0 ? <el.Text color={C.muted}>+{more} more</el.Text> : undefined}
678 </el.Box>
679 )
680}
681
682// ---- In-progress animation ----
683const SPIN_MS = 125
684const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
685// 0:42, 12:05, 1:02:09
686export function formatElapsed(ms: number): string {
687 const t = Math.max(0, Math.floor(ms / 1000))
688 const h = Math.floor(t / 3600)
689 const m = Math.floor((t % 3600) / 60)
690 const s = String(t % 60).padStart(2, '0')
691 return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`
692}
693export const spinnerFrame = (now: number) => SPINNER[Math.floor(now / SPIN_MS) % SPINNER.length]!
694// TodoWrite ids are list positions, so the title is part of the key.
695const taskKey = (t: Task) => `${t.id}:${t.title}`
696
697// When each in-progress task started: kept while it stays in progress,
698// stamped `now` when it first appears so, dropped once it leaves.
699export function trackStarts(list: Task[], prev: Map<string, number>, now: number): Map<string, number> {
700 const next = new Map<string, number>()
701 for (const t of list) if (t.status === 'in_progress') next.set(taskKey(t), prev.get(taskKey(t)) ?? now)
702 return next
703}
704
705// $0.004 -> <$0.01, $3.456 -> $3.46, $1234.5 -> $1,235
706export function formatUsd(usd: number): string {
707 if (usd > 0 && usd < 0.01) return '<$0.01'
708 const cents = Math.round(usd * 100) / 100 // $99.999 is $100, so whole dollars
709 if (cents < 100) return `$${cents.toFixed(2)}`
710 return `$${Math.round(usd).toLocaleString('en-US')}`
711}
712
713// ---- This session's share of the plan's limits (subscriptions) ----
714// Claude Code reports each window's % used for the whole account. The first
715// reading this session is the baseline; what it has climbed since is this
716// session's share. When a window resets the % drops: bank what was used before
717// the reset and count on from 0. ponytail: the baseline arrives after the first
718// reply, so that reply goes uncounted, and use elsewhere on the account in the
719// same window counts here too; Claude Code reports nothing finer.
720type LimitUse = { base: number; last: number; banked: number }
721const LIMIT_ROWS = [
722 ['five_hour', '5-hour limit'],
723 ['seven_day', 'Weekly limit'],
724] as const
725
726export function trackLimitUse(
727 prev: Map<string, LimitUse>,
728 readings: { kind: string; percentUsed: number }[],
729): Map<string, LimitUse> {
730 const next = new Map(prev)
731 for (const { kind, percentUsed: p } of readings) {
732 const u = prev.get(kind)
733 if (!u) next.set(kind, { base: p, last: p, banked: 0 })
734 else if (p < u.last) next.set(kind, { base: 0, last: p, banked: u.banked + (u.last - u.base) }) // window reset
735 else next.set(kind, { ...u, last: p })
736 }
737 return next
738}
739
740export const sessionShare = (u: LimitUse) => u.banked + (u.last - u.base)
741
742// +9%, +0.4%, <0.1%, 0%
743export function formatShare(pct: number): string {
744 if (pct <= 0) return '0%'
745 if (pct < 0.1) return '<0.1%'
746 return `+${pct < 10 ? +pct.toFixed(1) : Math.round(pct)}%`
747}
748