SLOPSHOPPER

trace-map

A pane that shows live what Claude is doing: the turn's phase, the last tool, and every file it touched

newpaneguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · trace-map
│ ┃ Trace map ✕ › fix the failing auth test and add an audit log call │ ┃ Claude is answered · 9 tool calls │ ┃ ✓ витираю стіл, доки ніхто не бачить ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Trail ⏺ Update(src/auth.ts) │ ┃ …k/app/src/audit.ts → /work/app/src/cache.ts ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Files (3) c: clear d: ✕ фрази ⎿ 3 pass, 1 fail │ ┃ 1× /work/app/src/cache.ts │ ┃ 1× /work/app/src/audit.ts ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 2× /work/app/src/auth.ts │ ✻ Worked for 42s · done 4:20 PM │ │ › /trace-map │ ⎿ trace-map: Trace map opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Trace map
Claude is answered · 9 tool calls ✓ витираю стіл, доки ніхто не бачить Trail …c/auth.ts → /work/app/src/audit.ts → /work/app/src/cache.ts Files (3) c: clear d: ✕ фрази 1× /work/app/src/cache.ts 1× /work/app/src/audit.ts 2× /work/app/src/auth.ts
README

trace-map

A Claude Code mod that adds a pane beside the transcript and draws, live, what Claude is doing during a turn. It was built for screencasts: instead of a wall of logs, viewers watch a map.

English · Українська

The Trace map pane in the Claude Desktop Code tab

The pane while Claude works on this repository: a shell call disguised as a chore, the thought stream on the right, the file map on the left, and the activity strip.

What it draws

One SVG image, redrawn on every event. From top to bottom:

  • Phase ring and label: idle (grey), thinking (yellow, the arc turns), using a tool (blue), answered (green dot).
  • Action line: a status mark (… running, ✓ done, ✗ error), a phrase in the tool's color, and a small hint such as a file name or a search pattern. By default the phrase is a homely chore in Ukrainian instead of the raw command, for example ✓ пришиваю ґудзик, відірваний втретє login.ts ("sewing on a button, torn off for the third time"). Each kind of call has its own set of phrases: search, read, edit, write, shell, delete, build, git, commit, push, pull, tests, notes, subagent and web.
  • Thought stream: the last lines of the model's reasoning in grey italics while it thinks.
  • File map: the project at the center, folders on the inner ring and files on the outer ring. A folder that was searched is a dashed amber circle. A file's dot grows with every touch and takes the color of the last tool. Blue arcs join files touched one after another.
  • Activity strip: one tick per tool call in the tool's color, with errors as short red ticks, next to an N tool calls counter.
  • Legend: read blue, search amber, edit green, shell purple, agent pink.
  • Row under the image: N files, a clear button, and a button that turns the phrases off and on.

In the terminal the pane shows a text list instead of the SVG. The pane opens by itself when a session starts.

Commands

| Command | What it does | | :- | :- | | /trace-map | Open the pane | | /trace-map honest | Show the tool and its gist instead of the phrases | | /trace-map disguise | Bring the phrases back |

The phrases choice is kept between sessions. You can also change it in /plugin → Installed → trace-map → Configure options (the Disguise the last tool option), or in the trace-map.disguise row of /config.

Examples

1. Record a screencast of Claude at work. Open the pane at one of the recommended widths and start recording. Keep the phrases on for a light tone, or run /trace-map honest for a technical audience. A prompt that makes a good recording:

Audit src/ for tech debt and write the findings to TECH-DEBT.md, worst first.

Viewers see the searches fan out as dashed rings, the files light up as Claude reads them, and the edit to TECH-DEBT.md turn green at the end.

2. Watch a large exploration as it happens. In a big or unfamiliar repository, the map shows which folders Claude searched, which files it read, and where it keeps coming back, since a dot grows with every touch. If it wanders into the wrong part of the code, you can stop it early and point it elsewhere. For example:

Find where the session token is refreshed and explain the flow end to end.

3. Check a change before you review it. Press clear before the task so the map shows that task alone. When Claude answers, a file's dot has the color of the last tool that touched it: blue for read, green for edited, purple for a shell command. A green file in a folder you didn't expect is the first place to look. Run /trace-map honest to see the last tool call as it really was. For example:

Rename the userId field to accountId across the API layer and update its tests.

Install

In a Claude Code session:

/plugin marketplace add ivangithubed/claude-mods
/plugin install trace-map@learningtogether-mods

From your shell:

claude plugin marketplace add ivangithubed/claude-mods
claude plugin install trace-map@learningtogether-mods

If a session is already open, run /reload-plugins there. To check, run /plugin: it shows 1 mod active · trace-map.

It needs Claude Code 2.1.287 or later in the terminal, or the Claude Desktop app from 2.1.286. The pane is drawn in the terminal and in the Code tab of Claude Desktop. In the VS Code extension, claude -p and cloud sessions the hooks run but nothing is drawn.

Pane width

The image adapts to the pane's width, but the action line is only readable in full at some widths. A quick check: a line such as ✓ пришиваю ґудзик, відірваний втретє login.ts should fit on one line with no … at the end.

| Pane width | Layout | What you see | | :- | :- | :- | | under ~57 columns (~440 px) | stacked | the phrase is cut | | ~60–79 columns (~470–620 px) | stacked | the full phrase; the file hint may drop | | ~80–91 columns (~620–710 px) | stacked | phrase and hint in full: recommended for a narrow pane | | ~92–149 columns (~720–1180 px) | map left, text right | the right column is narrow and the phrase is shortened | | ~150 columns and up (~1180 px) | map left, text right | everything in full: recommended for a wide pane |

Pixels are approximate, since the mod counts 8 px per column. For screen recording, use either a narrow pane of 80–91 columns or a wide one of 150 or more.

What the mod reads, and what it doesn't do

The mod runs inside Claude Code with your permissions, so here is the full list.

What it reads while a session runs:

  • the input of every tool call: the file path for Read, Edit, Write and NotebookEdit; the pattern and folder for Grep and Glob; the first line of the command for Bash and PowerShell, from which it also picks the tokens that look like file names; the description for Agent;
  • the model's reasoning (thinking) as it streams, for the main agent only; it keeps the last 600 characters in memory and never stores them;
  • the session's working folder, its id, and the theme from /config.

The events for a turn's start and end carry your prompt and Claude's answer. The mod uses them only to know when a turn starts and ends, and does not use or keep their text.

What it stores: only the map's state, in Claude Code's plugin store ($.store) under a key for the session, so the pane survives /reload-plugins. That is the touched file paths, the searched folders and patterns, the order of the last files touched, per-call tool names and success, the number of calls, the turn's phase, and the last tool call's name, kind and short hint, which is a file name or a search pattern. The session's key is deleted when the session ends. The phrases choice is stored separately. The mod keeps no log and stores no prompt, reasoning or command text. The honest line shows the last command from memory, so after /reload-plugins it shows only the tool's name until the next call. See PRIVACY.md.

What it changes: no settings and no environment variables. When you turn the phrases on or off with the button or a command, the choice goes into the mod's own store, not into Claude Code's settings. If you later change the disguise option in /config, that change wins over the button. The mod also hooks changes to the theme setting (config.set{key=theme}) only to redraw the pane in matching colors, and passes each change on unchanged.

What it doesn't do:

  • it makes no network requests, calls no model and starts no processes;
  • it reads and writes no files on disk: none of the calls: below touch files or the network;
  • it changes no tool calls, prompts or permissions: every hook passes its event on unchanged.

The full list of events and API calls is what claude plugin validate prints for this folder:

Validating plugin manifest: .../plugins/trace-map/.claude-plugin/plugin.json

