A side pane that shows the session turn by turn, like Anthropic's context-window diagram: every model request with its input (system and tools, earlier turns…

A side pane that shows the session turn by turn, the way Anthropic's context-window diagram draws it. Every request to the model is a turn. Its input (the system prompt and tools, every earlier turn, and what is new: your message or the tool results) is stacked above its output (thinking, text, tool use), and a bar shows how much of the context window it fills.
Context window 41.9k / 1.0M 4%
███░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
■ system ■ earlier ■ new ■ output
❯ read auth.js, then run 'grep -rn TODO .' with Bash…
▸ #1 2× Read, Bash ██░░░░░░░░░░░░ 41.1k
▾ #2 answer ██░░░░░░░░░░░░ 41.7k
┃ INPUT 41.2k · 41.2k cached
┃ ⚙ System, tools & memory 40.8k
┃ ↺ Earlier turns (1) 276
┃ ⇠ Tool result · Read ~96
┃ ⇠ Tool result · Bash ~37
┃ OUTPUT +474
┃ ✻ Thinking ~263
┃ ¶ Text response ~211
claude-opus-5-5 · end_turn
[ following ] [ collapse ] [ close ]
Run /turns to open it, /turns clear to forget the turns so far, /turns stop to close it.
2× Read, Bash, answer, cut off), its bar, and the context in use once it finished.⚙ System, tools & memory: what every request carries before the conversation.↺ Earlier turns (n): every earlier turn, folded into one block.❯ User message / ⇠ Tool result · <tool>: what is new in this request.✻ Thinking, ¶ Text response, ⚒ Tool use · <tool>: the response.✂ Cut off: the response hit the output limit or a full context window, as in the diagram's last turn.By default the pane follows the newest turn and keeps it expanded. Expanding or collapsing a row yourself stops that; follow turns it back on.
Each turn's input and output totals are the API's own usage for that request (input = uncached + cache read + cache write), so they are exact. Blocks marked ~ are an estimate. The engine reports one total per request, not per block, so the mod splits each total across its blocks by size (about 4 characters a token):
/context estimate, marked ~.A turn whose input shrank against the one before is marked compacted before this turn.
Turns are recorded from the moment the mod loads, whether the pane is open or not, so opening it late still shows every turn so far. Subagents' requests are not listed; the agent-flow mod draws those.
In the fullscreen layout the engine docks the pane beside the transcript. Turn it on with /tui fullscreen (needs a terminal at least 110 columns wide). On the classic layout the pane sits above the prompt instead. Click a row to expand it, or focus the pane (ctrl+x tab), move with Tab and press Enter. In Claude Desktop the pane draws in the session's side panel, and every padded cell uses non-breaking spaces so the chips keep their width.
Set them in user or managed settings under pluginConfigs["turn-inspector@skills-dir"].options ("turn-inspector" when loaded with --plugin-dir):
| Option | Default | What it does |
|---|---|---|
columns | 56 | Width asked for the docked pane (36-120); a width you drag it to wins |
maxTurns | 200 | Turns kept, oldest dropped first |
openOnStart | false | Open the pane when the session starts |
It only observes: every hook passes its event on unchanged.
| Event | What it records |
|---|---|
turn.start | the prompt, the first new input of the next request |
turn.step | each main-loop request: its stream (thinking, text, tool use blocks), its usage and stop reason |
tool.call | each tool's answer, new input for the next request |
turn.complete | refreshes the system/tools estimate while the pane is open |
session.measure | the context window's size |
npx claude-code-templates@latest --mod ui/turn-inspector
It is written to .claude/skills/turn-inspector/, which Claude Code auto-loads as turn-inspector@skills-dir in a trusted project. For one session: claude --plugin-dir .claude/skills/turn-inspector.
If /turns is missing from the typeahead, the mod did not load: run claude --debug and look for hooks module turn-inspector@… loaded in ~/.claude/debug/latest.
claude plugin test .claude/skills/turn-inspector
The tests drive the turn model directly, then raise turn.start, turn.step (with thinking, tool and text chunks), tool.call and turn.complete through the engine, and mount the pane on both the terminal and desktop surfaces.
Requirements. Mods are on by default in Claude Code 2.1.287+; builds 2.1.259 to 2.1.286 need CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1. Written and tested on 2.1.296 against the 2.1.283 declarations in this repo.
hooks/turn-inspector.tsx 419 lines1/**
2 * turn-inspector — Claude Mod
3 *
4 * A side pane that draws every model request of the session as a "turn", the
5 * way Anthropic's context-window diagram does: each turn's input (system
6 * prompt and tools, every earlier turn, what is new) stacked above its output
7 * (thinking, text, tool use), and a bar showing how much of the context
8 * window it fills. Click a turn (or Tab and Enter) to expand its blocks.
9 *
10 * `/turns` opens it · `/turns clear` forgets the turns so far ·
11 * `/turns stop` closes it
12 *
13 * Totals are the API's own `usage` per request; the split of a total across
14 * its blocks is an estimate (about 4 characters a token), marked `~`.
15 * It only observes: every hook passes its event on unchanged.
16 *
17 * Needs Claude Code >= 2.1.287.
18 *
19 * Options (pluginConfigs["turn-inspector@skills-dir"].options):
20 * columns: number width asked for the docked pane (default 56)
21 * maxTurns: number turns kept, oldest dropped first (default 200)
22 * openOnStart: boolean open the pane when the session starts (default false)
23 */
24import type { Register, RenderChildren, RenderSurface } from 'claude-code'
25import {
26 barCells,
27 cell,
28 chunk,
29 contextAfter,
30 createLedger,
31 fit,
32 fmtTokens,
33 gist,
34 inputStack,
35 maxTurns,
36 paneColumns,
37 promptStarted,
38 stepEnded,
39 stepStarted,
40 systemTokens,
41 toolAnswered,
42} from './turns.ts'
43import type { Block, BlockKind, Ledger, Turn } from './turns.ts'
44
45const PANE = 'turn-inspector'
46const COMMAND = 'turns'
47const KEY = 'turn:'
48
49/** The diagram's palette: input in terracotta, output in grey, thinking near-black. */
50const INK = '#f4ece6'
51const STYLE: Record<BlockKind, { icon: string; bg: string; fg: string }> = {
52 system: { icon: '⚙', bg: '#4a2a1d', fg: '#e8cbb8' },
53 history: { icon: '↺', bg: '#6b3d2a', fg: INK },
54 user: { icon: '❯', bg: '#9a5a3e', fg: INK },
55 tool_result: { icon: '⇠', bg: '#9a5a3e', fg: INK },
56 thinking: { icon: '✻', bg: '#151515', fg: '#d6d6d6' },
57 text: { icon: '¶', bg: '#5e5e5e', fg: INK },
58 tool_use: { icon: '⚒', bg: '#5e5e5e', fg: INK },
59 truncated: { icon: '✂', bg: '#2b2b2b', fg: '#9a9a9a' },
60}
61const INPUT_RAIL = '#c8734f'
62const OUTPUT_RAIL = '#8c8c8c'
63const BAR = { system: '#4a2a1d', history: '#8a4f37', added: '#d9875f', output: '#9a9a9a', free: '#3a3a3a' }
64
65let ledger: Ledger = createLedger()
66let live: Turn | undefined
67let isOpen = false
68let follow = true
69const expanded = new Set<number>()
70
71export const register: Register = (on, options) => {
72 const columns = paneColumns(options.columns)
73 const keep = maxTurns(options.maxTurns)
74 const openOnStart = options.openOnStart === true
75
76 on('session.start', async ($, e, next) => {
77 const r = await next(e)
78 ledger = createLedger()
79 live = undefined
80 expanded.clear()
81 follow = true
82 await $.command
83 .register({
84 name: COMMAND,
85 description: 'Turn-by-turn view of the context window: each request’s input and output blocks (clear|stop)',
86 argumentHint: '[clear|stop]',
87 immediate: true,
88 })
89 .catch(err => $.ui.log(`turn-inspector: /${COMMAND} not registered: ${err}`))
90 const usage = await $.session.usage().catch(() => undefined)
91 if (usage) ledger.window = usage.context.window
92 // a fresh session's first request measures what every request carries
93 ledger.isFresh = (await $.session.messages().catch(() => [1])).length === 0
94 if (openOnStart) {
95 isOpen = true
96 await $.ui.open({ id: PANE, title: 'turns', columns }).catch(err => {
97 isOpen = false
98 $.ui.log(`turn-inspector: pane not opened: ${err}`)
99 })
100 }
101 return r
102 })
103
104 on('command.run', { command: COMMAND }, async ($, e) => {
105 const arg = e.args.trim().toLowerCase()
106 if (arg === 'stop' || arg === 'close') {
107 await $.ui.close({ id: PANE }).catch(() => undefined)
108 isOpen = false
109 return { text: 'turn-inspector closed' }
110 }
111 if (arg === 'clear') {
112 const window = ledger.window
113 const { system, baseline } = ledger
114 ledger = Object.assign(createLedger(), { window, system, baseline })
115 live = undefined
116 expanded.clear()
117 if (isOpen) $.ui.invalidate('ui.render')
118 return { text: 'turn-inspector: turns cleared' }
119 }
120 const usage = await $.session.usage({ breakdown: 'summary', columns }).catch(() => undefined)
121 if (usage) {
122 ledger.window = usage.context.window
123 const system = systemTokens(usage.context.breakdown?.categories ?? [])
124 if (system !== undefined) ledger.system = system
125 }
126 try {
127 await $.ui.open({ id: PANE, title: 'turns', focus: true, columns })
128 } catch (err) {
129 isOpen = false
130 return { text: `turn-inspector: pane not opened: ${err}` }
131 }
132 isOpen = true
133 $.ui.invalidate('ui.render')
134 const hint = e.presentation.isFullscreen
135 ? 'click a turn, or Tab and Enter, to expand it'
136 : 'drawn above the prompt; /tui fullscreen docks it beside the transcript'
137 return { text: `${ledger.turns.length} turns so far · ${hint} · /${COMMAND} stop closes` }
138 })
139
140 on('turn.start', async ($, e, next) => {
141 promptStarted(ledger, e.turnId, e.text)
142 return next(e)
143 })
144
145 // a tool's answer is new input for the next request
146 on('tool.call', async ($, e, next) => {
147 const r = await next(e)
148 if (e.agentId !== undefined) return r
149 const text = r.deny ?? r.text ?? (r.result === undefined ? '' : safeJson(r.result))
150 toolAnswered(ledger, e.tool, text.length, r.deny !== undefined || r.isError === true)
151 return r
152 })
153
154 // each request of the main loop is a turn; its stream gives the output blocks
155 on('turn.step', async function* ($, e, next) {
156 if (e.agentId !== undefined) return yield* next(e)
157 const turn = stepStarted(ledger, e.turnId, e.index, e.model, keep)
158 live = turn
159 if (follow) {
160 expanded.clear()
161 expanded.add(turn.n)
162 }
163 if (isOpen) $.ui.invalidate('ui.render')
164 const names = new Map<number, string>()
165 const stream = next(e)
166 for await (const c of stream) {
167 let isNew = false
168 if (c.kind === 'thinking') isNew = chunk(turn, c.index, 'thinking', c.text)
169 else if (c.kind === 'text') isNew = chunk(turn, c.index, 'text', c.text)
170 else if (c.kind === 'tool') {
171 names.set(c.index, c.name)
172 isNew = chunk(turn, c.index, 'tool_use', c.name, c.name)
173 } else if (c.kind === 'input') isNew = chunk(turn, c.index, 'tool_use', c.json, names.get(c.index))
174 if (isNew && isOpen) $.ui.invalidate('ui.render')
175 yield c
176 }
177 const r = await stream.result
178 stepEnded(ledger, turn, r.usage, r.stopReason)
179 if (live === turn) live = undefined
180 if (isOpen) $.ui.invalidate('ui.render')
181 return r
182 })
183
184 on('turn.complete', async ($, e, next) => {
185 const r = await next(e)
186 if (e.agentId === undefined && isOpen) {
187 const usage = await $.session.usage({ breakdown: 'summary', columns }).catch(() => undefined)
188 const system = systemTokens(usage?.context.breakdown?.categories ?? [])
189 if (system !== undefined) ledger.system = system
190 if (isOpen) $.ui.invalidate('ui.render')
191 }
192 return r
193 })
194
195 on('session.measure', async ($, e, next) => {
196 const r = await next(e)
197 if (e.context.window !== ledger.window) {
198 ledger.window = e.context.window
199 if (isOpen) $.ui.invalidate('ui.render')
200 }
201 return r
202 })
203
204 on('ui.close', async ($, e, next) => {
205 if (e.id !== PANE) return next(e)
206 const r = await next(e)
207 isOpen = false
208 return r
209 })
210
211 on('ui.press', async ($, e, next) => {
212 if (e.plugin !== $.plugin.name || e.requestId !== PANE) return next(e)
213 const r = await next(e)
214 const key = e.element
215 if (key === 'close') {
216 await $.ui.close({ id: PANE }).catch(() => undefined)
217 return r
218 }
219 if (key === 'follow') {
220 follow = !follow
221 const last = ledger.turns.at(-1)
222 if (follow && last) {
223 expanded.clear()
224 expanded.add(last.n)
225 }
226 } else if (key === 'collapse') {
227 expanded.clear()
228 follow = false
229 } else if (key.startsWith(KEY)) {
230 const n = Number(key.slice(KEY.length))
231 if (expanded.has(n)) {
232 expanded.delete(n)
233 if (n === ledger.turns.at(-1)?.n) follow = false
234 } else expanded.add(n)
235 }
236 if (isOpen) $.ui.invalidate('ui.render')
237 return r
238 })
239
240 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
241 if (e.requestId !== PANE) return next(e)
242 const { Box, Text, Button } = $.ui.resolve(e)
243 const width = Math.max(30, e.props.bodyColumns - 1)
244 const sp = spacer(e.surface)
245 const noop = () => {}
246 const window = ledger.window
247 const last = ledger.turns.at(-1)
248 const lastDone = [...ledger.turns].reverse().find(t => t.input !== undefined)
249 const used = lastDone ? contextAfter(lastDone) : undefined
250 const pct = used !== undefined && window ? Math.round((used / window) * 100) : undefined
251
252 /** Bar cells for a turn: system, earlier, new input, output, then free. */
253 const turnBar = (t: Turn, w: number, key: string) => {
254 if (!window || t.input === undefined) return <Text key={key} color={BAR.free}>{'░'.repeat(w)}</Text>
255 const stack = inputStack(ledger, t)
256 const sum = (kinds: BlockKind[]) => stack.filter(b => kinds.includes(b.kind)).reduce((s, b) => s + b.tokens, 0)
257 const segs = [sum(['system']), sum(['history']), sum(['user', 'tool_result']), t.output ?? 0]
258 const cells = barCells(segs, window, w)
259 const free = Math.max(0, w - cells.reduce((s, c) => s + c, 0))
260 const colors = [BAR.system, BAR.history, BAR.added, BAR.output]
261 return (
262 <Text key={key}>
263 {cells.map((c, i) => (c ? <Text key={`s${i}`} color={colors[i]}>{'█'.repeat(c)}</Text> : null))}
264 <Text key="free" color={BAR.free}>{'░'.repeat(free)}</Text>
265 </Text>
266 )
267 }
268
269 const chip = (b: Block, w: number, key: string) => {
270 const s = STYLE[b.kind]
271 const tok = b.kind === 'truncated' ? '' : `${b.isEstimate ? '~' : ''}${fmtTokens(b.tokens)}`
272 const labelW = Math.max(4, w - tok.length - 5)
273 const text = ` ${s.icon} ${cell(b.label, labelW)} ${tok.padStart(tok.length)} `
274 return (
275 <Box key={key} width={w} height={1} flexShrink={0}>
276 <Text backgroundColor={s.bg} color={b.isError ? '#ff8f80' : s.fg} italic={b.kind === 'truncated'}>
277 {sp(cell(text, w))}
278 </Text>
279 </Box>
280 )
281 }
282
283 const section = (title: string, rail: string, blocks: Block[], w: number, key: string) => (
284 <Box key={key} flexDirection="column">
285 <Text key="t">
286 <Text color={rail}>{'┃ '}</Text>
287 <Text color={rail} bold>{title}</Text>
288 </Text>
289 {blocks.map((b, i) => (
290 <Box key={`b${i}`} flexDirection="row">
291 <Text color={rail}>{sp('┃ ')}</Text>
292 {chip(b, w - 2, `c${i}`)}
293 </Box>
294 ))}
295 </Box>
296 )
297
298 const detail = (t: Turn) => {
299 const w = width - 3
300 const stack = inputStack(ledger, t)
301 const cached = t.cacheRead ? ` · ${fmtTokens(t.cacheRead)} cached` : ''
302 const inTitle = t.input !== undefined ? `INPUT ${fmtTokens(t.input)}${cached}` : 'INPUT sending…'
303 const outTitle =
304 t.status === 'streaming' ? 'OUTPUT streaming…' : t.output !== undefined ? `OUTPUT +${fmtTokens(t.output)}` : 'OUTPUT none'
305 const answer = t.answer.length ? t.answer : []
306 return (
307 <Box key={`d${t.n}`} flexDirection="column" marginLeft={2} marginBottom={1}>
308 {section(inTitle, INPUT_RAIL, stack, w, 'in')}
309 {section(outTitle, OUTPUT_RAIL, answer, w, 'out')}
310 <Text key="meta" dimColor>
311 {fit(
312 [t.model, t.stopReason ?? '', t.isCompacted ? 'compacted before this turn' : '']
313 .filter(Boolean)
314 .join(' · '),
315 w,
316 )}
317 </Text>
318 </Box>
319 )
320 }
321
322 const row = (t: Turn) => {
323 const isOpenRow = expanded.has(t.n)
324 const after = contextAfter(t)
325 const right = after !== undefined ? fmtTokens(after) : '…'
326 const barW = Math.max(6, Math.min(18, Math.floor(width / 4)))
327 const labelW = Math.max(6, width - barW - 15)
328 const label = `#${t.n} ${gist(t)}`
329 const mark = t.status === 'failed' ? '✗' : isOpenRow ? '▾' : '▸'
330 const markColor = t.status === 'failed' ? 'red' : t === live ? 'yellow' : INPUT_RAIL
331 return (
332 <Box key={`r${t.n}`} flexDirection="row">
333 <Text key="m" color={markColor}>{sp(`${mark} `)}</Text>
334 <Button
335 key={`${KEY}${t.n}`}
336 plain
337 dimColor={!isOpenRow && t !== live}
338 label={sp(cell(label, labelW))}
339 onPress={noop}
340 />
341 <Text key="g">{sp(' ')}</Text>
342 {turnBar(t, barW, 'bar')}
343 <Text key="n" color="#c9c9c9">{sp(` ${right.padStart(6)}`)}</Text>
344 </Box>
345 )
346 }
347
348 const body: RenderChildren[] = []
349 let prompt: string | undefined
350 for (const t of ledger.turns) {
351 if (t.step === 0 || t.prompt !== prompt) {
352 prompt = t.prompt
353 body.push(
354 <Box key={`p${t.n}`} marginTop={body.length ? 1 : 0}>
355 <Text color={INPUT_RAIL} bold wrap="truncate-end">
356 {fit(`❯ ${t.prompt ? t.prompt.replace(/\s+/g, ' ') : '(continuation)'}`, width)}
357 </Text>
358 </Box>,
359 )
360 }
361 body.push(row(t))
362 if (expanded.has(t.n)) body.push(detail(t))
363 }
364
365 const legend = (
366 <Text key="legend">
367 <Text color={BAR.system}>{'■'}</Text>
368 <Text dimColor>{sp(' system ')}</Text>
369 <Text color={BAR.history}>{'■'}</Text>
370 <Text dimColor>{sp(' earlier ')}</Text>
371 <Text color={BAR.added}>{'■'}</Text>
372 <Text dimColor>{sp(' new ')}</Text>
373 <Text color={BAR.output}>{'■'}</Text>
374 <Text dimColor>{sp(' output')}</Text>
375 </Text>
376 )
377
378 return (
379 <Box flexDirection="column">
380 <Text key="h" bold>
381 <Text color={INPUT_RAIL}>{'Context window '}</Text>
382 {sp(`${fmtTokens(used)} / ${fmtTokens(window)}`)}
383 {pct !== undefined ? <Text color={pct > 80 ? 'red' : pct > 60 ? 'yellow' : 'green'}>{sp(` ${pct}%`)}</Text> : null}
384 </Text>
385 {last ? turnBar(lastDone ?? last, width, 'big') : <Text key="big" color={BAR.free}>{'░'.repeat(width)}</Text>}
386 {legend}
387 <Box key="list" flexDirection="column" marginTop={1}>
388 {ledger.turns.length === 0 ? (
389 <Text dimColor>{fit('No turns yet: send a prompt and each request shows up here.', width)}</Text>
390 ) : (
391 body
392 )}
393 </Box>
394 <Box key="foot" marginTop={1} flexDirection="column">
395 <Box key="toolbar" flexDirection="row" columnGap={1}>
396 <Button key="follow" label={follow ? 'following' : 'follow'} hotkey="f" onPress={noop} />
397 <Button key="collapse" label="collapse" onPress={noop} />
398 <Button key="close" label="close" onPress={noop} />
399 </Box>
400 <Text dimColor>{fit('▸ expand a turn · ~ estimated split of a real total', width)}</Text>
401 </Box>
402 </Box>
403 )
404 })
405}
406
407/** Desktop and the remote surfaces collapse runs of spaces; keep them. */
408function spacer(surface: RenderSurface): (s: string) => string {
409 return surface === 'terminal' ? s => s : s => s.replace(/ /g, '\u00a0')
410}
411
412function safeJson(v: unknown): string {
413 try {
414 return typeof v === 'string' ? v : (JSON.stringify(v) ?? '')
415 } catch {
416 return ''
417 }
418}
419hooks/turns.ts 334 lines1/**
2 * turn-inspector: the session's model requests ("turns") as plain data.
3 *
4 * A turn is one request to the model, as in Anthropic's context-window
5 * diagram: its input is everything sent (system prompt and tools, every
6 * earlier turn, and what is new since the last one: the user's message or
7 * the tool results), its output the blocks the model answered with (thinking,
8 * text, tool use). Every token total here is the API's own `usage`; only the
9 * split of a total across its blocks is estimated (about 4 characters a
10 * token), and every such figure is flagged `isEstimate`.
11 */
12
13export type BlockKind =
14 | 'system'
15 | 'history'
16 | 'user'
17 | 'tool_result'
18 | 'thinking'
19 | 'text'
20 | 'tool_use'
21 | 'truncated'
22
23export type Block = {
24 kind: BlockKind
25 label: string
26 tokens: number
27 isEstimate: boolean
28 isError?: boolean
29 /** Characters seen, the basis of an estimate. */
30 chars: number
31 /** The response's content block it streamed in, for output blocks. */
32 index?: number
33}
34
35export type TurnStatus = 'streaming' | 'done' | 'failed'
36
37export type Turn = {
38 /** 1-based, across the session. */
39 n: number
40 turnId: string
41 /** The request's position inside its prompt's turn, from 0. */
42 step: number
43 /** The user prompt the request answers ('' for a continuation). */
44 prompt: string
45 model?: string
46 status: TurnStatus
47 stopReason?: string | null
48 /** Input tokens in all: uncached + cache read + cache write. */
49 input?: number
50 cacheRead?: number
51 cacheWrite?: number
52 output?: number
53 /** What was new in this request's input (user message, tool results). */
54 added: Block[]
55 /** The response's blocks, by stream index. */
56 answer: Block[]
57 /** The input shrank against the turn before: a compaction ran between. */
58 isCompacted: boolean
59 /** The request carried no conversation before its new input. */
60 isFirst: boolean
61}
62
63export type Usage = {
64 input_tokens: number
65 output_tokens: number
66 cache_read_input_tokens: number
67 cache_creation_input_tokens: number
68 model?: string
69}
70
71export type Ledger = {
72 turns: Turn[]
73 /** Blocks that arrived since the last request; the next request carries them. */
74 pending: Block[]
75 /** The prompt of the turn in progress. */
76 prompt: string
77 turnId?: string
78 /** Context window in tokens, when the engine has said. */
79 window?: number
80 /** System prompt + tools + memory etc., from the /context breakdown (an estimate). */
81 system?: number
82 /**
83 * The same, measured: the input of a request that carried nothing but the
84 * first user message, less that message. Preferred over `system`.
85 */
86 baseline?: number
87 /** The session started with an empty conversation (not resumed). */
88 isFresh: boolean
89 nextN: number
90}
91
92export const CHARS_PER_TOKEN = 4
93
94export function createLedger(): Ledger {
95 return { turns: [], pending: [], prompt: '', nextN: 1, isFresh: false }
96}
97
98export function estimate(chars: number): number {
99 return chars <= 0 ? 0 : Math.max(1, Math.round(chars / CHARS_PER_TOKEN))
100}
101
102export function inputTotal(u: Usage): number {
103 return u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
104}
105
106function block(kind: BlockKind, label: string, chars: number, isError?: boolean): Block {
107 return { kind, label, chars, tokens: estimate(chars), isEstimate: true, ...(isError ? { isError } : {}) }
108}
109
110/** A prompt was submitted: its text is the next request's new input. */
111export function promptStarted(l: Ledger, turnId: string, text: string): void {
112 l.turnId = turnId
113 l.prompt = text
114 if (text) l.pending.push(block('user', 'User message', text.length))
115}
116
117/** A tool answered: its result goes back to the model with the next request. */
118export function toolAnswered(l: Ledger, tool: string, chars: number, isError: boolean): void {
119 l.pending.push(block('tool_result', `Tool result · ${tool}`, chars, isError))
120}
121
122/** A request is about to be sent. */
123export function stepStarted(l: Ledger, turnId: string, step: number, model: string, keep: number): Turn {
124 if (l.turnId !== turnId) {
125 l.turnId = turnId
126 if (step === 0) l.prompt = ''
127 }
128 const turn: Turn = {
129 n: l.nextN,
130 turnId,
131 step,
132 prompt: l.prompt,
133 model,
134 status: 'streaming',
135 added: l.pending,
136 answer: [],
137 isCompacted: false,
138 isFirst: l.isFresh && l.nextN === 1 && step === 0 && l.pending.length === 1 && l.pending[0]!.kind === 'user',
139 }
140 l.nextN += 1
141 l.pending = []
142 l.turns.push(turn)
143 if (l.turns.length > keep) l.turns.splice(0, l.turns.length - keep)
144 return turn
145}
146
147/** A piece of the response streamed in: grows the block at `index`. */
148export function chunk(turn: Turn, index: number, kind: 'thinking' | 'text' | 'tool_use', piece: string, name?: string): boolean {
149 let b = turn.answer.find(x => x.index === index)
150 const isNew = !b
151 if (!b) {
152 const label = kind === 'thinking' ? 'Thinking' : kind === 'text' ? 'Text response' : `Tool use · ${name ?? 'tool'}`
153 b = { ...block(kind, label, 0), index }
154 turn.answer.push(b)
155 }
156 b.chars += piece.length
157 b.tokens = estimate(b.chars)
158 return isNew
159}
160
161/**
162 * The response is whole: record the API's usage, then split the real totals
163 * across the blocks.
164 *
165 * Output: text and tool use keep their character estimate; thinking takes
166 * what is left, since the stream shows a summary of it at most. With no
167 * thinking block the estimates are scaled to the real total.
168 *
169 * Input: what is new since the turn before is the real growth (this input
170 * less the last input and output), split across the new blocks by size.
171 */
172export function stepEnded(l: Ledger, turn: Turn, usage: Usage | null, stopReason: string | null): void {
173 turn.stopReason = stopReason
174 if (!usage) {
175 turn.status = 'failed'
176 return
177 }
178 turn.status = 'done'
179 if (usage.model) turn.model = usage.model
180 turn.input = inputTotal(usage)
181 turn.cacheRead = usage.cache_read_input_tokens
182 turn.cacheWrite = usage.cache_creation_input_tokens
183 turn.output = usage.output_tokens
184
185 const thinking = turn.answer.filter(b => b.kind === 'thinking')
186 const rest = turn.answer.filter(b => b.kind !== 'thinking')
187 const restSum = rest.reduce((s, b) => s + b.tokens, 0)
188 if (thinking.length && restSum <= usage.output_tokens) {
189 split(thinking, usage.output_tokens - restSum)
190 } else {
191 split(turn.answer, usage.output_tokens)
192 }
193 if (stopReason === 'max_tokens' || stopReason === 'model_context_window_exceeded') {
194 const why = stopReason === 'max_tokens' ? 'Cut off: max output tokens' : 'Cut off: context window full'
195 turn.answer.push({ kind: 'truncated', label: why, tokens: 0, chars: 0, isEstimate: false })
196 }
197
198 if (turn.isFirst) l.baseline = Math.max(0, turn.input - turn.added[0]!.tokens)
199
200 const prev = previous(l, turn)
201 if (prev && prev.input !== undefined && prev.output !== undefined) {
202 const grown = turn.input - prev.input - prev.output
203 if (turn.input < prev.input) turn.isCompacted = true
204 else if (grown >= 0 && turn.added.length) split(turn.added, grown)
205 }
206}
207
208/**
209 * What every request carries before the conversation: the /context
210 * breakdown's used rows (system prompt, tools, MCP tools, memory files,
211 * skills, agents) less its `Messages` row.
212 */
213export function systemTokens(categories: readonly { name: string; tokens: number; kind: string }[]): number | undefined {
214 const used = categories.filter(c => c.kind === 'used' && c.name !== 'Messages')
215 return used.length ? used.reduce((s, c) => s + c.tokens, 0) : undefined
216}
217
218/** Spreads `total` over `blocks` by their current weights; exact sum. */
219function split(blocks: Block[], total: number): void {
220 if (!blocks.length) return
221 const weights = blocks.map(b => Math.max(1, b.chars))
222 const sum = weights.reduce((s, w) => s + w, 0)
223 let left = total
224 blocks.forEach((b, i) => {
225 const t = i === blocks.length - 1 ? left : Math.round((total * weights[i]!) / sum)
226 b.tokens = Math.max(0, t)
227 left -= b.tokens
228 })
229}
230
231export function previous(l: Ledger, turn: Turn): Turn | undefined {
232 const i = l.turns.indexOf(turn)
233 return i > 0 ? l.turns[i - 1] : undefined
234}
235
236/**
237 * The input stack of a turn as the diagram draws it: system & tools, then
238 * every earlier turn folded into one block, then what is new.
239 */
240export function inputStack(l: Ledger, turn: Turn): Block[] {
241 const added = turn.added
242 const addedSum = added.reduce((s, b) => s + b.tokens, 0)
243 if (turn.input === undefined) return added
244 const known = l.baseline ?? l.system
245 const system = Math.min(known ?? 0, Math.max(0, turn.input - addedSum))
246 const history = Math.max(0, turn.input - system - addedSum)
247 const earlier = turn.n - (l.turns[0]?.n ?? turn.n)
248 const out: Block[] = []
249 if (system) {
250 out.push({ kind: 'system', label: 'System, tools & memory', tokens: system, chars: 0, isEstimate: l.baseline === undefined })
251 }
252 if (history) {
253 const label = turn.isCompacted
254 ? 'Compacted summary & history'
255 : known === undefined
256 ? 'System, tools & history'
257 : earlier > 0
258 ? `Earlier turns (${earlier})`
259 : 'Earlier context'
260 out.push({ kind: 'history', label, tokens: history, chars: 0, isEstimate: l.baseline === undefined && known !== undefined })
261 }
262 return [...out, ...added]
263}
264
265/** Context in use after a turn: what it sent plus what it answered. */
266export function contextAfter(turn: Turn): number | undefined {
267 if (turn.input === undefined) return undefined
268 return turn.input + (turn.output ?? 0)
269}
270
271/** A short summary of what the turn answered with, for its row. */
272export function gist(turn: Turn): string {
273 if (turn.status === 'streaming') return 'streaming…'
274 if (turn.status === 'failed') return 'no response'
275 const tools = turn.answer.filter(b => b.kind === 'tool_use')
276 if (tools.length) {
277 const names = [...new Set(tools.map(b => b.label.replace('Tool use · ', '')))]
278 return `${tools.length}× ${names.slice(0, 2).join(', ')}${names.length > 2 ? '…' : ''}`
279 }
280 if (turn.answer.some(b => b.kind === 'truncated')) return 'cut off'
281 if (turn.answer.some(b => b.kind === 'text')) return 'answer'
282 return turn.answer.some(b => b.kind === 'thinking') ? 'thinking only' : 'empty'
283}
284
285export function fmtTokens(n: number | undefined): string {
286 if (n === undefined) return '–'
287 if (n < 1000) return String(n)
288 if (n < 100_000) return `${(n / 1000).toFixed(1)}k`
289 if (n < 1_000_000) return `${Math.round(n / 1000)}k`
290 return `${(n / 1_000_000).toFixed(1)}M`
291}
292
293/** Cuts `text` to `width` cells with an ellipsis. */
294export function fit(text: string, width: number): string {
295 const w = Math.max(1, Math.floor(width))
296 const chars = Array.from(text)
297 if (chars.length <= w) return text
298 return chars.slice(0, Math.max(0, w - 1)).join('') + '…'
299}
300
301/** `text` cut or padded to exactly `width` cells. */
302export function cell(text: string, width: number, align: 'left' | 'right' = 'left'): string {
303 const t = fit(text, width)
304 const pad = ' '.repeat(Math.max(0, width - Array.from(t).length))
305 return align === 'left' ? t + pad : pad + t
306}
307
308/**
309 * The cells of a context bar: `segments` tokens each, against `window`,
310 * `width` cells wide. Every segment that has tokens gets at least one cell
311 * while room lasts; the rest of the bar is free space.
312 */
313export function barCells(segments: number[], window: number, width: number): number[] {
314 const w = Math.max(1, Math.floor(width))
315 const cells = segments.map(t => (t > 0 && window > 0 ? Math.max(1, Math.round((t / window) * w)) : 0))
316 let over = cells.reduce((s, c) => s + c, 0) - w
317 for (let i = cells.length - 1; over > 0 && i >= 0; i--) {
318 const cut = Math.min(over, Math.max(0, cells[i]! - 1))
319 cells[i]! -= cut
320 over -= cut
321 }
322 return cells
323}
324
325export function paneColumns(v: unknown): number {
326 const n = typeof v === 'number' && Number.isFinite(v) ? Math.round(v) : 56
327 return Math.min(120, Math.max(36, n))
328}
329
330export function maxTurns(v: unknown): number {
331 const n = typeof v === 'number' && Number.isFinite(v) ? Math.round(v) : 200
332 return Math.min(2000, Math.max(10, n))
333}
334