SLOPSHOPPER

sidebar

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

newpanebandguardcommandprocess
★ 3v0.1.20MITupdated 2026-10-04FayequeP/sidebar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sidebar
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /sidebar ⎿ sidebar: Sidebar hidden. /sidebar or ctrl+x s shows it again. Show sidebar ctrl+x s ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Show sidebar ctrl+x s
README

sidebar

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

Updates

Know what every session costs · v0.1.20

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.

Tasks · v0.1.19

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.

Install

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.

What it shows

SectionWhat you get
TasksThe 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
ContextHow full the context window is, as a bar and 85.4k / 272k
TokensThis 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
CacheHit rate (share of all input served from the cache), and a countdown to when the main conversation's prompt cache expires
SpeedTime to first token and output speed in tokens per second
WorkspaceFolder, 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.

Show and hide

  • Type /sidebar, or press the Hide sidebar button at its foot.
  • For a keyboard shortcut (terminal), add this to ~/.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.

Resize

  • Desktop app: drag the sidebar's edge.
  • Terminal: click the sidebar to focus it, then press 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.

How the numbers are measured

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 asRuleLasts
envCLAUDE_CODE_PROMPT_CACHE_TTL set to 5m or 1has set
envFORCE_PROMPT_CACHING_5M=15 min
envENABLE_PROMPT_CACHING_1H=11 hour
setting"promptCacheTtl": "5m" or "1h" in settings.jsonas set
subscriptionClaude subscription within its usage limits1 hour
over limitSubscription with a usage window at 100%5 min
API keyAPI key, Bedrock, Vertex or Foundry5 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.

License

MIT. Contributions welcome, see CONTRIBUTING.md.

Source 1 files
hooks/register.tsx 748 lines
1/* @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