Validating hooks: .../plugins/trace-map/hooks/hooks.json

  ❯ ./register.tsx hooks: session.start, session.end, config.set{key=theme}, command.run{command=trace-map}, turn.start, turn.complete, turn.step, tool.call, ui.render{component=Pane, requestId=trace-map}
  ❯ ./register.tsx calls: $.command.register, $.config.list (via readTheme), $.session.id, $.store.delete, $.store.get, $.store.set (via changed, setDisguise), $.ui.invalidate, $.ui.open, $.ui.resolve

✔ Validation passed

Development

claude plugin validate ./plugins/trace-map
claude plugin test ./plugins/trace-map

There are 18 tests in hooks/register.test.ts. One of them checks that no prompt or reasoning text reaches the store. The files:

.claude-plugin/plugin.json   manifest, with the `disguise` userConfig option
hooks/hooks.json             points to the hooks module
hooks/register.tsx           the hooks and the drawing
hooks/disguise.ts            the phrases and how calls are classified
hooks/register.test.ts       tests
types/index.d.ts             shared types
docs/                        a sound design brief in Ukrainian
PRIVACY.md                   what the mod reads, stores and sends

Claude Code generates the .claude-plugin/types/ folder with the mods API types, and it is in .gitignore.

Versions

  • 0.3.4: the phrases button keeps its choice in the mod's own store and no longer writes Claude Code's settings; a later change in /config wins over it. The screenshot moved out of the plugin folder.
  • 0.3.3: a local variable no longer reuses the name of the JSX factory, so the directory's scanner can read the mod.
  • 0.3.2: a listing icon, and the README says which setting the mod changes.
  • 0.3.1: the text of commands is no longer stored. The honest line keeps it in memory only.
  • 0.3.0: the log and /trace-map log are gone, so the mod no longer keeps any prompt or command history. Adds a privacy policy, support and documentation links, and usage examples.
  • 0.2.0: Ukrainian chore phrases by kind of call, a button and /trace-map honest|disguise|log, the thought stream in the pane, state kept across reloads.
  • 0.1.0: first version with the phase ring, tool line, radial file map and activity strip.

Support

Questions, bugs and ideas: GitHub Issues. If the pane doesn't appear, check the Claude Code version, run /plugin to see whether the mod is listed as active, and run /trace-map to open the pane by hand.

License

MIT © 2026 learningtogetherua

