SLOPSHOPPER

turn-inspector

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…

newpaneguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · turn-inspector
│ ┃ turns ✕ › fix the failing auth test and add an audit log call │ ┃ Context window – / 200k │ ┃ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ⏺ Read(src/auth.ts) │ ┃ ░░░░░░░░░░░ ⎿ Read 6 lines │ ┃ ■ system ■ earlier ■ new ■ output ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ No turns yet: send a prompt and each request ⏺ Bash(bun test) │ ┃ shows up … ⎿ 3 pass, 1 fail │ ┃ │ ┃ [ following ] [ collapse ] [ close ] ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ▸ expand a turn · ~ estimated split of a │ ┃ real total ✻ Worked for 42s · done 4:20 PM │ │ › /turns │ ⎿ turn-inspector: 0 turns so far · click a turn, or Tab and Enter, │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · turns
Context window – / 200k ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ■ system ■ earlier ■ new ■ output No turns yet: send a prompt and each request shows up … [ following ] [ collapse ] [ close ] ▸ expand a turn · ~ estimated split of a real total
README

turn-inspector

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.

What it shows

  • The window. At the top, the context in use after the last turn against the model's window (tokens, %, a bar), with the bar split into system, earlier turns, new input and output.
  • One row per turn. Turns are grouped under the prompt they answer, since one prompt can take several requests (each tool round trip is a new one). A row shows what the turn answered with (2× Read, Bash, answer, cut off), its bar, and the context in use once it finished.
  • The blocks of a turn. Expand a row to see its stack, colored as in the diagram: input in terracotta, output in grey, thinking in near-black.
  • ⚙ 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.

Where the numbers come from

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):

  • Output. Text and tool use are sized by their characters, and thinking gets the rest of the real total. The stream shows only a summary of the thinking, so it can't be measured directly.
  • New input. This is the real growth since the previous turn (this input less the previous input and output), split across the new blocks.
  • System, tools & memory. In a fresh session this is measured exactly from the first request. In a resumed session, or one opened with the mod already mid-session, it falls back to the local /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.

Where it draws

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.

Options

Set them in user or managed settings under pluginConfigs["turn-inspector@skills-dir"].options ("turn-inspector" when loaded with --plugin-dir):

OptionDefaultWhat it does
columns56Width asked for the docked pane (36-120); a width you drag it to wins
maxTurns200Turns kept, oldest dropped first
openOnStartfalseOpen the pane when the session starts

How it works

It only observes: every hook passes its event on unchanged.

EventWhat it records
turn.startthe prompt, the first new input of the next request
turn.stepeach main-loop request: its stream (thinking, text, tool use blocks), its usage and stop reason
tool.calleach tool's answer, new input for the next request
turn.completerefreshes the system/tools estimate while the pane is open
session.measurethe context window's size

Install

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.

Tests

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.

Source 2 files
hooks/turn-inspector.tsx 419 lines
1/**
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}
419
hooks/turns.ts 334 lines
1/**
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