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
v0.1.0MITupdated 2026-10-19Covre81/claude-code-mods/turn-inspector
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, 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.

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 — 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.

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 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.

Source 3 files
hooks/register.tsx 447 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 · `/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}
447
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
types/index.d.ts 12 lines
1export type TurnInspectorState = {
2  isEnabled: boolean
3}
4
5declare module 'claude-code' {
6  interface PluginState {
7    'turn-inspector': {
8      isEnabled: boolean
9    }
10  }
11}
12