Source 3 files
hooks/register.tsx 837 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import type { LastTool, Phase, Touch } from '../types'
4import { kindOf, phraseOf } from './disguise'
5
6const PANE = 'trace-map'
7
8// The `disguise` option of plugin.json: the last tool shown as a homely chore
9// ("нарізаю сир") instead of the raw command. Off, the honest line is drawn.
10// The pane's button and `/trace-map honest|disguise` flip it and keep the
11// choice in the plugin's own store, never in Claude Code's settings. The
12// choice remembers the option it was made against, so a later change of the
13// option in /config wins over it.
14let disguise = true
15let configured = true
16const DISGUISE_KEY = 'trace-map:disguise'
17
18/** The button's choice, and the /config option it was made against. */
19type Kept = { isOn: boolean; configured: boolean }
20
21function isKept(value: unknown): value is Kept {
22  if (typeof value !== 'object' || value === null) return false
23  const v = value as Record<string, unknown>
24  return typeof v.isOn === 'boolean' && typeof v.configured === 'boolean'
25}
26
27// $.store is shared by every session on the machine, so each session saves
28// under its own key: a new session starts clean, a hot reload within one
29// restores its list, and session.end removes the key.
30let storeKey = 'trace:unknown'
31
32const TRAIL_LENGTH = 12
33const MAX_TOUCHES = 300
34const DRAWN_FILES = 120
35const DRAWN_HOPS = 8
36const ACTIVITY_LENGTH = 48
37const THOUGHT_MAX = 600
38// While thinking streams, the pane redraws at most this often: each redraw swaps the image
39const REDRAW_EVERY_MS = 600
40
41// The desktop's approximate pixel size of one cell, to size the SVG to the pane
42const PX_PER_COLUMN = 8
43const PX_PER_ROW = 17
44const LANDSCAPE_FROM = 720
45
46// What the pane draws. Module variables are lost on a hot reload, so the
47// session's part (touches, trail, calls, activity, scans) is also kept in
48// $.store and loaded back in session.start.
49let phase: Phase = 'idle'
50let lastTool: LastTool | null = null
51let touches: Touch[] = []
52let trail: string[] = []
53let calls = 0
54let activity: Activity[] = []
55let scans: Scan[] = []
56let thought = ''
57let cwd = ''
58let isDark = true
59let lastRedrawAt = 0
60
61type Activity = { tool: string; isError: boolean }
62type Scan = { dir: string; pattern: string; count: number }
63
64// Nothing of the conversation is kept beyond what the pane draws: no log, no
65// prompt text, and the thought only in memory
66type Saved = {
67  touches: Touch[]
68  trail: string[]
69  calls: number
70  activity: Activity[]
71  scans: Scan[]
72  lastTool?: LastTool | null
73  phase?: Phase
74}
75
76function isSaved(value: unknown): value is Saved {
77  if (typeof value !== 'object' || value === null) return false
78  const v = value as Record<string, unknown>
79  return (
80    Array.isArray(v.touches) &&
81    Array.isArray(v.trail) &&
82    typeof v.calls === 'number' &&
83    Array.isArray(v.activity) &&
84    Array.isArray(v.scans)
85  )
86}
87
88/**
89 * What the store keeps of the session: the map, and the last tool without its
90 * gist, so no command text is written anywhere. After a reload the honest line
91 * shows the tool alone until the next call.
92 */
93function toSave(): Saved {
94  return { touches, trail, calls, activity, scans, lastTool: lastTool && { ...lastTool, what: '' }, phase }
95}
96
97/** Redraws the pane and saves the session's part of the state. */
98async function changed($: EngineInterface): Promise<void> {
99  $.ui.invalidate('ui.render')
100  lastRedrawAt = Date.now()
101  await $.store.set(storeKey, toSave())
102}
103
104/** Flips the disguise and keeps the choice in the plugin's store, read back on load. */
105async function setDisguise($: EngineInterface, isOn: boolean): Promise<void> {
106  disguise = isOn
107  await $.store.set(storeKey, toSave())
108  const kept: Kept = { isOn, configured }
109  await $.store.set(DISGUISE_KEY, kept)
110  $.ui.invalidate('ui.render')
111}
112
113/** Loads the button's choice, unless the /config option changed since it was made. */
114async function loadDisguise($: EngineInterface): Promise<void> {
115  const kept = await $.store.get(DISGUISE_KEY)
116  if (isKept(kept) && kept.configured === configured) disguise = kept.isOn
117  else if (kept !== undefined) await $.store.delete(DISGUISE_KEY)
118}
119
120/** Reads the theme row of /config: light themes start with `light`. */
121async function readTheme($: EngineInterface): Promise<void> {
122  const row = (await $.config.list()).find(one => one.key === 'theme')
123  isDark = !String(row?.value ?? 'dark').startsWith('light')
124}
125
126// The tools whose path names one file. Grep's and Glob's `path` is a search
127// root, not a file, so it counts as a scan of a directory instead.
128const FILE_TOOLS = new Set(['Read', 'Edit', 'Write', 'NotebookEdit'])
129const SEARCH_TOOLS = new Set(['Grep', 'Glob'])
130
131/** The file a tool call goes to, when the tool reads or writes one; else undefined. */
132function fileOf(tool: string, e: Record<string, unknown>): string | undefined {
133  if (!FILE_TOOLS.has(tool)) return undefined
134  const path = e.file_path ?? e.notebook_path
135  return typeof path === 'string' && path.length > 0 ? path : undefined
136}
137
138/** A path whose last segment has a short extension, as `notes.md` does and `.claude` does not. */
139function looksLikeFile(path: string): boolean {
140  const base = baseOf(path)
141  return !base.startsWith('.') && /\.[A-Za-z0-9]{1,6}$/.test(base)
142}
143
144// The extensions a token in a shell command may end in to count as a file:
145// `.length` and `.keys(e)` in an inline script are not files
146const FILE_EXTENSIONS = new Set([
147  'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'mts', 'cts', 'json', 'jsonc', 'md', 'mdx', 'txt', 'css', 'scss', 'less',
148  'html', 'htm', 'svg', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'ico', 'yml', 'yaml', 'toml', 'xml', 'csv', 'tsv',
149  'py', 'rb', 'go', 'rs', 'java', 'kt', 'c', 'h', 'cpp', 'hpp', 'cs', 'php', 'sh', 'bash', 'ps1', 'bat', 'cmd',
150  'sql', 'env', 'lock', 'log', 'cfg', 'ini', 'conf', 'vue', 'svelte', 'astro', 'prisma', 'graphql', 'proto', 'pdf',
151])
152
153/** A token of a shell command that names a file: a known extension, and no code around it. */
154function looksLikeFileToken(token: string): boolean {
155  if (/[()'"`]/.test(token)) return false
156  const base = baseOf(token)
157  if (base.startsWith('.')) return false
158  const dot = base.lastIndexOf('.')
159  return dot > 0 && FILE_EXTENSIONS.has(base.slice(dot + 1).toLowerCase())
160}
161
162const SHELL_TOOLS = new Set(['Bash', 'PowerShell'])
163const MAX_COMMAND_PATHS = 8
164
165/**
166 * The files a shell command names: its tokens that look like a file, quotes
167 * and punctuation stripped, globs and variables left out, relative ones
168 * resolved against the session's directory.
169 */
170function pathsInCommand(command: string): string[] {
171  const found: string[] = []
172  for (const raw of command.split(/\s+/)) {
173    const token = raw.replace(/^[('"`]+/, '').replace(/[)'"`;,|&]+$/, '')
174    if (token.length < 3 || token.startsWith('-') || token.includes('://')) continue
175    if (/[*?{}$<>[\]=]/.test(token)) continue
176    if (!looksLikeFileToken(token)) continue
177    const isAbsolute = token.startsWith('/') || /^[A-Za-z]:/.test(token)
178    found.push(isAbsolute ? token : slashes(cwd).replace(/\/+$/, '') + '/' + slashes(token).replace(/^\.\//, ''))
179  }
180  return [...new Set(found)].slice(0, MAX_COMMAND_PATHS)
181}
182
183function str(e: Record<string, unknown>, key: string): string {
184  return typeof e[key] === 'string' ? (e[key] as string) : ''
185}
186
187/** One short line that says what a tool call is about. */
188function gistOf(tool: string, e: Record<string, unknown>): string {
189  switch (tool) {
190    case 'Read':
191    case 'Edit':
192    case 'Write':
193    case 'NotebookEdit':
194      return fileOf(tool, e) ?? ''
195    case 'Grep':
196      return `/${str(e, 'pattern')}/` + (str(e, 'path') ? ` in ${str(e, 'path')}` : '')
197    case 'Glob':
198      return str(e, 'pattern')
199    case 'Bash':
200    case 'PowerShell':
201      return str(e, 'command').split('\n')[0] ?? ''
202    case 'Agent':
203      return str(e, 'description')
204    default:
205      return ''
206  }
207}
208
209/**
210 * One or two words of context for the disguise: a file's name or a pattern,
211 * never the command itself. A shell command gets the first file it names, if
212 * any; an agent, a web call and the rest get nothing, so only the chore shows.
213 */
214function hintOf(tool: string, e: Record<string, unknown>): string {
215  switch (tool) {
216    case 'Read':
217    case 'Edit':
218    case 'Write':
219    case 'NotebookEdit':
220      return baseOf(fileOf(tool, e) ?? '')
221    case 'Grep':
222      return `/${str(e, 'pattern')}/`
223    case 'Glob':
224      return str(e, 'pattern')
225    case 'Bash':
226    case 'PowerShell': {
227      const [first] = pathsInCommand(str(e, 'command'))
228      return first === undefined ? '' : baseOf(first)
229    }
230    default:
231      return ''
232  }
233}
234
235type Line = { mark: string; color: string; text: string; hint: string }
236
237/**
238 * The line under the phase: disguised, a chore and a hint ("✓ нарізаю сир  login.ts");
239 * honest, the tool and its gist ("✓ Edit src/auth/login.ts"). After the answer, the
240 * disguise tidies up. `cells` is the width the whole line may take.
241 */
242function lineOf(cells: number): Line | null {
243  // The chore is cut to the cells past the mark when the column is too narrow for it whole
244  if (disguise && phase === 'done') {
245    return { mark: '&#10003;', color: PHASE_HEX.done, text: head(phraseOf('done', calls), cells - 2), hint: '' }
246  }
247  if (lastTool === null) return null
248  const mark = lastTool.isDone ? (lastTool.isError ? '&#10007;' : '&#10003;') : '&#8230;'
249  if (disguise) {
250    // The chore and the hint share the line. Too long together, the chore drops
251    // its aside (what follows the comma or colon); still too long, the hint goes.
252    const full = phraseOf(lastTool.kind, lastTool.callNo)
253    const bare = full.split(/[,:] /)[0] ?? full
254    const hint = lastTool.hint
255    const fits = (text: string) => text.length + 2 + (hint ? hint.length + 2 : 0) <= cells
256    const text = fits(full) ? full : fits(bare) ? bare : head(full, cells - 2)
257    return { mark, color: hexOf(lastTool.tool), text, hint: fits(text) ? hint : '' }
258  }
259  const room = cells - lastTool.tool.length - 4
260  const what = FILE_TOOLS.has(lastTool.tool) ? tail(relative(lastTool.what), room) : head(lastTool.what, room)
261  return { mark, color: hexOf(lastTool.tool), text: lastTool.tool, hint: what }
262}
263
264/** The terminal's glyph for a numeric entity of `lineOf`. */
265function glyph(mark: string): string {
266  return mark === '&#10003;' ? '✓' : mark === '&#10007;' ? '✗' : '…'
267}
268
269function slashes(path: string): string {
270  return path.split(String.fromCharCode(92)).join('/')
271}
272
273/** The path relative to the session's directory when it lies inside it. */
274function relative(path: string): string {
275  const p = slashes(path)
276  const root = slashes(cwd).replace(/\/+$/, '')
277  if (root && (p === root || p.startsWith(root + '/'))) return p.slice(root.length + 1) || '.'
278  return p
279}
280
281function dirOf(rel: string): string {
282  const i = rel.lastIndexOf('/')
283  return i < 0 ? '.' : rel.slice(0, i)
284}
285
286function baseOf(path: string): string {
287  const p = slashes(path).replace(/\/+$/, '')
288  const i = p.lastIndexOf('/')
289  return i < 0 ? p : p.slice(i + 1)
290}
291
292/** The path cut to its tail so it fits `width` cells. */
293function tail(path: string, width: number): string {
294  const normal = slashes(path)
295  if (normal.length <= width) return normal
296  return '…' + normal.slice(normal.length - width + 1)
297}
298
299/** The text cut to its head so it fits `width` cells: for a command or a pattern, whose start matters. */
300function head(text: string, width: number): string {
301  if (text.length <= width) return text
302  return text.slice(0, Math.max(0, width - 1)) + '…'
303}
304
305/** Records one touch of `file` by `tool`: the file moves to the top with its count raised. */
306function touch(file: string, tool: string): void {
307  const found = touches.find(one => one.path === file)
308  const rest = touches.filter(one => one.path !== file)
309  const tools = [...(found?.tools ?? []).filter(t => t !== tool), tool]
310  touches = [{ path: file, tools, count: (found?.count ?? 0) + 1, lastAt: Date.now() }, ...rest].slice(0, MAX_TOUCHES)
311  if (trail[trail.length - 1] !== file) trail = [...trail, file].slice(-TRAIL_LENGTH)
312}
313
314/** Records a search over `dir`: a dashed node on the map, its label the pattern. */
315function scan(dir: string, pattern: string): void {
316  const rel = relative(dir)
317  const found = scans.find(one => one.dir === rel)
318  scans = [{ dir: rel, pattern, count: (found?.count ?? 0) + 1 }, ...scans.filter(one => one.dir !== rel)].slice(0, 40)
319}
320
321const PHASE_LABEL: Record<Phase, string> = {
322  idle: 'idle',
323  thinking: 'thinking',
324  'tool-use': 'using a tool',
325  done: 'answered',
326}
327
328const PHASE_COLOR: Record<Phase, string | undefined> = {
329  idle: undefined,
330  thinking: 'yellow',
331  'tool-use': 'cyan',
332  done: 'green',
333}
334
335// Colors of the map: one per kind of tool, the same on a light and a dark page
336const PHASE_HEX: Record<Phase, string> = { idle: '#8b919c', thinking: '#e2b53a', 'tool-use': '#38bdf8', done: '#34c27a' }
337const TOOL_HEX: Record<string, string> = {
338  Read: '#4f8cff',
339  Grep: '#e2a52b',
340  Glob: '#e2a52b',
341  Edit: '#34c27a',
342  Write: '#34c27a',
343  NotebookEdit: '#34c27a',
344  Bash: '#b67bf0',
345  PowerShell: '#b67bf0',
346  Agent: '#f0718f',
347}
348
349function hexOf(tool: string): string {
350  return TOOL_HEX[tool] ?? '#8b919c'
351}
352
353function esc(text: string): string {
354  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
355}
356
357/** Breaks text into at most `lines` lines of about `width` characters, keeping the end. */
358function wrap(text: string, width: number, lines: number): string[] {
359  const words = text.replace(/\s+/g, ' ').trim().split(' ')
360  const out: string[] = []
361  let line = ''
362  for (const word of words) {
363    if ((line + ' ' + word).trim().length > width && line) {
364      out.push(line)
365      line = word
366    } else line = (line + ' ' + word).trim()
367  }
368  if (line) out.push(line)
369  return out.length > lines ? ['…' + (out[out.length - lines] ?? '').slice(1), ...out.slice(-lines + 1)] : out
370}
371
372type Point = { x: number; y: number }
373
374/**
375 * The map as one SVG document, `W` by `H` pixels: a narrow pane stacks the
376 * header, the thought, the map and the activity; a wide one puts the map on
377 * the left and the rest in a column on the right.
378 */
379function svg(W: number, H: number): string {
380  const isWide = W >= LANDSCAPE_FROM
381  const bg = isDark ? '#1b1b1f' : '#ffffff'
382  const INK = isDark ? '#9aa0a8' : '#5b6069'
383  const INK_DIM = isDark ? '#9aa0a880' : '#5b606980'
384  const parts: string[] = []
385  // Square corners: a rounded rect would let the image slot's own background show through
386  parts.push(`<rect x="0" y="0" width="${W}" height="${H}" fill="${bg}"/>`)
387
388  // Where the pieces go: wide, the map takes the left and a column of at most
389  // 420px the right; narrow, everything stacks. `k` scales the marks with the map.
390  const colW = isWide ? Math.min(420, Math.round(W * 0.35)) : W - 24
391  const mapW = isWide ? W - colW - 24 : W
392  // Type: the column's sizes, and the map's, which also grow with the map
393  const T = { phase: 28, body: 20, thought: 19, line: 27, small: 16 }
394  const headerH = 120
395  // The ring leaves room past it for the outer lane and the legend (92px wide),
396  // and narrow, 96px on each side for the labels
397  const R_FILE = Math.max(60, isWide ? Math.min(mapW, H) / 2 - 92 : Math.min(W / 2 - 96, (H - headerH - 3 * T.line - 170) / 2))
398  // Narrow and tall, the thought takes the room the map leaves above it
399  const narrowLines = Math.max(3, Math.min(12, Math.floor((H - headerH - 2 * R_FILE - 240) / T.line)))
400  const C: Point = isWide ? { x: mapW / 2, y: H / 2 + 6 } : { x: W / 2, y: headerH + narrowLines * T.line + R_FILE + 40 }
401  const R_DIR = R_FILE * 0.56
402  const k = Math.max(1.4, Math.min(2.6, R_FILE / 110))
403  const labelChars = isWide ? 16 : 11
404  // Labels stay compact however large the marks grow
405  const labelFont = Math.min(9 * k, 12).toFixed(1)
406  const col = isWide ? { x: mapW + 12, w: colW } : { x: 12, w: W - 24 }
407  const charW = T.body * 0.54
408
409  // Header: the phase ring and the counts
410  const ring = PHASE_HEX[phase]
411  const spinning = phase === 'thinking' || phase === 'tool-use'
412  const hx = col.x + 16
413  const hy = 36
414  // The drawing is an image, redrawn on each change, so the ring turns a step per redraw instead of animating
415  parts.push(`<circle cx="${hx}" cy="${hy}" r="15" fill="none" stroke="${ring}" stroke-width="3" opacity="0.35"/>`)
416  if (spinning) {
417    const turn = (calls * 47) % 360
418    parts.push(
419      `<circle cx="${hx}" cy="${hy}" r="15" fill="none" stroke="${ring}" stroke-width="4" stroke-dasharray="26 68" stroke-linecap="round" transform="rotate(${turn} ${hx} ${hy})"/>`,
420    )
421  } else parts.push(`<circle cx="${hx}" cy="${hy}" r="7" fill="${ring}"/>`)
422  parts.push(`<text x="${hx + 28}" y="${hy + 10}" font-size="${T.phase}" font-weight="600" fill="${ring}">${esc(PHASE_LABEL[phase])}</text>`)
423  // The count sits by the activity strip, one tick per call, so the header row
424  // holds only the ring and the phase and nothing collides when the column is narrow
425  const line = lineOf(Math.floor(col.w / charW))
426  if (line !== null) {
427    // The mark is a numeric entity, so the glyph survives whatever encoding the surface gives the markup.
428    // Disguised, the hint is small and dim; honest, it is the gist in the body size.
429    const hintSize = disguise ? T.small : T.body
430    const hintInk = disguise ? INK_DIM : INK
431    // The hint takes what the chore leaves; under eight characters it is left out rather than mangled
432    const hintRoom = Math.floor((col.w - charW * (line.text.length + 2) - 12) / (hintSize * 0.54))
433    parts.push(`<text x="${col.x}" y="${hy + 44}" font-size="${T.body}" fill="${line.color}">${line.mark} ${esc(line.text)}</text>`)
434    if (line.hint !== '' && hintRoom >= 8) {
435      parts.push(
436        `<text x="${(col.x + charW * (line.text.length + 2) + 12).toFixed(1)}" y="${hy + 44}" font-size="${hintSize}" fill="${hintInk}">${esc(head(line.hint, hintRoom))}</text>`,
437      )
438    }
439  }
440
441  // The thought: the last of what the model is thinking, while it thinks
442  const thoughtLines = isWide ? Math.max(3, Math.floor((H - headerH - 130) / T.line)) : narrowLines
443  const lines = thought ? wrap(thought, Math.floor(col.w / (T.thought * 0.52)), thoughtLines) : []
444  lines.forEach((line, i) => {
445    parts.push(`<text x="${col.x}" y="${headerH + i * T.line}" font-size="${T.thought}" font-style="italic" fill="${INK_DIM}">${esc(line)}</text>`)
446  })
447
448  // The map: root at the center, directories on a ring, files on a ring outside
449  const files = touches.slice(0, DRAWN_FILES)
450  const byDir = new Map<string, Touch[]>()
451  for (const one of files) {
452    const dir = dirOf(relative(one.path))
453    byDir.set(dir, [...(byDir.get(dir) ?? []), one])
454  }
455  for (const one of scans) if (!byDir.has(one.dir)) byDir.set(one.dir, [])
456  const dirs = [...byDir.keys()]
457  const weightOf = (dir: string) => (byDir.get(dir)?.length ?? 0) + 1
458  const total = dirs.reduce((sum, dir) => sum + weightOf(dir), 0)
459  const at = (angle: number, radius: number): Point => ({ x: C.x + Math.cos(angle) * radius, y: C.y + Math.sin(angle) * radius })
460  const place = new Map<string, Point>()
461  const dirAt = new Map<string, Point>()
462  const dirSpan = new Map<string, number>()
463  // A crowded ring: marks shrink, and a big directory's files take three lanes
464  const crowd = (2 * Math.PI * R_FILE) / Math.max(1, files.length)
465  const kn = k * Math.max(0.5, Math.min(1, crowd / 22))
466  let start = -Math.PI / 2
467  for (const dir of dirs) {
468    const span = (2 * Math.PI * weightOf(dir)) / total
469    const list = byDir.get(dir) ?? []
470    dirAt.set(dir, at(start + span / 2, R_DIR))
471    dirSpan.set(dir, span)
472    list.forEach((one, j) => {
473      const angle = start + (span * (j + 0.5)) / list.length
474      const lane = list.length > 6 ? (j % 3) - 1 : 0
475      place.set(one.path, at(angle, R_FILE + lane * 13 * k))
476    })
477    start += span
478  }
479  const f1 = (n: number) => n.toFixed(1)
480
481  // Labels keep clear of each other: a label whose box meets a placed one is left out
482  // The JSX factory's one-letter name is reserved in a .tsx file, so the boxes spell out width and height
483  const boxes: { x: number; y: number; width: number; height: number }[] = []
484  const labelPx = Number(labelFont)
485  const fits = (x: number, y: number, chars: number, anchor: 'start' | 'end' | 'middle'): boolean => {
486    const width = chars * labelPx * 0.58
487    const height = labelPx * 1.2
488    const left = anchor === 'start' ? x : anchor === 'end' ? x - width : x - width / 2
489    const box = { x: left, y: y - height, width, height }
490    const clear = boxes.every(
491      b => box.x + box.width < b.x || b.x + b.width < box.x || box.y + box.height < b.y || b.y + b.height < box.y,
492    )
493    if (clear) boxes.push(box)
494    return clear
495  }
496
497  // Spokes: root to directory, directory to file
498  for (const dir of dirs) {
499    const d = dirAt.get(dir)!
500    parts.push(`<line x1="${f1(C.x)}" y1="${f1(C.y)}" x2="${f1(d.x)}" y2="${f1(d.y)}" stroke="${INK}" stroke-opacity="0.18"/>`)
501    for (const one of byDir.get(dir) ?? []) {
502      const p = place.get(one.path)!
503      parts.push(`<line x1="${f1(d.x)}" y1="${f1(d.y)}" x2="${f1(p.x)}" y2="${f1(p.y)}" stroke="${INK}" stroke-opacity="0.14"/>`)
504    }
505  }
506
507  // The trail: arcs between files in the order they were touched, the latest strongest
508  const hops = trail.filter(one => place.has(one)).slice(-DRAWN_HOPS - 1)
509  for (let i = 1; i < hops.length; i += 1) {
510    const a = place.get(hops[i - 1] ?? '')!
511    const b = place.get(hops[i] ?? '')!
512    // A gentle bow toward the center, so hops across the map clear the nodes between
513    const cx = ((a.x + b.x) / 2) * 0.8 + C.x * 0.2
514    const cy = ((a.y + b.y) / 2) * 0.8 + C.y * 0.2
515    const strength = (i / (hops.length - 1)) * 0.5 + 0.1
516    parts.push(
517      `<path d="M${f1(a.x)},${f1(a.y)} Q${f1(cx)},${f1(cy)} ${f1(b.x)},${f1(b.y)}" fill="none" stroke="#4f8cff" stroke-width="${f1((0.8 + strength) * k)}" stroke-opacity="${strength.toFixed(2)}" stroke-linecap="round"/>`,
518    )
519  }
520
521  // Root
522  const fs = (base: number) => (base * k).toFixed(1)
523  parts.push(`<circle cx="${f1(C.x)}" cy="${f1(C.y)}" r="${f1(5 * k)}" fill="${INK}"/>`)
524  parts.push(`<text x="${f1(C.x)}" y="${f1(C.y + 12 * k)}" font-size="14" text-anchor="middle" fill="${INK}">${esc(head(baseOf(cwd) || 'project', 18))}</text>`)
525
526  // Directories: a dashed ring where a search ran, a dot otherwise
527  for (const dir of dirs) {
528    const d = dirAt.get(dir)!
529    const scanned = scans.find(one => one.dir === dir)
530    if (scanned) {
531      parts.push(
532        `<circle cx="${f1(d.x)}" cy="${f1(d.y)}" r="${f1((6 + Math.min(scanned.count, 5)) * k)}" fill="#e2a52b" fill-opacity="0.12" stroke="#e2a52b" stroke-dasharray="3 2" stroke-width="${f1(1.2 * k)}"/>`,
533      )
534    } else parts.push(`<circle cx="${f1(d.x)}" cy="${f1(d.y)}" r="${f1(3 * k)}" fill="${INK}" fill-opacity="0.7"/>`)
535    const label = dir === '.' || dir === '' ? '/' : head(baseOf(dir), 14)
536    const above = d.y < C.y
537    const ly = above ? d.y - 8 * k : d.y + 11 * k
538    // A directory with a sliver of the ring, and nothing searched in it, goes unlabelled;
539    // a crowded narrow map labels only the searched ones and those with four files or more
540    const few = (byDir.get(dir)?.length ?? 0) < 4
541    const slim = !scanned && ((dirSpan.get(dir) ?? 0) * R_DIR < 26 || (files.length > 30 && !isWide && few))
542    if (!slim && fits(d.x, ly, label.length, 'middle')) {
543      parts.push(`<text x="${f1(d.x)}" y="${f1(ly)}" font-size="${labelFont}" text-anchor="middle" fill="${INK}" fill-opacity="0.8">${esc(label)}</text>`)
544    }
545  }
546
547  // Files: size by how often, color by the last tool, the latest one pulsing
548  const current = lastTool !== null && !lastTool.isDone && FILE_TOOLS.has(lastTool.tool) ? lastTool.what : null
549  const latest = trail[trail.length - 1]
550  // Label the files returned to first, then the most recent, and not too many.
551  // A crowded narrow map labels only the files returned to three times or more.
552  const crowded = files.length > (isWide ? 60 : 30)
553  const returned = touches.filter(one => one.count > 1)
554  const once = touches.filter(one => one.count === 1)
555  const picked = crowded && !isWide ? returned.filter(one => one.count >= 3) : [...returned, ...once]
556  const labelled = new Set(picked.slice(0, isWide ? 12 : 7).map(one => one.path))
557  if (latest !== undefined) labelled.add(latest)
558  for (const one of files) {
559    const p = place.get(one.path)!
560    const color = hexOf(one.tools[one.tools.length - 1] ?? 'Read')
561    const r = (3 + Math.min(one.count, 6) * 1.1) * kn
562    // The latest file wears two halos, a still stand-in for a pulse
563    if (one.path === current || one.path === latest) {
564      parts.push(
565        `<circle cx="${f1(p.x)}" cy="${f1(p.y)}" r="${f1(r + 4 * k)}" fill="none" stroke="${color}" stroke-width="${f1(1.5 * k)}" opacity="0.55"/>` +
566          `<circle cx="${f1(p.x)}" cy="${f1(p.y)}" r="${f1(r + 8 * k)}" fill="none" stroke="${color}" stroke-width="${f1(k)}" opacity="0.22"/>`,
567      )
568    }
569    parts.push(`<circle cx="${f1(p.x)}" cy="${f1(p.y)}" r="${f1(r)}" fill="${color}" fill-opacity="0.9"/>`)
570    if (labelled.has(one.path)) {
571      const left = p.x < C.x
572      const label = head(baseOf(one.path), labelChars)
573      const lx = left ? p.x - r - 4 : p.x + r + 4
574      if (fits(lx, p.y + 4, label.length, left ? 'end' : 'start')) {
575        parts.push(`<text x="${f1(lx)}" y="${f1(p.y + 4)}" font-size="${labelFont}" text-anchor="${left ? 'end' : 'start'}" fill="${INK}">${esc(label)}</text>`)
576      }
577    }
578  }
579  if (files.length === 0 && scans.length === 0) {
580    parts.push(`<text x="${f1(C.x)}" y="${f1(C.y - R_DIR)}" font-size="${T.small}" text-anchor="middle" fill="${INK_DIM}">nothing touched yet</text>`)
581  }
582
583  // The activity strip: one tick per tool call, the latest at the right
584  const y0 = H - 96
585  parts.push(`<text x="${col.x}" y="${y0 - 10}" font-size="${T.small}" fill="${INK_DIM}">activity</text>`)
586  parts.push(`<text x="${col.x + col.w}" y="${y0 - 10}" font-size="${T.small}" text-anchor="end" fill="${INK}">${calls} tool calls</text>`)
587  const step = 12
588  const fit = Math.min(ACTIVITY_LENGTH, Math.floor(col.w / step))
589  const recent = activity.slice(-fit)
590  recent.forEach((one, i) => {
591    const x = col.x + col.w - (recent.length - 1 - i) * step
592    parts.push(
593      `<rect x="${x - 9}" y="${y0}" width="9" height="${one.isError ? 10 : 22}" rx="2" fill="${one.isError ? '#e05a5a' : hexOf(one.tool)}" opacity="${(0.35 + (0.65 * (i + 1)) / recent.length).toFixed(2)}"/>`,
594    )
595  })
596
597  // Legend, along the bottom from the left
598  const legend: [string, string][] = [['read', '#4f8cff'], ['search', '#e2a52b'], ['edit', '#34c27a'], ['shell', '#b67bf0'], ['agent', '#f0718f']]
599  const legendStep = Math.min(110, (W - 24) / legend.length)
600  const legendFont = legendStep < 100 ? 12 : T.small
601  legend.forEach(([name, color], i) => {
602    const x = 12 + i * legendStep
603    parts.push(`<circle cx="${x + 6}" cy="${H - 26}" r="${legendStep < 100 ? 4.5 : 6}" fill="${color}"/><text x="${x + 16}" y="${H - 21}" font-size="${legendFont}" fill="${INK}">${name}</text>`)
604  })
605
606  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" font-family="ui-sans-serif, system-ui, sans-serif">${parts.join('')}</svg>`
607}
608
609export const register: Register = (on, options) => {
610  configured = options.disguise !== false
611  disguise = configured
612
613  on('session.start', async ($, e, next) => {
614    await $.command.register({
615      name: 'trace-map',
616      description: 'Open the trace map: what Claude is doing and which files it touched',
617    })
618    cwd = e.cwd
619    await readTheme($)
620    storeKey = `trace:${await $.session.id()}`
621    const saved = await $.store.get(storeKey)
622    if (isSaved(saved)) {
623      touches = saved.touches
624      trail = saved.trail
625      calls = saved.calls
626      activity = saved.activity
627      scans = saved.scans
628      lastTool = saved.lastTool ?? null
629      phase = saved.phase ?? 'idle'
630    }
631    await loadDisguise($)
632    void $.ui.open({ id: PANE, title: 'Trace map' })
633    return next(e)
634  })
635
636  on('session.end', async ($, e, next) => {
637    await $.store.delete(storeKey)
638    return next(e)
639  })
640
641  on('config.set', { key: 'theme' }, async ($, e, next) => {
642    const done = await next(e)
643    await readTheme($)
644    $.ui.invalidate('ui.render')
645    return done
646  })
647
648  // `/trace-map` opens the pane; `/trace-map honest` and `/trace-map disguise`
649  // flip the line
650  on('command.run', { command: 'trace-map' }, async ($, e) => {
651    const arg = e.args.trim().toLowerCase()
652    if (arg === 'honest' || arg === 'disguise') {
653      await setDisguise($, arg === 'disguise')
654      return { text: arg === 'disguise' ? 'Trace map: the chores are back on.' : 'Trace map: showing the honest tool line.' }
655    }
656    await $.ui.open({ id: PANE, title: 'Trace map' })
657    return { text: 'Trace map opened.' }
658  })
659
660  on('turn.start', async ($, e, next) => {
661    phase = 'thinking'
662    thought = ''
663    await changed($)
664    return next(e)
665  })
666
667  // The main loop's turn only: a subagent's turn.complete would end the phase early
668  on('turn.complete', async ($, e, next) => {
669    if (e.agentId === undefined) {
670      phase = 'done'
671      await changed($)
672    }
673    return next(e)
674  })
675
676  // The model's thinking as it streams: the pane shows its last lines
677  on('turn.step', async function* ($, e, next) {
678    const stream = next(e)
679    let block = -1
680    for await (const chunk of stream) {
681      if (chunk.kind === 'thinking' && e.agentId === undefined) {
682        if (chunk.index !== block) {
683          block = chunk.index
684          thought = ''
685        }
686        thought = (thought + chunk.text).slice(-THOUGHT_MAX)
687        if (Date.now() - lastRedrawAt > REDRAW_EVERY_MS) {
688          lastRedrawAt = Date.now()
689          $.ui.invalidate('ui.render')
690        }
691      }
692      yield chunk
693    }
694    if (block >= 0) $.ui.invalidate('ui.render')
695    return await stream.result
696  })
697
698  on('tool.call', async ($, e, next) => {
699    const input = e as unknown as Record<string, unknown>
700    const file = fileOf(e.tool, input)
701    calls += 1
702    const started: LastTool = {
703      tool: e.tool,
704      what: gistOf(e.tool, input),
705      kind: kindOf(e.tool, str(input, 'command')),
706      hint: hintOf(e.tool, input),
707      callNo: calls,
708      isDone: false,
709      isError: false,
710    }
711
712    lastTool = started
713    if (e.agentId === undefined) phase = 'tool-use'
714    if (file !== undefined) touch(file, e.tool)
715    if (SEARCH_TOOLS.has(e.tool)) {
716      // A search over one file is a touch of that file; over a directory, a scan of it
717      const where = str(input, 'path') || cwd || '.'
718      if (looksLikeFile(where)) touch(where, e.tool)
719      else scan(where, str(input, 'pattern'))
720    }
721    // A shell command names the files it reads or writes in its text
722    if (SHELL_TOOLS.has(e.tool)) for (const path of pathsInCommand(str(input, 'command'))) touch(path, e.tool)
723    await changed($)
724
725    const ran = await next(e)
726
727    const isError = ran.deny !== undefined || ran.isError === true
728    if (lastTool === started) lastTool = { ...started, isDone: true, isError }
729    activity = [...activity, { tool: e.tool, isError }].slice(-ACTIVITY_LENGTH)
730    if (e.agentId === undefined) phase = 'thinking'
731    await changed($)
732    return ran
733  })
734
735  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
736    const { Box, Text, Button } = $.ui.resolve(e)
737
738    const clear = async () => {
739      touches = []
740      trail = []
741      lastTool = null
742      calls = 0
743      activity = []
744      scans = []
745      thought = ''
746      await changed($)
747    }
748    // The pane's own switch: ✕ turns the chores off, ✓ turns them back on
749    const toggle = () => setDisguise($, !disguise)
750    const toggleLabel = disguise ? '✕ фрази' : '✓ фрази'
751
752    if (e.surface === 'desktop') {
753      const { Svg } = $.ui.resolve(e)
754      // Size the drawing to the pane. The surface scales the image to the pane's
755      // width, so what matters is the aspect: the pane's own, from its cells,
756      // less a little for the row under the drawing.
757      const cols = e.props.bodyColumns
758      const rows = e.viewport?.rows
759      const W = Math.max(340, Math.min(1600, cols * PX_PER_COLUMN - 16))
760      // 72px off the height: the row under the drawing and the pane's padding, so nothing scrolls
761      const paneAspect = rows !== undefined && rows > 0 ? (rows * PX_PER_ROW - 72) / (cols * PX_PER_COLUMN) : undefined
762      const H = Math.round(Math.max(360, Math.min(1200, paneAspect !== undefined ? W * paneAspect : W >= LANDSCAPE_FROM ? W * 0.5 : 560)))
763      return (
764        <Box flexDirection="column">
765          <Svg
766            source={svg(W, H)}
767            width={W}
768            height={H}
769            alt={`Claude is ${PHASE_LABEL[phase]}, ${calls} tool calls, ${touches.length} files touched`}
770          />
771          <Box flexDirection="row" columnGap={1}>
772            <Text dimColor>{`${touches.length} files`}</Text>
773            <Button key="clear" label="clear" hotkey="c" plain onPress={clear} />
774            <Button key="disguise" label={toggleLabel} hotkey="d" plain onPress={toggle} />
775          </Box>
776        </Box>
777      )
778    }
779
780    // The terminal: a text list
781    const width = Math.max(20, e.props.bodyColumns - 1)
782    const rows = e.viewport?.rows ?? 24
783    const room = Math.max(1, rows - 9)
784    const shown = touches.slice(0, room)
785    const widest = Math.max(...shown.map(one => String(one.count).length), 1)
786
787    const line = lineOf(width + 1)
788    const trailLine = trail.length === 0 ? 'nothing touched yet' : trail.map(one => tail(one, 24)).join(' → ')
789
790    return (
791      <Box flexDirection="column">
792        <Box flexDirection="row" columnGap={1}>
793          <Text bold>Claude is</Text>
794          <Text color={PHASE_COLOR[phase]} bold>
795            {PHASE_LABEL[phase]}
796          </Text>
797          <Text dimColor>{`· ${calls} tool calls`}</Text>
798        </Box>
799        {line === null ? (
800          <Text dimColor>no tool yet</Text>
801        ) : (
802          <Box flexDirection="row" columnGap={1}>
803            <Text dimColor={!disguise} wrap="truncate-end">{`${glyph(line.mark)} ${line.text}`}</Text>
804            {line.hint !== '' && (
805              <Text dimColor wrap="truncate-end">
806                {line.hint}
807              </Text>
808            )}
809          </Box>
810        )}
811        {thought !== '' && <Text dimColor italic wrap="truncate-end">{`“${thought.slice(-width + 2)}”`}</Text>}
812        <Text> </Text>
813        <Text bold>Trail</Text>
814        <Text wrap="truncate-start" dimColor={trail.length === 0}>
815          {trailLine}
816        </Text>
817        <Text> </Text>
818        <Box flexDirection="row" columnGap={1}>
819          <Text bold>Files</Text>
820          <Text dimColor>{`(${touches.length})`}</Text>
821          <Button key="clear" label="clear" hotkey="c" plain onPress={clear} />
822          <Button key="disguise" label={toggleLabel} hotkey="d" plain onPress={toggle} />
823        </Box>
824        {shown.map(one => (
825          <Box flexDirection="row" columnGap={1}>
826            <Text color={one.count > 1 ? 'cyan' : undefined}>{`${String(one.count).padStart(widest)}×`}</Text>
827            <Text wrap="truncate-start" dimColor={one.count === 1}>
828              {tail(one.path, width - widest - 3)}
829            </Text>
830          </Box>
831        ))}
832        {touches.length > shown.length && <Text dimColor>{`… and ${touches.length - shown.length} more`}</Text>}
833      </Box>
834    )
835  })
836}
837
hooks/disguise.ts 185 lines
1// The disguise: a homely Ukrainian phrase in place of the raw tool line, in the
2// spirit of the spinner's words, tied to what the call really does.
3
4import type { Kind } from '../types'
5
6/**
7 * Three to five phrases per kind; the call's number picks one, so a redraw
8 * keeps it. One household: the kitchen, the wardrobe, the mail, the neighbours,
9 * told with a mild, self-mocking shrug. "гортаю" is always reading,
10 * "перебираю" always searching, "латаю" always mending. Kept under
11 * 36 characters, so a narrow pane shows the whole line.
12 */
13export const PHRASES: Record<Kind, readonly string[]> = {
14  search: [
15    'шукаю ключі, що були в руці',
16    'розгрібаю безлад, що сам і зробив',
17    'перебираю шухляду, там «потрібне»',
18    'зазираю під диван, бо де ж іще',
19    'перетрушую кладовку, ніби там нове',
20  ],
21  read: [
22    'гортаю книжку, щоб потім перечитати',
23    'читаю етикетку, ніби щось зрозумію',
24    'вивчаю інструкцію, вперше за роки',
25    'розбираю почерк, вочевидь свій',
26    'переглядаю рецепт, який і так знаю',
27  ],
28  // Edit mends what is there; Write makes something new
29  edit: [
30    'латаю шкарпетку, замість купити нову',
31    'пришиваю ґудзик, відірваний втретє',
32    'підклеюю обкладинку, бо то не нова',
33    'підтягую ніжку стільця, знову',
34    'штопаю рукав, бо так, кажуть, модно',
35  ],
36  write: [
37    'ліплю вареники, бо хтось же має',
38    'нарізаю сир рівно, як ніколи',
39    'пишу листівку, яку ніхто не прочитає',
40    'заварюю чай, бо інакше не працюється',
41    'застеляю ліжко, щоб було куди впасти',
42  ],
43  // A shell: deletes take out the trash, builds and installs wait for the kettle, the rest are odd chores
44  remove: [
45    'виношу сміття, цього разу насправді',
46    'вигрібаю попіл, сам не вигребеться',
47    'витрушую килим, поки ніхто не бачить',
48    'зливаю воду з ванни, хоч і шкода',
49  ],
50  build: [
51    'нагріваю воду для кави, це надовго',
52    'вмикаю чайник і стежу, щоб не втік',
53    'збираю шафу за інструкцією, майже',
54    'замішую тісто, бо раптом вийде',
55    'чекаю, поки закипить, фахово',
56  ],
57  shell: [
58    'поливаю квіти, які ще живі',
59    'підкручую гайки, бо щось же треба',
60    'ставлю чайник, вдруге за годину',
61    'розвішую білизну за кольором, звісно',
62    'заводжу годинник, що й так спішить',
63  ],
64  // Git: looking back, fixing a moment, sending it off, bringing it in
65  git: [
66    'переглядаю старі фото: хто це накоїв',
67    'гортаю альбом, самі дивні рішення',
68    'перебираю листи, самі рахунки',
69    'звіряю, що змінилось у шафі, і чому',
70  ],
71  commit: [
72    'вклеюю фото в альбом, хай буде доказ',
73    'підписую дату на звороті: хто винен',
74    'ставлю печатку в щоденнику, офіційно',
75    'перев’язую пачку листів, нарешті',
76  ],
77  send: [
78    'несу листа на пошту, не повернеш',
79    'кидаю листівку в скриньку, і все',
80    'віддаю пакунок кур’єру, хай сам',
81    'виставляю молоко за двері, заберуть',
82  ],
83  fetch: [
84    'забираю пошту, хоч би не рахунки',
85    'приймаю посилку, яку сам і замовив',
86    'зустрічаю гостей з валізами, надовго',
87    'заношу газету з ґанку, вчорашню',
88  ],
89  test: [
90    'пробую суп на сіль, уже втретє',
91    'нюхаю молоко, дата на пачці бреше',
92    'стукаю по кавуну, ніби щось чую',
93    'міряю воду ліктем, як бабуся вчила',
94  ],
95  note: [
96    'записую в блокнот, який загублю',
97    'клею стікер на холодильник, до решти',
98    'дописую в список те, що не куплю',
99    'роблю нотатку на полях, нерозбірливо',
100  ],
101  agent: [
102    'кличу сусіда, бо самому ліньки',
103    'шлю брата за хлібом, верне з чіпсами',
104    'гукаю когось із кухні, там нікого',
105    'дзвоню майстру, який прийде «завтра»',
106  ],
107  web: [
108    'розпитую сусідів, вони все знають',
109    'дивлюсь у вікно, ніби щось зміниться',
110    'гортаю газету, вчорашню, звісно',
111    'слухаю радіо, бо інтернету замало',
112  ],
113  other: [
114    'порядкую на столі: пересуваю купки',
115    'розкладаю речі по місцях, нових',
116    'протираю окуляри, ніби стане ясніше',
117    'переставляю горщики, туди й назад',
118  ],
119  done: [
120    'прибираюсь після роботи, ненадовго',
121    'витираю стіл, доки ніхто не бачить',
122    'мию посуд, ніби це востаннє',
123    'вимикаю світло на кухні, нарешті',
124  ],
125}
126
127// A command's words: `git`, `npm`, `rm`... at its start or after `;`, `|`, `&&`, `(`
128const W = '(^|[\\s;|&(])'
129
130/** Appends to a file: `>>`, a heredoc, `tee -a`, PowerShell's `Add-Content` or `Out-File -Append`. */
131const APPENDS = new RegExp(W + `(>>|<<-?\\s*['"]?\\w|tee\\s+-a\\b|add-content\\b|out-file\\b.*-append)`, 'i')
132/** Tests and checks: a test runner, a type check, a lint, a plugin's validate. `npx` alone is not one. */
133const TESTS = new RegExp(
134  W +
135    '((npm|pnpm|yarn|bun)\\s+(run\\s+)?(test|lint|typecheck|check)\\b|pytest\\b|vitest\\b|jest\\b|mocha\\b|cargo\\s+(test|check|clippy)\\b|go\\s+(test|vet)\\b|dotnet\\s+test\\b|tsc\\b|eslint\\b|biome\\s+(check|lint)\\b|ruff\\b|mypy\\b|claude\\s+plugin\\s+(test|validate)\\b)',
136  'i',
137)
138/** Builds and installs: what takes a while and makes something. */
139const BUILDS = new RegExp(
140  W +
141    '((npm|pnpm|yarn|bun)\\s+(i|install|ci|add|run\\s+build|build)\\b|pip\\s+install\\b|cargo\\s+(build|install)\\b|go\\s+(build|get|mod)\\b|dotnet\\s+(build|restore)\\b|make\\b|cmake\\b|gradle\\w*\\b|mvn\\b|docker\\s+(build|compose)\\b|vite\\s+build\\b|webpack\\b|esbuild\\b)',
142  'i',
143)
144/** Deletes: `rm`, `rmdir`, `del`, `Remove-Item`, `git clean`. */
145const REMOVES = new RegExp(W + '(rm\\b|rmdir\\b|del\\b|erase\\b|remove-item\\b|ri\\b|git\\s+clean\\b)', 'i')
146/** Git's three moods: a commit fixes a moment, a push sends it, a pull brings mail in. The rest looks back. */
147const GIT = new RegExp(W + 'git\\s')
148const GIT_COMMIT = new RegExp(W + 'git\\s+(commit\\b|tag\\b|stash\\b(?!\\s+(pop|apply|list|show|drop))|add\\b|merge\\b|rebase\\b|cherry-pick\\b)', 'i')
149const GIT_SEND = new RegExp(W + '(git\\s+push\\b|gh\\s+(pr|release)\\s+create\\b|gh\\s+pr\\s+merge\\b)', 'i')
150const GIT_FETCH = new RegExp(W + '(git\\s+(pull|fetch|clone)\\b|gh\\s+(repo\\s+clone|pr\\s+checkout)\\b)', 'i')
151
152/** What kind of chore a tool call is, from its tool and, for a shell, its command. */
153export function kindOf(tool: string, command: string): Kind {
154  if (tool === 'Read') return 'read'
155  if (tool === 'Write') return 'write'
156  if (tool === 'Edit' || tool === 'NotebookEdit') return 'edit'
157  if (tool === 'Grep' || tool === 'Glob') return 'search'
158  if (tool === 'Agent') return 'agent'
159  if (tool === 'WebSearch' || tool === 'WebFetch') return 'web'
160  if (tool === 'Bash' || tool === 'PowerShell') {
161    const text = command.toLowerCase()
162    if (APPENDS.test(text)) return 'note'
163    if (GIT_SEND.test(text)) return 'send'
164    if (GIT_FETCH.test(text)) return 'fetch'
165    if (GIT_COMMIT.test(text)) return 'commit'
166    if (TESTS.test(text)) return 'test'
167    if (BUILDS.test(text)) return 'build'
168    if (REMOVES.test(text)) return 'remove'
169    if (GIT.test(text)) return 'git'
170    return 'shell'
171  }
172  return 'other'
173}
174
175/** The phrase for a kind, the same for the same call number. */
176export function phraseOf(kind: Kind, callNo: number): string {
177  const list = PHRASES[kind]
178  return list[Math.abs(callNo) % list.length] ?? ''
179}
180
181/** A regular expression matching any phrase of the kind: for tests and searches. */
182export function anyPhrase(kind: Kind): RegExp {
183  return new RegExp(PHRASES[kind].join('|'))
184}
185
types/index.d.ts 47 lines
1export type Phase = 'idle' | 'thinking' | 'tool-use' | 'done'
2
3/** What kind of chore a tool call is, for the disguise's phrase */
4export type Kind =
5  | 'search'
6  | 'read'
7  | 'edit'
8  | 'write'
9  | 'shell'
10  | 'remove'
11  | 'build'
12  | 'git'
13  | 'commit'
14  | 'send'
15  | 'fetch'
16  | 'test'
17  | 'note'
18  | 'agent'
19  | 'web'
20  | 'other'
21  | 'done'
22
23export type Touch = {
24  /** The path as the tool got it, shortened for the drawing by the hook */
25  path: string
26  /** Which tools touched it, latest last */
27  tools: string[]
28  /** How many times a tool went to it */
29  count: number
30  /** Milliseconds since the epoch when it was last touched */
31  lastAt: number
32}
33
34export type LastTool = {
35  tool: string
36  /** The one-line gist: a path, a pattern, a command's head */
37  what: string
38  /** The kind of chore, for the disguise */
39  kind: Kind
40  /** One or two words of context next to the disguise: a file's name, a pattern, a command's head */
41  hint: string
42  /** The call's number in the session: picks the disguise's phrase, so a redraw keeps it */
43  callNo: number
44  isDone: boolean
45  isError: boolean
46}
47