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

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 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.
One SVG image, redrawn on every event. From top to bottom:
idle (grey), thinking (yellow, the arc turns), using a tool (blue), answered (green dot).… 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.N tool calls counter.read blue, search amber, edit green, shell purple, agent pink.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.
| 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.
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.
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.
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.
The mod runs inside Claude Code with your permissions, so here is the full list.
What it reads while a session runs:
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;thinking) as it streams, for the main agent only; it keeps the last 600 characters in memory and never stores them;/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:
calls: below touch files or the network;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
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.
/config wins over it. The screenshot moved out of the plugin folder./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./trace-map honest|disguise|log, the thought stream in the pane, state kept across reloads.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.
MIT © 2026 learningtogetherua
hooks/register.tsx 837 lines1import 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: '✓', 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 ? '✗' : '✓') : '…'
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 === '✓' ? '✓' : mark === '✗' ? '✗' : '…'
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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}
837hooks/disguise.ts 185 lines1// 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}
185types/index.d.ts 47 lines1export 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