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, and /turns on|off to disable the whole mod (so meus-mods can control it too) — off also closes the pane and stops recording turns.
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 — unless the mod was turned off with /turns off, which stops recording until /turns on. 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 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+.
Vendored from davila7/claude-code-templates (cli-tool/components/mods/ui/turn-inspector), MIT licensed. Added the isEnabled toggle above so it fits the on/off convention of every other mod in this repo.
hooks/register.tsx 447 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 · `/turns on|off` disables the mod entirely
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 *
24 * Vendored from davila7/claude-code-templates (cli-tool/components/mods/ui/turn-inspector),
25 * MIT licensed. Added: an isEnabled toggle (/turns on|off) so meus-mods can control it.
26 */
27import { atom, read, update } from 'claude-code'
28import type { Register, RenderChildren, RenderSurface } from 'claude-code'
29import {
30 barCells,
31 cell,
32 chunk,
33 contextAfter,
34 createLedger,
35 fit,
36 fmtTokens,
37 gist,
38 inputStack,
39 maxTurns,
40 paneColumns,
41 promptStarted,
42 stepEnded,
43 stepStarted,
44 systemTokens,
45 toolAnswered,
46} from './turns.ts'
47import type { Block, BlockKind, Ledger, Turn } from './turns.ts'
48
49const PANE = 'turn-inspector'
50const COMMAND = 'turns'
51const KEY = 'turn:'
52
53/** The diagram's palette: input in terracotta, output in grey, thinking near-black. */
54const INK = '#f4ece6'
55const STYLE: Record<BlockKind, { icon: string; bg: string; fg: string }> = {
56 system: { icon: '⚙', bg: '#4a2a1d', fg: '#e8cbb8' },
57 history: { icon: '↺', bg: '#6b3d2a', fg: INK },
58 user: { icon: '❯', bg: '#9a5a3e', fg: INK },
59 tool_result: { icon: '⇠', bg: '#9a5a3e', fg: INK },
60 thinking: { icon: '✻', bg: '#151515', fg: '#d6d6d6' },
61 text: { icon: '¶', bg: '#5e5e5e', fg: INK },
62 tool_use: { icon: '⚒', bg: '#5e5e5e', fg: INK },
63 truncated: { icon: '✂', bg: '#2b2b2b', fg: '#9a9a9a' },
64}
65const INPUT_RAIL = '#c8734f'
66const OUTPUT_RAIL = '#8c8c8c'
67const BAR = { system: '#4a2a1d', history: '#8a4f37', added: '#d9875f', output: '#9a9a9a', free: '#3a3a3a' }
68
69const isEnabledRef = atom({ plugin: 'turn-inspector', key: 'isEnabled' } as const, true)
70
71let ledger: Ledger = createLedger()
72let live: Turn | undefined
73let isOpen = false
74let follow = true
75// kept in sync with isEnabledRef at session.start and on every /turns on|off,
76// so the frequent observer hooks below can check it without an async $.state read
77let isEnabled = true
78const expanded = new Set<number>()
79
80export const register: Register = (on, options) => {
81 const columns = paneColumns(options.columns)
82 const keep = maxTurns(options.maxTurns)
83 const openOnStart = options.openOnStart === true
84
85 on('session.start', async ($, e, next) => {
86 const saved = await $.store.get('isEnabled').catch(() => undefined)
87 await update($, isEnabledRef, () => (typeof saved === 'boolean' ? saved : isEnabledRef.initial))
88 const r = await next(e)
89 ledger = createLedger()
90 live = undefined
91 expanded.clear()
92 follow = true
93 isEnabled = await read($, isEnabledRef)
94 await $.command
95 .register({
96 name: COMMAND,
97 description: 'Turn-by-turn view of the context window: each request’s input and output blocks (clear|stop|on|off)',
98 argumentHint: '[clear|stop|on|off]',
99 immediate: true,
100 })
101 .catch(err => $.ui.log(`turn-inspector: /${COMMAND} not registered: ${err}`))
102 const usage = await $.session.usage().catch(() => undefined)
103 if (usage) ledger.window = usage.context.window
104 // a fresh session's first request measures what every request carries
105 ledger.isFresh = (await $.session.messages().catch(() => [1])).length === 0
106 if (openOnStart && isEnabled) {
107 isOpen = true
108 await $.ui.open({ id: PANE, title: 'turns', columns }).catch(err => {
109 isOpen = false
110 $.ui.log(`turn-inspector: pane not opened: ${err}`)
111 })
112 }
113 return r
114 })
115
116 on('command.run', { command: COMMAND }, async ($, e) => {
117 const arg = e.args.trim().toLowerCase()
118
119 if (arg === 'on' || arg === 'off') {
120 isEnabled = arg === 'on'
121 await update($, isEnabledRef, () => isEnabled)
122 await $.store.set('isEnabled', isEnabled) // $.state is per session; the store survives a restart
123 if (!isEnabled && isOpen) {
124 await $.ui.close({ id: PANE }).catch(() => undefined)
125 isOpen = false
126 }
127 return { text: `turn-inspector ${isEnabled ? 'ativado' : 'desativado'}.` }
128 }
129 if (!isEnabled) {
130 return { text: 'turn-inspector esta desativado. Uso: /turns on' }
131 }
132
133 if (arg === 'stop' || arg === 'close') {
134 await $.ui.close({ id: PANE }).catch(() => undefined)
135 isOpen = false
136 return { text: 'turn-inspector closed' }
137 }
138 if (arg === 'clear') {
139 const window = ledger.window
140 const { system, baseline } = ledger
141 ledger = Object.assign(createLedger(), { window, system, baseline })
142 live = undefined
143 expanded.clear()
144 if (isOpen) $.ui.invalidate('ui.render')
145 return { text: 'turn-inspector: turns cleared' }
146 }
147 const usage = await $.session.usage({ breakdown: 'summary', columns }).catch(() => undefined)
148 if (usage) {
149 ledger.window = usage.context.window
150 const system = systemTokens(usage.context.breakdown?.categories ?? [])
151 if (system !== undefined) ledger.system = system
152 }
153 try {
154 await $.ui.open({ id: PANE, title: 'turns', focus: true, columns })
155 } catch (err) {
156 isOpen = false
157 return { text: `turn-inspector: pane not opened: ${err}` }
158 }
159 isOpen = true
160 $.ui.invalidate('ui.render')
161 const hint = e.presentation.isFullscreen
162 ? 'click a turn, or Tab and Enter, to expand it'
163 : 'drawn above the prompt; /tui fullscreen docks it beside the transcript'
164 return { text: `${ledger.turns.length} turns so far · ${hint} · /${COMMAND} stop closes` }
165 })
166
167 on('turn.start', async ($, e, next) => {
168 if (!isEnabled) return next(e)
169 promptStarted(ledger, e.turnId, e.text)
170 return next(e)
171 })
172
173 // a tool's answer is new input for the next request
174 on('tool.call', async ($, e, next) => {
175 const r = await next(e)
176 if (!isEnabled || e.agentId !== undefined) return r
177 const text = r.deny ?? r.text ?? (r.result === undefined ? '' : safeJson(r.result))
178 toolAnswered(ledger, e.tool, text.length, r.deny !== undefined || r.isError === true)
179 return r
180 })
181
182 // each request of the main loop is a turn; its stream gives the output blocks
183 on('turn.step', async function* ($, e, next) {
184 if (!isEnabled || e.agentId !== undefined) return yield* next(e)
185 const turn = stepStarted(ledger, e.turnId, e.index, e.model, keep)
186 live = turn
187 if (follow) {
188 expanded.clear()
189 expanded.add(turn.n)
190 }
191 if (isOpen) $.ui.invalidate('ui.render')
192 const names = new Map<number, string>()
193 const stream = next(e)
194 for await (const c of stream) {
195 let isNew = false
196 if (c.kind === 'thinking') isNew = chunk(turn, c.index, 'thinking', c.text)
197 else if (c.kind === 'text') isNew = chunk(turn, c.index, 'text', c.text)
198 else if (c.kind === 'tool') {
199 names.set(c.index, c.name)
200 isNew = chunk(turn, c.index, 'tool_use', c.name, c.name)
201 } else if (c.kind === 'input') isNew = chunk(turn, c.index, 'tool_use', c.json, names.get(c.index))
202 if (isNew && isOpen) $.ui.invalidate('ui.render')
203 yield c
204 }
205 const r = await stream.result
206 stepEnded(ledger, turn, r.usage, r.stopReason)
207 if (live === turn) live = undefined
208 if (isOpen) $.ui.invalidate('ui.render')
209 return r
210 })
211
212 on('turn.complete', async ($, e, next) => {
213 const r = await next(e)
214 if (isEnabled && e.agentId === undefined && isOpen) {
215 const usage = await $.session.usage({ breakdown: 'summary', columns }).catch(() => undefined)
216 const system = systemTokens(usage?.context.breakdown?.categories ?? [])
217 if (system !== undefined) ledger.system = system
218 if (isOpen) $.ui.invalidate('ui.render')
219 }
220 return r
221 })
222
223 on('session.measure', async ($, e, next) => {
224 const r = await next(e)
225 if (isEnabled && e.context.window !== ledger.window) {
226 ledger.window = e.context.window
227 if (isOpen) $.ui.invalidate('ui.render')
228 }
229 return r
230 })
231
232 on('ui.close', async ($, e, next) => {
233 if (e.id !== PANE) return next(e)
234 const r = await next(e)
235 isOpen = false
236 return r
237 })
238
239 on('ui.press', async ($, e, next) => {
240 if (e.plugin !== $.plugin.name || e.requestId !== PANE) return next(e)
241 const r = await next(e)
242 const key = e.element
243 if (key === 'close') {
244 await $.ui.close({ id: PANE }).catch(() => undefined)
245 return r
246 }
247 if (key === 'follow') {
248 follow = !follow
249 const last = ledger.turns.at(-1)
250 if (follow && last) {
251 expanded.clear()
252 expanded.add(last.n)
253 }
254 } else if (key === 'collapse') {
255 expanded.clear()
256 follow = false
257 } else if (key.startsWith(KEY)) {
258 const n = Number(key.slice(KEY.length))
259 if (expanded.has(n)) {
260 expanded.delete(n)
261 if (n === ledger.turns.at(-1)?.n) follow = false
262 } else expanded.add(n)
263 }
264 if (isOpen) $.ui.invalidate('ui.render')
265 return r
266 })
267
268 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
269 if (e.requestId !== PANE) return next(e)
270 const { Box, Text, Button } = $.ui.resolve(e)
271 const width = Math.max(30, e.props.bodyColumns - 1)
272 const sp = spacer(e.surface)
273 const noop = () => {}
274 const window = ledger.window
275 const last = ledger.turns.at(-1)
276 const lastDone = [...ledger.turns].reverse().find(t => t.input !== undefined)
277 const used = lastDone ? contextAfter(lastDone) : undefined
278 const pct = used !== undefined && window ? Math.round((used / window) * 100) : undefined
279
280 /** Bar cells for a turn: system, earlier, new input, output, then free. */
281 const turnBar = (t: Turn, w: number, key: string) => {
282 if (!window || t.input === undefined) return <Text key={key} color={BAR.free}>{'░'.repeat(w)}</Text>
283 const stack = inputStack(ledger, t)
284 const sum = (kinds: BlockKind[]) => stack.filter(b => kinds.includes(b.kind)).reduce((s, b) => s + b.tokens, 0)
285 const segs = [sum(['system']), sum(['history']), sum(['user', 'tool_result']), t.output ?? 0]
286 const cells = barCells(segs, window, w)
287 const free = Math.max(0, w - cells.reduce((s, c) => s + c, 0))
288 const colors = [BAR.system, BAR.history, BAR.added, BAR.output]
289 return (
290 <Text key={key}>
291 {cells.map((c, i) => (c ? <Text key={`s${i}`} color={colors[i]}>{'█'.repeat(c)}</Text> : null))}
292 <Text key="free" color={BAR.free}>{'░'.repeat(free)}</Text>
293 </Text>
294 )
295 }
296
297 const chip = (b: Block, w: number, key: string) => {
298 const s = STYLE[b.kind]
299 const tok = b.kind === 'truncated' ? '' : `${b.isEstimate ? '~' : ''}${fmtTokens(b.tokens)}`
300 const labelW = Math.max(4, w - tok.length - 5)
301 const text = ` ${s.icon} ${cell(b.label, labelW)} ${tok.padStart(tok.length)} `
302 return (
303 <Box key={key} width={w} height={1} flexShrink={0}>
304 <Text backgroundColor={s.bg} color={b.isError ? '#ff8f80' : s.fg} italic={b.kind === 'truncated'}>
305 {sp(cell(text, w))}
306 </Text>
307 </Box>
308 )
309 }
310
311 const section = (title: string, rail: string, blocks: Block[], w: number, key: string) => (
312 <Box key={key} flexDirection="column">
313 <Text key="t">
314 <Text color={rail}>{'┃ '}</Text>
315 <Text color={rail} bold>{title}</Text>
316 </Text>
317 {blocks.map((b, i) => (
318 <Box key={`b${i}`} flexDirection="row">
319 <Text color={rail}>{sp('┃ ')}</Text>
320 {chip(b, w - 2, `c${i}`)}
321 </Box>
322 ))}
323 </Box>
324 )
325
326 const detail = (t: Turn) => {
327 const w = width - 3
328 const stack = inputStack(ledger, t)
329 const cached = t.cacheRead ? ` · ${fmtTokens(t.cacheRead)} cached` : ''
330 const inTitle = t.input !== undefined ? `INPUT ${fmtTokens(t.input)}${cached}` : 'INPUT sending…'
331 const outTitle =
332 t.status === 'streaming' ? 'OUTPUT streaming…' : t.output !== undefined ? `OUTPUT +${fmtTokens(t.output)}` : 'OUTPUT none'
333 const answer = t.answer.length ? t.answer : []
334 return (
335 <Box key={`d${t.n}`} flexDirection="column" marginLeft={2} marginBottom={1}>
336 {section(inTitle, INPUT_RAIL, stack, w, 'in')}
337 {section(outTitle, OUTPUT_RAIL, answer, w, 'out')}
338 <Text key="meta" dimColor>
339 {fit(
340 [t.model, t.stopReason ?? '', t.isCompacted ? 'compacted before this turn' : '']
341 .filter(Boolean)
342 .join(' · '),
343 w,
344 )}
345 </Text>
346 </Box>
347 )
348 }
349
350 const row = (t: Turn) => {
351 const isOpenRow = expanded.has(t.n)
352 const after = contextAfter(t)
353 const right = after !== undefined ? fmtTokens(after) : '…'
354 const barW = Math.max(6, Math.min(18, Math.floor(width / 4)))
355 const labelW = Math.max(6, width - barW - 15)
356 const label = `#${t.n} ${gist(t)}`
357 const mark = t.status === 'failed' ? '✗' : isOpenRow ? '▾' : '▸'
358 const markColor = t.status === 'failed' ? 'red' : t === live ? 'yellow' : INPUT_RAIL
359 return (
360 <Box key={`r${t.n}`} flexDirection="row">
361 <Text key="m" color={markColor}>{sp(`${mark} `)}</Text>
362 <Button
363 key={`${KEY}${t.n}`}
364 plain
365 dimColor={!isOpenRow && t !== live}
366 label={sp(cell(label, labelW))}
367 onPress={noop}
368 />
369 <Text key="g">{sp(' ')}</Text>
370 {turnBar(t, barW, 'bar')}
371 <Text key="n" color="#c9c9c9">{sp(` ${right.padStart(6)}`)}</Text>
372 </Box>
373 )
374 }
375
376 const body: RenderChildren[] = []
377 let prompt: string | undefined
378 for (const t of ledger.turns) {
379 if (t.step === 0 || t.prompt !== prompt) {
380 prompt = t.prompt
381 body.push(
382 <Box key={`p${t.n}`} marginTop={body.length ? 1 : 0}>
383 <Text color={INPUT_RAIL} bold wrap="truncate-end">
384 {fit(`❯ ${t.prompt ? t.prompt.replace(/\s+/g, ' ') : '(continuation)'}`, width)}
385 </Text>
386 </Box>,
387 )
388 }
389 body.push(row(t))
390 if (expanded.has(t.n)) body.push(detail(t))
391 }
392
393 const legend = (
394 <Text key="legend">
395 <Text color={BAR.system}>{'■'}</Text>
396 <Text dimColor>{sp(' system ')}</Text>
397 <Text color={BAR.history}>{'■'}</Text>
398 <Text dimColor>{sp(' earlier ')}</Text>
399 <Text color={BAR.added}>{'■'}</Text>
400 <Text dimColor>{sp(' new ')}</Text>
401 <Text color={BAR.output}>{'■'}</Text>
402 <Text dimColor>{sp(' output')}</Text>
403 </Text>
404 )
405
406 return (
407 <Box flexDirection="column">
408 <Text key="h" bold>
409 <Text color={INPUT_RAIL}>{'Context window '}</Text>
410 {sp(`${fmtTokens(used)} / ${fmtTokens(window)}`)}
411 {pct !== undefined ? <Text color={pct > 80 ? 'red' : pct > 60 ? 'yellow' : 'green'}>{sp(` ${pct}%`)}</Text> : null}
412 </Text>
413 {last ? turnBar(lastDone ?? last, width, 'big') : <Text key="big" color={BAR.free}>{'░'.repeat(width)}</Text>}
414 {legend}
415 <Box key="list" flexDirection="column" marginTop={1}>
416 {ledger.turns.length === 0 ? (
417 <Text dimColor>{fit('No turns yet: send a prompt and each request shows up here.', width)}</Text>
418 ) : (
419 body
420 )}
421 </Box>
422 <Box key="foot" marginTop={1} flexDirection="column">
423 <Box key="toolbar" flexDirection="row" columnGap={1}>
424 <Button key="follow" label={follow ? 'following' : 'follow'} hotkey="f" onPress={noop} />
425 <Button key="collapse" label="collapse" onPress={noop} />
426 <Button key="close" label="close" onPress={noop} />
427 </Box>
428 <Text dimColor>{fit('▸ expand a turn · ~ estimated split of a real total', width)}</Text>
429 </Box>
430 </Box>
431 )
432 })
433}
434
435/** Desktop and the remote surfaces collapse runs of spaces; keep them. */
436function spacer(surface: RenderSurface): (s: string) => string {
437 return surface === 'terminal' ? s => s : s => s.replace(/ /g, ' ')
438}
439
440function safeJson(v: unknown): string {
441 try {
442 return typeof v === 'string' ? v : (JSON.stringify(v) ?? '')
443 } catch {
444 return ''
445 }
446}
447hooks/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}
334types/index.d.ts 12 lines1export type TurnInspectorState = {
2 isEnabled: boolean
3}
4
5declare module 'claude-code' {
6 interface PluginState {
7 'turn-inspector': {
8 isEnabled: boolean
9 }
10 }
11}
12