SLOPSHOPPER

Thinking Flow

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…

newpaneguardcommandmodeltimer
v0.1.4MITupdated 2026-10-13thunini/thinking-flow
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · thinking-flow
│ ┃ Thinking flow ✕ › fix the failing auth test and add an audit log call │ ┃ No answer yet. The flow appears when the │ ┃ next answer is done. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /thinking-flow │ ⎿ thinking-flow: Thinking flow opened. Each answer from now on get │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Thinking flow
No answer yet. The flow appears when the next answer is done.
README

thinking-flow

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 flow with all five kinds of step

An example. The question and the steps are made up for this picture.

What the panel shows

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:

OutlineKind of work
WhiteThought or wrote to you, with no tools
CyanLooked at files or the web
Hot pinkMade changes, such as edits or form entries
GoldRan commands or other tools
White, dashedHanded 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.

Install

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.

What it sends, and what it costs

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:

  • your message, cut to 700 characters
  • for each step: the thinking summary as the session showed it (cut to 300 characters), up to eight tool calls with a short target (the last two parts of a file path, or a command, URL or search cut to 48 characters), and whether the step wrote text
  • Claude's answer, cut to 700 characters

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.

What it hooks

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.

HookWhat it does
session.startRegisters the /thinking-flow command and starts the timer that words finished answers
command.runAnswers /thinking-flow only, by opening the panel
turn.startStarts a record for the new answer
turn.stepNotes each model request's thinking summary, tool calls and text, and passes every part on unchanged
tool.callNotes whether each tool call succeeded, failed or was refused, and passes the call on unchanged
turn.completeMarks the answer as finished, so the timer can word it
ui.renderDraws the panel

How the wording stays close to the record

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.

Limits

  • The figure is a picture. Use Show actions for the details behind a step.
  • A flow appears a few seconds after its answer ends.
  • Steps that subagents take are counted on the step that started them.
  • A new session starts with an empty panel.

Develop

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.

License

MIT. See LICENSE.

Source 3 files
hooks/register.tsx 275 lines
1// 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}
275
hooks/flow.ts 488 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
488
types/index.d.ts 62 lines
1// 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