A simple flow of how Claude reached each answer: the question, a few plain-word steps worded by a small model from what was visible, and the answer. Open it…

A Claude Code mod that draws how Claude reached each answer. A panel beside the conversation shows your question, a few steps in plain words, and the answer.

An example. The question and the steps are made up for this picture.
Type /thinking-flow to open the panel. Every answer Claude finishes after that gets a flow, and the buttons under it move between answers.
A small model (Haiku) reads what the session showed during the answer and groups the work into two to six steps. Each step gets a short label and one line on what it found. The line at the bottom left of a box lists the tools that step used. Show actions lists the tool calls and thinking summaries behind every step, so you can check the wording against what happened.
The outline color says what kind of work a step did:
| Outline | Kind of work |
|---|---|
| White | Thought or wrote to you, with no tools |
| Cyan | Looked at files or the web |
| Hot pink | Made changes, such as edits or form entries |
| Gold | Ran commands or other tools |
| White, dashed | Handed work to subagents |
When a step did several kinds of work, the strongest one sets the color, in this order: changes, commands, subagents, looking, then writing or thinking. A pink outline therefore always means something changed. Tool calls you refused do not count, and updates to Claude's to-do list count as thinking. The question sits in a white card and the answer in a yellow one.
In a terminal session the panel shows the same flow as numbered lines.
Built and tested on Claude Code 2.1.293. Mods use an early-access API that can change between releases.
The mod works only in Claude Code. It draws in the terminal and in the Code tab of the Claude desktop app. It has no effect in claude.ai chat or in Cowork, and the chat panel of the VS Code extension shows nothing from it.
In a terminal session, run:
/plugin install thinking-flow --marketplace thunini/thinking-flow
Answer y to add the marketplace, then choose a scope. The user scope loads the mod in every session, including the Code tab of the Claude desktop app, which cannot run the install command itself.
The steps read better when Claude Code shows thinking summaries. To turn them on, add this line to ~/.claude/settings.json:
"showThinkingSummaries": true
Without it, the record holds tool calls and text only.
When an answer ends, the mod makes one request to Haiku through Claude Code's own model access, on the account your session already uses. The request holds:
A record keeps at most 40 steps: the first 20 and the last 20. On three answers rebuilt from a real session, with 2 to 12 steps, the request was 1,369 to 3,461 characters (about 340 to 870 tokens) and the replies were 500 to 929 characters. The reply is capped at 800 tokens. The request counts toward your usage like any other Claude Code request.
Nothing else leaves the session. The mod writes no files and makes no other network calls. It reads no environment variables, files or settings, and it starts no processes. claude plugin validate lists every call it makes. The record lives in the session's memory and ends with it. The only web address in the code is http://www.w3.org/2000/svg, the standard name every SVG drawing carries, and nothing ever fetches it.
To make no model calls at all, set Step wording to rules, either on the install screen or later in /config. Steps then get fixed labels such as "Looked at 3 sources".
The full policy is on the Privacy page.
The mod registers seven hooks. All of them watch and pass events on unchanged, except the one that answers /thinking-flow and the one that draws the panel.
| Hook | What it does |
|---|---|
session.start | Registers the /thinking-flow command and starts the timer that words finished answers |
command.run | Answers /thinking-flow only, by opening the panel |
turn.start | Starts a record for the new answer |
turn.step | Notes each model request's thinking summary, tool calls and text, and passes every part on unchanged |
tool.call | Notes whether each tool call succeeded, failed or was refused, and passes the call on unchanged |
turn.complete | Marks the answer as finished, so the timer can word it |
ui.render | Draws the panel |
The small model sees only what the session showed: thinking summaries, tool calls and the answer. Its reply is used only when its steps cover every step of the record exactly once and in order. Otherwise fixed rules group the steps, and the panel says why. The wording is still a summary written by a model, so Show actions is there to check it.
claude plugin validate . --strict
claude plugin test .
The types field in plugin.json names the mod's state contract, types/index.d.ts. Claude Code's validator requires it for a mod that keeps state, so the field stays even though some checkers list it as unknown.
To run the mod from a folder, start Claude Code with claude --plugin-dir <folder>. Claude Code writes its type declarations into .claude-plugin/types/ whenever it loads the folder, and after that a TypeScript compiler run, tsc -p ., type checks the mod.
MIT. See LICENSE.
hooks/register.tsx 275 lines1// thinking-flow: a simple flow of how Claude reached each answer.
2//
3// While a turn runs, the mod watches each main-loop model request (turn.step) without changing
4// it and records what was visible: the thinking text as the session shows it (summaries, or
5// nothing when hidden), the tool calls, and whether the step wrote text. When the turn ends, a
6// timer started at session start sends that record to a small model (Haiku), which groups the
7// steps into a few plain-word stages. Its reply is used only if the stages cover every step
8// once, in order; otherwise fixed rules group the steps. The "wording" setting set to "rules"
9// skips the model call entirely. Each stage keeps the steps it covers,
10// so "Show actions" can list the actions behind it. State lives in the session and ends with it.
11import { atom, read, update } from 'claude-code'
12import type { EngineInterface, Register } from 'claude-code'
13
14import type { Action, Step, Turn, View } from '../types'
15import { creditOf, isTrivial, laneOf, parseFlow, promptFor, rulesFlow, short, stageDetails, svgFlow, SYSTEM, targetOf, textFlow } from './flow'
16
17const PANE = 'thinking-flow'
18const MAX_TURNS = 30
19const MAX_STEPS = 300
20const THINKING_CAP = 500
21const MODEL = 'haiku'
22
23const turnsAtom = atom({ plugin: 'thinking-flow', key: 'turns' } as const, [] as Turn[])
24const viewAtom = atom({ plugin: 'thinking-flow', key: 'view' } as const, { turn: null } as View)
25
26function withTurn(turns: Turn[], id: string, fn: (t: Turn) => Turn): Turn[] {
27 return turns.map(t => (t.id === id ? fn(t) : t))
28}
29
30/** The main turn still running: subagent steps and outcomes are filed under it. */
31function openTurnId(turns: Turn[]): string | undefined {
32 return [...turns].reverse().find(t => t.flowState === 'open')?.id
33}
34
35/** Words every pending turn's flow, oldest first; one call at a time. */
36async function drain($: EngineInterface, busy: { now: boolean }, useModel: boolean): Promise<void> {
37 if (busy.now) return
38 busy.now = true
39 try {
40 for (;;) {
41 const turns = await read($, turnsAtom)
42 const turn = turns.find(t => t.flowState === 'pending')
43 if (!turn) return
44 await update($, turnsAtom, list => withTurn(list, turn.id, t => ({ ...t, flowState: 'working' })))
45 let flow = isTrivial(turn)
46 ? rulesFlow(turn, 'a one-step answer')
47 : useModel ? undefined : rulesFlow(turn, 'the small model is turned off in settings')
48 if (!flow) {
49 try {
50 const reply = await $.model.complete({ model: MODEL, system: SYSTEM, prompt: promptFor(turn), maxTokens: 800, timeoutMs: 45000 })
51 flow = reply.isAnswered
52 ? parseFlow(reply.text, turn.steps.length) ?? rulesFlow(turn, 'the small model gave an unusable reply')
53 : rulesFlow(turn, `the small model did not answer: ${reply.reason}`)
54 } catch {
55 flow = rulesFlow(turn, 'the small model could not be called')
56 }
57 }
58 const done = flow
59 await update($, turnsAtom, list => withTurn(list, turn.id, t => ({ ...t, flowState: 'done', flow: done })))
60 }
61 } finally {
62 busy.now = false
63 }
64}
65
66export const register: Register = (on, options) => {
67 const busy = { now: false }
68 // The "wording" setting: "rules" keeps every flow on fixed labels and makes no model call.
69 const useModel = options.wording !== 'rules'
70
71 on('session.start', async ($, e, next) => {
72 // The timer below must start even if registering the command fails.
73 await $.command.register({
74 name: 'thinking-flow',
75 description: 'Show a simple flow of how Claude reached each answer',
76 }).catch(() => undefined)
77 // Turns left half-done by a reload are picked up again.
78 await update($, turnsAtom, list => list.map((t): Turn => (t.flowState === 'working' ? { ...t, flowState: 'pending' } : t))).catch(() => undefined)
79 $.clock.every(1500, () => {
80 void drain($, busy, useModel)
81 })
82 return next(e)
83 })
84
85 on('command.run', { command: 'thinking-flow' }, async $ => {
86 await update($, viewAtom, v => ({ ...v, turn: null }))
87 await $.ui.open({ id: PANE, title: 'Thinking flow' })
88 return { text: 'Thinking flow opened. Each answer from now on gets a flow.' }
89 })
90
91 on('turn.start', async ($, e, next) => {
92 const now = await $.clock.now().catch(() => 0)
93 const turn: Turn = { id: e.turnId, prompt: e.text || '(a continuation)', answer: '', startedAt: now, steps: [], flowState: 'open' }
94 await update($, turnsAtom, list => [...list, turn].slice(-MAX_TURNS)).catch(() => undefined)
95 return next(e)
96 })
97
98 // Watch every chunk and pass it on unchanged; record the step when it ends.
99 on('turn.step', async function* ($, e, next) {
100 const ids: string[] = []
101 const blocks = new Set<number>()
102 let thinking = ''
103 let thinkingChars = 0
104 let wroteChars = 0
105
106 const stream = next(e)
107 for await (const chunk of stream) {
108 try {
109 if (chunk.kind === 'thinking') {
110 blocks.add(chunk.index)
111 thinkingChars += chunk.text.length
112 if (thinking.length < THINKING_CAP) thinking = (thinking + chunk.text).slice(0, THINKING_CAP)
113 } else if (chunk.kind === 'text') {
114 wroteChars += chunk.text.length
115 } else if (chunk.kind === 'tool') {
116 ids.push(chunk.id)
117 }
118 } catch {
119 // observing must never stop the stream
120 }
121 yield chunk
122 }
123 let result
124 try {
125 result = await stream.result
126 } catch {
127 result = undefined
128 }
129
130 const actions: Action[] = (result?.toolUses ?? []).map((use, i) => ({
131 id: ids[i] ?? `${e.turnId}:${e.index}:${i}`,
132 name: use.name,
133 target: targetOf(use.name, use.input),
134 lane: laneOf(use.name, use.input),
135 outcome: 'running',
136 }))
137
138 await update($, turnsAtom, list => {
139 if (e.agentId !== undefined) {
140 // A subagent's step: count it on the step of the running main turn that started it.
141 const owner = openTurnId(list)
142 return owner === undefined
143 ? list
144 : withTurn(list, owner, t => (t.steps.length === 0 ? t : {
145 ...t,
146 steps: t.steps.map((s, i) => (i === t.steps.length - 1 ? { ...s, subSteps: s.subSteps + 1 } : s)),
147 }))
148 }
149 const step: Step = {
150 n: e.index + 1,
151 thinking: thinking.replace(/\s+/g, ' ').trim(),
152 thinkingChars,
153 thinkingBlocks: blocks.size,
154 actions,
155 subSteps: 0,
156 wroteChars,
157 }
158 return withTurn(list, e.turnId, t => (t.steps.length >= MAX_STEPS ? t : { ...t, steps: [...t.steps, step] }))
159 }).catch(() => undefined)
160 return result
161 })
162
163 // Mark how each tool call ended.
164 on('tool.call', async ($, e, next) => {
165 const ran = await next(e)
166 if (e.agentId !== undefined) return ran
167 const outcome: Action['outcome'] = ran.deny !== undefined ? 'denied' : ran.isError ? 'error' : 'ok'
168 const id = e.tool_use_id
169 await update($, turnsAtom, list => list.map(t =>
170 t.flowState !== 'open' ? t : {
171 ...t,
172 steps: t.steps.map(s => (s.actions.some(a => a.id === id) ? { ...s, actions: s.actions.map(a => (a.id === id ? { ...a, outcome } : a)) } : s)),
173 })).catch(() => undefined)
174 return ran
175 }).catch(($, e, next) => next(e)) // only watching: if this hook fails, the call goes on unchanged
176
177 // Only the main turn's end queues the flow; a subagent run ends with its own agentId.
178 on('turn.complete', async ($, e, next) => {
179 if (e.agentId === undefined) {
180 const now = await $.clock.now().catch(() => 0)
181 await update($, turnsAtom, list => withTurn(list, e.turnId, t => ({
182 ...t,
183 answer: e.answer,
184 endedAt: now,
185 reason: e.reason,
186 flowState: t.steps.length === 0 ? 'done' : 'pending',
187 }))).catch(() => undefined)
188 } else {
189 // Defensive: should a subagent run ever open a turn of its own, drop it when it ends.
190 await update($, turnsAtom, list => list.filter(t => t.id !== e.turnId)).catch(() => undefined)
191 }
192 return next(e)
193 })
194
195 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
196 const turns = (await read($, turnsAtom)).filter(t => t.steps.length > 0 || t.flowState === 'open')
197 const view = await read($, viewAtom)
198 const at = view.turn === null ? turns.length - 1 : Math.min(Math.max(view.turn, 0), turns.length - 1)
199 const turn = turns[at]
200 const { Box, Text, Button } = $.ui.resolve(e)
201
202 if (!turn) {
203 return (
204 <Box flexDirection="column">
205 <Text dimColor>No answer yet. The flow appears when the next answer is done.</Text>
206 </Box>
207 )
208 }
209
210 const go = (n: number | null) => update($, viewAtom, v => ({ ...v, turn: n }))
211 const showDetails = view.details === true
212 const toggle = () => update($, viewAtom, v => ({ ...v, details: !(v.details === true) }))
213 const nav = (
214 <Box flexDirection="row" gap={1}>
215 <Button key="prev" label="Previous answer" onPress={() => go(Math.max(at - 1, 0))} />
216 <Button key="next" label="Next answer" onPress={() => go(at + 1 >= turns.length - 1 ? null : at + 1)} />
217 <Button key="latest" label="Latest" onPress={() => go(null)} />
218 </Box>
219 )
220 const title = <Text bold>{`Answer ${at + 1} of ${turns.length}`}</Text>
221
222 if (turn.flowState === 'open') {
223 return (
224 <Box flexDirection="column" gap={1}>
225 {title}
226 <Text dimColor>{`Working: ${turn.steps.length} steps so far. The flow appears when the answer is done.`}</Text>
227 {nav}
228 </Box>
229 )
230 }
231
232 const flow = turn.flow ?? rulesFlow(turn)
233 const credit = creditOf(turn, flow)
234 const details = (
235 <Box flexDirection="column" gap={1}>
236 <Button key="details" label={showDetails ? 'Hide actions' : 'Show actions'} onPress={toggle} />
237 {showDetails
238 ? stageDetails(turn, flow).map((d, i) => (
239 <Box key={`d${i}`} flexDirection="column">
240 <Text bold>{`${i + 1}. ${d.label}`}</Text>
241 {d.lines.map((line, j) => <Text key={`d${i}-${j}`} dimColor>{` ${line}`}</Text>)}
242 </Box>
243 ))
244 : null}
245 </Box>
246 )
247
248 if (e.surface === 'desktop') {
249 const { Svg } = $.ui.resolve(e)
250 const drawn = svgFlow(turn, flow)
251 return (
252 <Box flexDirection="column" gap={1}>
253 {title}
254 <Svg key="flow" source={drawn.source} alt={drawn.alt} />
255 <Text dimColor>{credit}</Text>
256 {details}
257 {nav}
258 </Box>
259 )
260 }
261
262 return (
263 <Box flexDirection="column" gap={1}>
264 {title}
265 <Box flexDirection="column">
266 {textFlow(turn, flow).map((line, i) => <Text key={`l${i}`}>{line}</Text>)}
267 </Box>
268 <Text dimColor>{short(credit, 160)}</Text>
269 {details}
270 {nav}
271 </Box>
272 )
273 })
274}
275hooks/flow.ts 488 lines1// thinking-flow: pure logic, no engine calls. Classifies tool calls, builds the record a small
2// model words into a flow, checks the model's reply, groups steps by rules when it cannot, names
3// each stage's kind of work, and draws the flow (an SVG for the desktop, numbered lines elsewhere).
4import type { Action, Flow, Lane, Stage, Step, Turn } from '../types'
5
6// ---------- actions ----------
7
8const LOOK = new Set([
9 'Read', 'Grep', 'Glob', 'LS', 'WebFetch', 'WebSearch', 'NotebookRead', 'ToolSearch', 'Skill',
10 'ListMcpResources', 'ReadMcpResource', 'TaskOutput', 'BashOutput',
11])
12const CHANGE = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
13// The session's own to-do list is planning: it changes nothing of the person's.
14const PLAN = new Set(['TodoWrite', 'TodoRead', 'TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet'])
15const DELEGATE = new Set(['Agent', 'Task', 'Workflow', 'SendMessage'])
16const TALK = new Set(['AskUserQuestion', 'SendUserFile', 'Artifact', 'ExitPlanMode', 'PushNotification'])
17// Word lists that sort tool calls by the command text or tool name they start with. They are
18// only matched against text; the mod never runs any of them.
19const READONLY_COMMANDS = [
20 'ls', 'cat', 'head', 'tail', 'wc', 'grep', 'rg', 'find', 'sed -n', 'awk',
21 'git log', 'git diff', 'git status', 'git show', 'git blame', 'git grep', 'git ls-files', 'git rev-parse', 'git fetch',
22 'pwd', 'echo', 'which', 'command -v', 'stat', 'file', 'du', 'tree', 'jq', 'shasum', 'date', 'curl -s',
23]
24const MCP_LOOK_VERBS = ['get', 'list', 'read', 'search', 'find', 'query', 'fetch', 'describe', 'view', 'lookup', 'status', 'download']
25const MCP_CHANGE_VERBS = [
26 'create', 'update', 'delete', 'send', 'post', 'set', 'write', 'edit',
27 'upload', 'add', 'remove', 'apply', 'label', 'move', 'share', 'trash',
28]
29
30/** A regex alternation of a word list; a space inside an entry matches any run of spaces. */
31function anyOf(words: readonly string[]): string {
32 return words.map(w => w.split(' ').join('\\s+')).join('|')
33}
34
35// A shell call counts as looking when it starts with a read-only command, after an optional `cd <dir> &&`.
36const READONLY_SHELL = new RegExp(`^\\s*(cd\\s+\\S+\\s*(&&|;)\\s*)?(${anyOf(READONLY_COMMANDS)})\\b`)
37const MCP_LOOK = new RegExp(`^(${anyOf(MCP_LOOK_VERBS)})`, 'i')
38const MCP_CHANGE = new RegExp(`^(${anyOf(MCP_CHANGE_VERBS)})`, 'i')
39// The in-app browser and Claude in Chrome: reading and moving around a page is looking.
40const BROWSER = /^mcp__(Claude_Browser|claude-in-chrome|remote-devices__Claude_Browser)__/
41const BROWSER_CHANGE = /^(form_input|file_upload)$/i
42const BROWSER_RUN = /^(javascript_tool|javascript_exec|preview_stop)$/i
43
44function field(input: unknown, key: string): string {
45 if (input && typeof input === 'object' && key in input) {
46 const v = (input as Record<string, unknown>)[key]
47 return typeof v === 'string' ? v : ''
48 }
49 return ''
50}
51
52export function laneOf(name: string, input: unknown): Lane {
53 if (PLAN.has(name)) return 'think'
54 if (LOOK.has(name)) return 'look'
55 if (CHANGE.has(name)) return 'change'
56 if (DELEGATE.has(name)) return 'delegate'
57 if (TALK.has(name)) return 'talk'
58 if (name === 'Bash' || name === 'PowerShell') return READONLY_SHELL.test(field(input, 'command')) ? 'look' : 'run'
59 if (BROWSER.test(name)) {
60 const verb = name.split('__').pop() ?? ''
61 return BROWSER_CHANGE.test(verb) ? 'change' : BROWSER_RUN.test(verb) ? 'run' : 'look'
62 }
63 if (name.startsWith('mcp__')) {
64 const verb = name.split('__').pop() ?? ''
65 if (MCP_LOOK.test(verb)) return 'look'
66 if (MCP_CHANGE.test(verb)) return 'change'
67 }
68 return 'run'
69}
70
71export function short(text: string, max = 48): string {
72 const one = text.replace(/\s+/g, ' ').trim()
73 return one.length > max ? one.slice(0, max - 1) + '…' : one
74}
75
76function tailPath(path: string): string {
77 return path.split('/').filter(Boolean).slice(-2).join('/')
78}
79
80/** A short target for a tool call: the file, pattern, command, URL or task it names. */
81export function targetOf(name: string, input: unknown): string {
82 for (const key of ['file_path', 'notebook_path', 'path']) {
83 const v = field(input, key)
84 if (v) return short(tailPath(v))
85 }
86 for (const key of ['pattern', 'url', 'query', 'command', 'skill', 'description', 'subagent_type', 'prompt', 'action']) {
87 const v = field(input, key)
88 if (v) return short(v)
89 }
90 return name.startsWith('mcp__') ? short(name.split('__').slice(1).join(' ')) : ''
91}
92
93export function describeAction(a: Action): string {
94 const end = a.outcome === 'error' ? ' (failed)' : a.outcome === 'denied' ? ' (refused)' : ''
95 return `${a.name}${a.target ? ` ${a.target}` : ''}${end}`
96}
97
98// ---------- the record a small model words ----------
99
100export const MAX_TRACE_STEPS = 40
101
102/** True when there is nothing to word: one step that only wrote the answer. */
103export function isTrivial(turn: Turn): boolean {
104 return turn.steps.length <= 1 && turn.steps.every(s => s.actions.length === 0 && s.subSteps === 0)
105}
106
107/** The visible record of a turn as compact JSON: request, steps (thinking as shown, actions), answer. */
108export function traceFor(turn: Turn): string {
109 const steps = turn.steps.length > MAX_TRACE_STEPS
110 ? [...turn.steps.slice(0, MAX_TRACE_STEPS / 2), ...turn.steps.slice(-MAX_TRACE_STEPS / 2)]
111 : turn.steps
112 return JSON.stringify({
113 request: short(turn.prompt, 700),
114 steps: steps.map(s => ({
115 step: s.n,
116 thinking_as_shown: s.thinking ? short(s.thinking, 300) : s.thinkingBlocks > 0 ? '(hidden)' : '',
117 actions: [
118 ...s.actions.slice(0, 8).map(describeAction),
119 ...(s.actions.length > 8 ? [`and ${s.actions.length - 8} more`] : []),
120 ...(s.subSteps > 0 ? [`subagents took ${s.subSteps} steps`] : []),
121 ],
122 wrote_text: s.wroteChars > 0,
123 })),
124 ...(turn.steps.length > MAX_TRACE_STEPS ? { note: `${turn.steps.length - MAX_TRACE_STEPS} middle steps left out` } : {}),
125 answer: short(turn.answer, 700),
126 })
127}
128
129export const SYSTEM = [
130 'You turn the record of how an AI assistant handled one request into a short flow that a reader can follow.',
131 'Use only what the record shows. Never state a reason, a finding or a result that the record does not show.',
132 'Write plain words, in the past tense, for a reader who knows neither the tools nor the project: "Read the routine settings", not "Read routines.json"; "Compared the two versions", not "Diffed".',
133 'Avoid tool words such as diff, grep, snapshot, commit, prepend or repo unless the reader needs them.',
134].join(' ')
135
136export function promptFor(turn: Turn): string {
137 const n = turn.steps.length
138 return [
139 'The record:',
140 traceFor(turn),
141 '',
142 `Group the ${n} steps into 2 to 6 stages, in order. Each stage covers consecutive steps, and together the stages cover every step from 1 to ${n} exactly once.`,
143 'For each stage give: "label", a verb phrase of at most 6 words saying what was done; "detail", at most 14 words on what that stage found or decided, or "" when the record does not show it; "steps", the step numbers it covers.',
144 'Also give "answer": the answer in at most 12 words.',
145 'Reply with JSON only, in this shape: {"stages":[{"label":"...","detail":"...","steps":[1,2]}],"answer":"..."}',
146 ].join('\n')
147}
148
149/** Reads the model's reply and accepts it only when the stages cover steps 1..n once, in order. */
150export function parseFlow(text: string, n: number): Flow | null {
151 const start = text.indexOf('{')
152 const end = text.lastIndexOf('}')
153 if (start < 0 || end <= start) return null
154 let data: unknown
155 try {
156 data = JSON.parse(text.slice(start, end + 1))
157 } catch {
158 return null
159 }
160 if (!data || typeof data !== 'object') return null
161 const raw = (data as { stages?: unknown }).stages
162 if (!Array.isArray(raw) || raw.length < 1 || raw.length > 8) return null
163 const stages: Stage[] = []
164 const seen: number[] = []
165 for (const item of raw) {
166 if (!item || typeof item !== 'object') return null
167 const label = field(item, 'label')
168 const detail = field(item, 'detail')
169 const steps = (item as { steps?: unknown }).steps
170 if (!label || !Array.isArray(steps) || steps.length === 0) return null
171 if (!steps.every(x => typeof x === 'number' && Number.isInteger(x))) return null
172 seen.push(...(steps as number[]))
173 stages.push({ label: short(label, 60), detail: short(detail, 110), steps: steps as number[] })
174 }
175 if (seen.length !== n || seen.some((x, i) => x !== i + 1)) return null
176 const answer = field(data, 'answer')
177 return { by: 'haiku', stages, answer: short(answer || '', 120) }
178}
179
180// ---------- kinds of work ----------
181
182// When steps did several kinds of work, the strongest names them: a change matters most to the
183// reader, then a command, a subagent, a lookup, words to the person, and thinking last.
184const STRENGTH: readonly Lane[] = ['change', 'run', 'delegate', 'look', 'talk', 'think']
185
186/** The strongest kind of work in some steps. A refused call never ran, so it does not count. */
187function strongest(steps: Step[]): Lane {
188 const seen = new Set<Lane>()
189 for (const s of steps) {
190 for (const a of s.actions) if (a.outcome !== 'denied') seen.add(a.lane)
191 if (s.actions.length === 0 && s.subSteps > 0) seen.add('delegate')
192 if (s.wroteChars > 0) seen.add('talk')
193 }
194 return STRENGTH.find(lane => seen.has(lane)) ?? 'think'
195}
196
197/** What a stage box shows by its border color. Writing to the person counts with thinking: neither works outside the conversation. */
198export type Kind = 'think' | 'look' | 'change' | 'run' | 'delegate'
199
200export function kindOf(turn: Turn, stage: Stage): Kind {
201 const lane = strongest(turn.steps.filter(s => stage.steps.includes(s.n)))
202 return lane === 'talk' ? 'think' : lane
203}
204
205// ---------- rules, when the model is not used or fails ----------
206
207function keyOf(step: Step): Lane {
208 return strongest([step])
209}
210
211function plural(n: number, one: string, many: string): string {
212 return `${n} ${n === 1 ? one : many}`
213}
214
215function ruleLabel(key: Lane, steps: Step[]): Stage {
216 const actions = steps.flatMap(s => s.actions).filter(a => a.outcome !== 'denied')
217 const targets = [...new Set(actions.map(a => a.target).filter(Boolean))]
218 const detail = short(targets.slice(0, 3).join(', ') + (targets.length > 3 ? `, and ${targets.length - 3} more` : ''), 110)
219 const n = steps.map(s => s.n)
220 const label =
221 key === 'look' ? `Looked at ${plural(actions.filter(a => a.lane === 'look').length, 'source', 'sources')}`
222 : key === 'change' ? `Changed ${plural(new Set(actions.filter(a => a.lane === 'change').map(a => a.target)).size, 'file', 'files')}`
223 : key === 'run' ? `Ran ${plural(actions.filter(a => a.lane === 'run').length, 'command', 'commands')}`
224 : key === 'delegate' ? `Handed work to subagents`
225 : key === 'talk' ? 'Wrote to you'
226 : 'Thought it through'
227 return { label, detail: key === 'think' ? short(steps.map(s => s.thinking).filter(Boolean).join(' '), 110) : detail, steps: n }
228}
229
230/** Groups consecutive steps of the same kind of work, labelled by fixed rules. */
231export function rulesFlow(turn: Turn, note?: string): Flow {
232 const stages: Stage[] = []
233 let run: Step[] = []
234 let key: Lane | null = null
235 for (const step of turn.steps) {
236 const k = keyOf(step)
237 if (key !== null && k !== key) {
238 stages.push(ruleLabel(key, run))
239 run = []
240 }
241 key = k
242 run.push(step)
243 }
244 if (key !== null) stages.push(ruleLabel(key, run))
245 return { by: 'rules', stages, answer: short(turn.answer || '(no answer text)', 120), ...(note ? { note } : {}) }
246}
247
248// ---------- drawing ----------
249
250export function escapeXml(text: string): string {
251 return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
252}
253
254/** Splits text into at most `max` lines of about `width` characters, by words. */
255export function wrap(text: string, width: number, max: number): string[] {
256 const words = text.replace(/\s+/g, ' ').trim().split(' ').filter(Boolean)
257 const lines: string[] = []
258 let line = ''
259 for (const w of words) {
260 if ((line + ' ' + w).trim().length > width && line) {
261 lines.push(line)
262 line = w
263 if (lines.length === max) break
264 } else line = (line + ' ' + w).trim()
265 }
266 if (lines.length < max && line) lines.push(line)
267 const used = lines.join(' ').length
268 if (used < text.replace(/\s+/g, ' ').trim().length && lines.length) {
269 lines[lines.length - 1] = short(lines[lines.length - 1] + ' …', width)
270 }
271 return lines
272}
273
274// The drawing's own coordinate space is narrow (VIEW_W units); the markup declares a much larger
275// width (DECLARED_W px) so the pane's box takes the full panel width ("the markup's own width up
276// to the slot") and the drawing scales up to it. Text sizes below are in coordinate units, so a
277// wider panel shows proportionally larger text.
278const VIEW_W = 400
279const DECLARED_W = 1600
280const PAD = 10
281const BOX_X = PAD
282const BOX_W = VIEW_W - 2 * PAD
283const GAP = 22
284const TITLE = { size: 15, line: 19, chars: 40, lines: 2 }
285const BODY = { size: 13, line: 17, chars: 50, lines: 3 }
286const TOOLS = { size: 11, line: 14, chars: 60, lines: 2 }
287// The palette: a deep charcoal canvas, navy-black stage boxes with white text and colored outlines,
288// a white card for the question and a lemon-yellow card for the answer.
289const CANVAS = '#121212'
290const BOX = '#0D1137'
291const WHITE = '#FDFDFD'
292const INK = { title: WHITE, body: '#CDCED5', tools: '#9193A4', dark: BOX }
293const QUESTION = WHITE
294const ANSWER = '#E2D810'
295/** Stage kinds in the order the color key lists them: outline color, dashed or solid, and label. */
296export const KINDS: readonly Kind[] = ['think', 'look', 'change', 'run', 'delegate']
297// Four outline colors stay distinct, also for color-blind readers; subagents share white, dashed.
298export const KIND: Record<Kind, { color: string; dashed: boolean; label: string }> = {
299 think: { color: WHITE, dashed: false, label: 'Thought or wrote to you' },
300 look: { color: '#12A4D9', dashed: false, label: 'Looked at files or the web' },
301 change: { color: '#E52165', dashed: false, label: 'Made changes' },
302 run: { color: '#CCA800', dashed: false, label: 'Ran commands or tools' },
303 delegate: { color: WHITE, dashed: true, label: 'Handed work to subagents' },
304}
305// The legend, titled "Color key": a card above the flow, two entries per row.
306const LEGEND = { size: 11, row: 17, col: 184 }
307
308/** The color key card for the kinds a flow uses, drawn at y; returns its markup and height. */
309function legendCard(kinds: Kind[], y: number): { svg: string; h: number } {
310 if (kinds.length === 0) return { svg: '', h: 0 }
311 const left = BOX_X + 12
312 const h = 25 + Math.ceil(kinds.length / 2) * LEGEND.row
313 const entries = kinds.map((k, i) => {
314 const x = left + (i % 2) * LEGEND.col
315 const ry = y + 17 + LEGEND.row * (Math.floor(i / 2) + 1)
316 const dash = KIND[k].dashed ? ' stroke-dasharray="3 2"' : ''
317 return `<rect x="${x}" y="${ry - 9}" width="16" height="11" rx="3" fill="${BOX}" stroke="${KIND[k].color}" stroke-width="2"${dash} />` +
318 `<text x="${x + 22}" y="${ry}" font-size="${LEGEND.size}" fill="${INK.body}">${escapeXml(KIND[k].label)}</text>`
319 })
320 const svg = `<g><rect x="${BOX_X}" y="${y}" width="${BOX_W}" height="${h}" rx="8" fill="${BOX}" stroke="${INK.tools}" stroke-opacity="0.5" stroke-width="1" />` +
321 `<text x="${left}" y="${y + 17}" font-size="${LEGEND.size}" font-weight="600" fill="${INK.tools}">Color key</text>${entries.join('')}</g>`
322 return { svg, h }
323}
324
325/** A short display name for a tool: "Read", "Browser navigate", "Slack send_message". */
326export function toolName(name: string): string {
327 if (!name.startsWith('mcp__')) return name
328 const parts = name.split('__')
329 const server = parts[1] ?? ''
330 const tool = parts[parts.length - 1] ?? name
331 const label = /Claude_Browser/i.test(server) ? 'Browser' : /claude-in-chrome/i.test(server) ? 'Chrome' : server.replace(/^[0-9a-f-]{20,}$/i, 'Connector')
332 return `${label} ${tool}`
333}
334
335/** The tool calls of some steps as "Read ×3, Bash ×2", in order of first use, plus failures and subagent steps. */
336export function toolSummary(steps: Step[]): string {
337 const counts = new Map<string, number>()
338 let failed = 0
339 for (const s of steps) {
340 for (const a of s.actions) {
341 const n = toolName(a.name)
342 counts.set(n, (counts.get(n) ?? 0) + 1)
343 if (a.outcome === 'error' || a.outcome === 'denied') failed += 1
344 }
345 }
346 const subs = steps.reduce((a, s) => a + s.subSteps, 0)
347 const bits = [...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n))
348 if (subs > 0) bits.push(`${subs} subagent steps`)
349 if (failed > 0) bits.push(`${failed} failed`)
350 return bits.join(', ')
351}
352
353/** The flow as a vertical SVG that fills the pane's width: a color key, then question, stages, answer, with arrows. */
354export function svgFlow(turn: Turn, flow: Flow): { source: string; alt: string; width: number; height: number } {
355 type Box = {
356 fill: string
357 stroke: string | null
358 dashed: boolean
359 ink: { title: string; body: string }
360 title: string
361 body: string[]
362 tools: string[]
363 tip: string
364 }
365 const onCard = { title: INK.dark, body: INK.dark }
366 const onBox = { title: INK.title, body: INK.body }
367 const boxes: Box[] = []
368 const used = new Set<Kind>()
369 const said: string[] = []
370 boxes.push({ fill: QUESTION, stroke: null, dashed: false, ink: onCard, title: 'Question', body: wrap(turn.prompt, BODY.chars, BODY.lines), tools: [], tip: turn.prompt })
371 for (const stage of flow.stages) {
372 const kind = kindOf(turn, stage)
373 used.add(kind)
374 said.push(`${stage.label} (${KIND[kind].label.toLowerCase()})`)
375 const steps = turn.steps.filter(s => stage.steps.includes(s.n))
376 const acts = steps.flatMap(s => s.actions.map(describeAction))
377 const subs = steps.reduce((a, s) => a + s.subSteps, 0)
378 const thought = steps.map(s => s.thinking).filter(Boolean).join(' ')
379 const tip = [
380 `Steps ${stage.steps[0]}${stage.steps.length > 1 ? `–${stage.steps[stage.steps.length - 1]}` : ''}`,
381 ...acts.slice(0, 12),
382 ...(acts.length > 12 ? [`and ${acts.length - 12} more`] : []),
383 ...(subs > 0 ? [`${subs} steps inside subagents`] : []),
384 ...(thought ? [`Thinking as shown: ${short(thought, 240)}`] : []),
385 ].join('\n')
386 const summary = toolSummary(steps)
387 boxes.push({
388 fill: BOX,
389 stroke: KIND[kind].color,
390 dashed: KIND[kind].dashed,
391 ink: onBox,
392 title: stage.label,
393 body: stage.detail ? wrap(stage.detail, BODY.chars, BODY.lines) : [],
394 tools: summary ? wrap(`Tools: ${summary}`, TOOLS.chars, TOOLS.lines) : [],
395 tip,
396 })
397 }
398 boxes.push({ fill: ANSWER, stroke: null, dashed: false, ink: onCard, title: 'Answer', body: wrap(flow.answer, BODY.chars, BODY.lines), tools: [], tip: short(turn.answer, 600) })
399
400 const parts: string[] = []
401 const cx = BOX_X + BOX_W / 2
402 const left = BOX_X + 12
403 let y = PAD
404 const legend = legendCard(KINDS.filter(k => used.has(k)), y)
405 if (legend.h > 0) {
406 parts.push(legend.svg)
407 y += legend.h + 14
408 }
409 boxes.forEach((b, i) => {
410 const titleLines = wrap(b.title, TITLE.chars, TITLE.lines)
411 const toolsH = b.tools.length > 0 ? 6 + b.tools.length * TOOLS.line : 0
412 const h = 12 + titleLines.length * TITLE.line + b.body.length * BODY.line + toolsH + 10
413 let ty = y + 12 + TITLE.size - 2
414 const lines: string[] = []
415 for (const line of titleLines) {
416 lines.push(`<text x="${cx}" y="${ty}" text-anchor="middle" font-size="${TITLE.size}" font-weight="600" fill="${b.ink.title}">${escapeXml(line)}</text>`)
417 ty += TITLE.line
418 }
419 for (const line of b.body) {
420 lines.push(`<text x="${cx}" y="${ty}" text-anchor="middle" font-size="${BODY.size}" fill="${b.ink.body}">${escapeXml(line)}</text>`)
421 ty += BODY.line
422 }
423 if (b.tools.length > 0) {
424 // bottom left, under a thin rule
425 const ruleY = y + h - 10 - b.tools.length * TOOLS.line - 3
426 lines.push(`<line x1="${left}" y1="${ruleY}" x2="${BOX_X + BOX_W - 12}" y2="${ruleY}" stroke="${b.stroke ?? INK.tools}" stroke-opacity="0.25" stroke-width="1" />`)
427 let ly = ruleY + TOOLS.size + 2
428 for (const line of b.tools) {
429 lines.push(`<text x="${left}" y="${ly}" text-anchor="start" font-size="${TOOLS.size}" fill="${INK.tools}">${escapeXml(line)}</text>`)
430 ly += TOOLS.line
431 }
432 }
433 const outline = b.stroke ? ` stroke="${b.stroke}" stroke-width="2"${b.dashed ? ' stroke-dasharray="6 4"' : ''}` : ''
434 const frame = `<rect x="${BOX_X}" y="${y}" width="${BOX_W}" height="${h}" rx="8" fill="${b.fill}"${outline} />`
435 parts.push(`<g><title>${escapeXml(b.tip)}</title>${frame}${lines.join('')}</g>`)
436 y += h
437 if (i < boxes.length - 1) {
438 parts.push(`<line x1="${cx}" y1="${y + 3}" x2="${cx}" y2="${y + GAP - 4}" stroke="${INK.tools}" stroke-width="1.5" />`)
439 parts.push(`<path d="M${cx - 5} ${y + GAP - 9} L${cx} ${y + GAP - 3} L${cx + 5} ${y + GAP - 9}" fill="none" stroke="${INK.tools}" stroke-width="1.5" />`)
440 y += GAP
441 }
442 })
443 const viewH = y + PAD
444 const declaredH = Math.round((viewH * DECLARED_W) / VIEW_W)
445 const alt = `Flow of how the answer came about: ${said.join(', then ')}; answer: ${flow.answer}`
446 // The whole figure sits on its own dark canvas, so it looks the same on a light or a dark panel.
447 const canvas = `<rect x="0" y="0" width="${VIEW_W}" height="${viewH}" rx="12" fill="${CANVAS}" />`
448 const source = `<svg xmlns="http://www.w3.org/2000/svg" width="${DECLARED_W}" height="${declaredH}" viewBox="0 0 ${VIEW_W} ${viewH}" preserveAspectRatio="xMidYMin meet" font-family="-apple-system, Segoe UI, sans-serif">${canvas}${parts.join('')}</svg>`
449 return { source, alt, width: DECLARED_W, height: declaredH }
450}
451
452/** The flow as numbered lines, for surfaces without Svg. */
453export function textFlow(turn: Turn, flow: Flow): string[] {
454 return [
455 `Question: ${short(turn.prompt, 100)}`,
456 ...flow.stages.map((s, i) => `${i + 1}. ${s.label}${s.detail ? ` — ${s.detail}` : ''}`),
457 `Answer: ${flow.answer}`,
458 ]
459}
460
461/** One line on who worded the flow. */
462export function creditOf(turn: Turn, flow: Flow): string {
463 if (flow.by === 'haiku') return 'Steps worded by a small model (Haiku) from what was visible: thinking summaries, tool calls and the answer.'
464 if (turn.flowState === 'pending' || turn.flowState === 'working') return 'Grouped by rules for now; the small model is wording the steps.'
465 return `Grouped by rules${flow.note ? ` (${flow.note})` : ''}.`
466}
467
468/** What each stage did, for the "Show actions" list: its label, then its actions and thinking as shown. */
469export function stageDetails(turn: Turn, flow: Flow): { label: string; lines: string[] }[] {
470 return flow.stages.map(stage => {
471 const steps = turn.steps.filter(s => stage.steps.includes(s.n))
472 const acts = steps.flatMap(s => s.actions.map(describeAction))
473 const subs = steps.reduce((a, s) => a + s.subSteps, 0)
474 const thought = steps.map(s => s.thinking).filter(Boolean).join(' ')
475 const span = stage.steps.length > 1 ? `steps ${stage.steps[0]}–${stage.steps[stage.steps.length - 1]}` : `step ${stage.steps[0]}`
476 return {
477 label: `${stage.label} (${span})`,
478 lines: [
479 ...acts.slice(0, 10),
480 ...(acts.length > 10 ? [`and ${acts.length - 10} more`] : []),
481 ...(subs > 0 ? [`${subs} steps inside subagents`] : []),
482 ...(thought ? [`Thinking as shown: ${short(thought, 200)}`] : []),
483 ...(acts.length === 0 && subs === 0 && !thought ? ['(no actions; wrote text)'] : []),
484 ],
485 }
486 })
487}
488types/index.d.ts 62 lines1// thinking-flow: the type contract of its session state ($.state).
2
3/** The kind of work an action is. 'think' holds the session's own planning tools (the to-do list). */
4export type Lane = 'think' | 'look' | 'change' | 'run' | 'delegate' | 'talk'
5
6/** One tool call: its name, a short target, its kind, and how it ended. */
7export type Action = {
8 id: string
9 name: string
10 target: string
11 lane: Lane
12 outcome: 'running' | 'ok' | 'error' | 'denied'
13}
14
15/** One model request of the main loop, numbered from 1 within its turn. */
16export type Step = {
17 n: number
18 /** The thinking text as the session showed it, cut short; '' when hidden. */
19 thinking: string
20 thinkingChars: number
21 thinkingBlocks: number
22 actions: Action[]
23 /** Model requests made inside subagents this step started. */
24 subSteps: number
25 /** Characters of visible text the step wrote. */
26 wroteChars: number
27}
28
29/** One box of the flow: a plain-word label, a short detail, the steps it covers. */
30export type Stage = { label: string; detail: string; steps: number[] }
31
32export type Flow = {
33 /** Who worded it: the small model, or the built-in rules. */
34 by: 'haiku' | 'rules'
35 stages: Stage[]
36 /** The answer in a short line. */
37 answer: string
38 /** Why the rules were used, when they were. */
39 note?: string
40}
41
42export type Turn = {
43 id: string
44 prompt: string
45 answer: string
46 startedAt: number
47 endedAt?: number
48 reason?: string
49 steps: Step[]
50 flowState: 'open' | 'pending' | 'working' | 'done'
51 flow?: Flow
52}
53
54/** Which turn the pane shows: null follows the latest. */
55export type View = { turn: number | null; details?: boolean }
56
57declare module 'claude-code' {
58 interface PluginState {
59 'thinking-flow': { turns: Turn[]; view: View }
60 }
61}
62