Claude Work Visualized: a live, animated map of the project showing where Claude reads, edits, creates and deletes files

A Claude Code mod that turns Claude's file operations into a live, animated map of your project. A sidebar pane shows the project tree and animates every file as Claude reads, edits, creates or deletes it, so you can see at a glance where Claude is working right now.
<img src="docs/media/demo.gif" alt="The work map following Claude through a session: reading two files, editing one, creating a component, editing an index, deleting a file, then the completion card" width="446">
▌ marker, a tinted row and a ◂ editing label.| Command | What it does |
|---|---|
/watch | Turns the work map on or off (opens or closes the pane). The choice is remembered across sessions. |
/watch opens it at any width./watch reopens it.± edit src/app.ts.| Glyph | Operation | Colour | While running | After |
|---|---|---|---|---|
◉ | read | blue | a band of light sweeps across the name | ✓, colour fades |
± | edit | orange | a ripple runs along the name, then a blinking cursor ▏ | ✓, colour fades |
+ | create | green | the name types itself in, then glows | settles into the tree |
✗ | delete | red | red pulse | highlight → damped shake → removed letter by letter |
<table> <tr> <td align="center"><img src="docs/media/read.png" alt="Reading: a band of light sweeps across session.ts" width="300"><br><sub><b>Reading</b>: the scan band sweeps the name</sub></td> <td align="center"><img src="docs/media/edit.png" alt="Editing: session.ts pulses orange, with the lit path from src/ down to it" width="300"><br><sub><b>Editing</b>: ripple, cursor and lit path</sub></td> </tr> <tr> <td align="center"><img src="docs/media/create.png" alt="Creating: UserProfile.tsx types itself into the tree" width="300"><br><sub><b>Creating</b>: the name types itself in</sub></td> <td align="center"><img src="docs/media/delete.png" alt="Deleting: legacy.ts flashes red before it leaves the tree" width="300"><br><sub><b>Deleting</b>: red pulse, then shake and dissolve</sub></td> </tr> <tr> <td align="center" colspan="2"><img src="docs/media/complete.png" alt="The completion card: 3 viewed, 2 edited, 1 created, 1 deleted, with bars" width="300"><br><sub><b>Task complete</b>: the trail of the turn and its summary</sub></td> </tr> </table>
<sub>The GIF and screenshots are rendered from the plugin's own drawing code (hooks/model.ts) by scripts/render-media.mjs, so they show exactly what the pane draws, frame by frame.</sub>
See docs/visual-language.md for the full timelines and colours.
/watch.This repository is also a Claude Code plugin marketplace. Inside Claude Code, run:
/plugin marketplace add GrzeskoByte/claude-work-visualized
/plugin install work-visualized@claude-work-visualized
Or from your shell:
claude plugin marketplace add GrzeskoByte/claude-work-visualized
claude plugin install work-visualized@claude-work-visualized
Restart Claude Code (or start a new session) and the work map loads. Run /watch if the pane doesn't open by itself.
To update to the latest version:
claude plugin marketplace update claude-work-visualized
claude plugin update work-visualized@claude-work-visualized
To remove it:
claude plugin uninstall work-visualized@claude-work-visualized
claude plugin marketplace remove claude-work-visualized
Useful for trying it once or for working on the mod itself:
git clone https://github.com/GrzeskoByte/claude-work-visualized.git
claude --plugin-dir ./claude-work-visualized
--plugin-dir loads the plugin for that session only, and reloads it whenever you save a file. To load it in every session without installing it, add the folder's absolute path to CLAUDE_CODE_PLUGIN_DIRS, either in your environment or in the env block of ~/.claude/settings.json:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/home/you/claude-work-visualized"
}
}
/watch. The pane should show ✶ work-map ~/<your project> and the project tree.If nothing appears, start Claude Code with claude --debug. The debug log names the plugin and the reason whenever a hook fails or the module doesn't load.
claude-work-visualized/
├── .claude-plugin/
│ ├── plugin.json plugin manifest
│ └── marketplace.json lets the repo be added as a marketplace
├── hooks/
│ ├── hooks.json names the hooks module
│ ├── register.tsx hooks: tool calls, scanning, /watch, timers, the pane
│ ├── model.ts pure logic: command parsing, scan diffs, the animation engine
│ └── work.test.tsx tests
├── types/index.d.ts types for the data the mod keeps in session state
├── docs/ documentation
│ └── media/ README screenshots and GIF
├── scripts/render-media.mjs renders docs/media from the drawing code
├── LICENSE
└── README.md
rm, mv, cp, touch, mkdir, cat, sed -i, > and similar are recognised by reading the command text. Anything else is caught by the rescan after the command and shown as finished, without a "running" phase.node_modules, .git, dist, build, caches and virtual environments are left out of the tree.MIT © 2026 Grzegorz Sierocki
hooks/register.tsx 393 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Activity, Ghost, Op, TreeEntry, TurnInfo } from '../types'
5import {
6 COLOR, FAINT, ICON, IGNORED, LABEL, MUTED, T,
7 buildRows, classifyBash, counted, diffTrees, emptyTurn, focusOf, headerLine, isAnimating, legendLines, mix,
8 relPath, scrollTo, staleKeys, statusLine, summaryLines, withPath, withoutPath,
9} from './model'
10import type { FolderMemory, Seg, Target } from './model'
11
12const PANE = 'work-map'
13const TITLE = 'Claude Work'
14const MAX_ENTRIES = 2000
15const MAX_DEPTH = 12
16const FRAME_MS = 33 // ~30 fps while anything moves, the terminal's cap for a shown pane
17const POLL_MS = 1500 // how often the project is rescanned for changes made outside tool calls
18
19const tree = atom({ plugin: 'work-visualized', key: 'tree' } as const, [] as TreeEntry[])
20const activity = atom({ plugin: 'work-visualized', key: 'activity' } as const, {} as Record<string, Activity>)
21const ghosts = atom({ plugin: 'work-visualized', key: 'ghosts' } as const, {} as Record<string, Ghost>)
22const turn = atom({ plugin: 'work-visualized', key: 'turn' } as const, emptyTurn())
23const watching = atom({ plugin: 'work-visualized', key: 'watching' } as const, true)
24
25// Drawing memory only: a reload starts these over, harmlessly.
26const folders: FolderMemory = new Map()
27let scroll = 0
28let animateUntil = 0
29let isWatching = true // mirrors the watching atom for synchronous checks
30let scanning: Promise<unknown> = Promise.resolve()
31
32async function scanTree($: EngineInterface): Promise<TreeEntry[]> {
33 const cwd = (await $.session.cwd()).replace(/\/+$/, '')
34 const out: TreeEntry[] = []
35 let level: string[] = ['']
36 for (let depth = 0; depth < MAX_DEPTH && level.length > 0 && out.length < MAX_ENTRIES; depth++) {
37 const next: string[] = []
38 const lists = await Promise.all(level.map(dir => $.fs.list(dir === '' ? cwd : `${cwd}/${dir}`).catch(() => [])))
39 level.forEach((dir, i) => {
40 for (const entry of lists[i] ?? []) {
41 if (IGNORED.has(entry.name) || out.length >= MAX_ENTRIES) continue
42 const p = dir === '' ? entry.name : `${dir}/${entry.name}`
43 const d = entry.kind === 'dir'
44 out.push({ p, d, m: entry.mtimeMs })
45 if (d) next.push(p)
46 }
47 })
48 level = next
49 }
50 return out
51}
52
53/** Rescans the project and animates whatever changed that no tool call named. */
54function rescan($: EngineInterface, claimed: ReadonlySet<string>): Promise<unknown> {
55 scanning = scanning.then(async () => {
56 const fresh = await scanTree($)
57 const before = await read($, tree)
58 const now = await $.clock.now()
59 const diff = diffTrees(before, fresh)
60 // Keep entries a running create put in early, so they don't blink out.
61 const running = await read($, activity)
62 const busy = new Set([...claimed, ...Object.keys(running).filter(p => (running[p]?.running ?? 0) > 0)])
63 let merged = fresh
64 for (const [p, a] of Object.entries(running)) {
65 if (a.running > 0 && a.op === 'create') merged = withPath(merged, p, false)
66 }
67 await update($, tree, () => merged)
68 if (before.length === 0) return // the first map of the project is no activity
69 const born = diff.created.filter(t => !busy.has(t.p))
70 const changed = diff.edited.filter(p => !busy.has(p))
71 const gone = diff.deleted.filter(t => !running[t.p] || running[t.p]?.op !== 'create')
72 if (born.length + changed.length + gone.length === 0) return
73 await update($, activity, map => {
74 const out = { ...map }
75 for (const t of born) out[t.p] = { op: 'create', startedAt: now, endedAt: now, running: 0 }
76 for (const p of changed) out[p] = { op: 'edit', startedAt: now, endedAt: now, running: 0 }
77 for (const t of gone) delete out[t.p]
78 return out
79 })
80 if (gone.length > 0) {
81 await update($, ghosts, map => {
82 const out = { ...map }
83 for (const t of gone.slice(0, 40)) out[t.p] = { d: t.d, at: now }
84 return out
85 })
86 }
87 await update($, turn, info => {
88 let t = info
89 for (const e of born) t = counted(t, 'create', e.p)
90 for (const p of changed) t = counted(t, 'edit', p)
91 for (const e of gone) t = counted(t, 'delete', e.p)
92 return t
93 })
94 animateUntil = now + T.trail + 2000
95 }).catch(() => undefined)
96 return scanning
97}
98
99/** The paths a tool call touches, and how, relative to the session's folder. */
100async function targetsOf($: EngineInterface, tool: string, input: Record<string, unknown>, cwd: string): Promise<Target[]> {
101 const str = (k: string) => (typeof input[k] === 'string' ? (input[k] as string) : undefined)
102 if ((await read($, tree)).length === 0) await scanning
103 const known = new Map((await read($, tree)).map(t => [t.p, t]))
104 const resolve = (raw: string | undefined, op: Op): Target[] => {
105 if (raw === undefined) return []
106 const path = relPath(cwd, raw.startsWith('/') || raw.startsWith('~') ? raw : `${cwd}/${raw}`)
107 if (path === undefined) return []
108 // A shell word that names no known path is most likely not a path.
109 if (tool === 'Bash' && op !== 'create' && !known.has(path)) return []
110 return [{ op, path }]
111 }
112 switch (tool) {
113 case 'Read':
114 return resolve(str('file_path'), 'view')
115 case 'Edit':
116 case 'MultiEdit':
117 return resolve(str('file_path'), 'edit')
118 case 'NotebookEdit':
119 return resolve(str('notebook_path'), 'edit')
120 case 'Write': {
121 const raw = str('file_path')
122 const path = raw === undefined ? undefined : relPath(cwd, raw)
123 return resolve(raw, path !== undefined && known.has(path) ? 'edit' : 'create')
124 }
125 case 'Glob':
126 case 'Grep':
127 case 'LS':
128 return resolve(str('path') ?? cwd + '/.', 'view')
129 case 'Bash': {
130 const command = str('command') ?? ''
131 return classifyBash(command).flatMap(t => {
132 // Writing to a file that isn't there yet is a create.
133 const path = relPath(cwd, t.path.startsWith('/') ? t.path : `${cwd}/${t.path}`)
134 const op: Op = t.op === 'edit' && path !== undefined && !known.has(path) ? 'create' : t.op
135 return resolve(t.path, op)
136 })
137 }
138 default:
139 return []
140 }
141}
142
143function setStatus($: EngineInterface, text: string | undefined) {
144 if (text !== undefined && !isWatching) return
145 try {
146 $.ui.status(text)
147 } catch {
148 // A surface with no status line.
149 }
150}
151
152function startAnimating(now: number) {
153 animateUntil = Math.max(animateUntil, now + T.trail + 2000)
154}
155
156export const register: Register = on => {
157 on('session.start', async ($, e, next) => {
158 await $.command.register({
159 name: 'watch',
160 description: 'Toggle Claude Work Visualized: the live map of where Claude is working',
161 }).catch(() => undefined)
162 const now = await $.clock.now()
163 const stored = await $.store.get('watching').catch(() => undefined)
164 const isOn = stored !== false
165 await update($, watching, () => isOn)
166 isWatching = isOn
167 startAnimating(now)
168 void rescan($, new Set())
169 if (isOn) void $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
170
171 // The frame clock: redraws while anything moves, and tidies spent state.
172 let lastTidy = 0
173 $.clock.every(FRAME_MS, () => {
174 void (async () => {
175 if (!(await read($, watching))) return
176 const t = await $.clock.now()
177 if (t < animateUntil) $.ui.invalidate('ui.render')
178 if (t - lastTidy < 1000) return
179 lastTidy = t
180 const g = await read($, ghosts)
181 if (Object.values(g).some(x => t - x.at >= T.ghostGone + 200)) {
182 await update($, ghosts, map => Object.fromEntries(Object.entries(map).filter(([, x]) => t - x.at < T.ghostGone + 200)))
183 }
184 const a = await read($, activity)
185 const stale = staleKeys(a, t)
186 if (stale.length > 0) {
187 await update($, activity, map => {
188 const out = { ...map }
189 for (const p of stale) delete out[p]
190 return out
191 })
192 }
193 if (!isAnimating(a, g, await read($, turn), t) && t > animateUntil - T.trail) animateUntil = Math.min(animateUntil, t + 1500)
194 })()
195 })
196
197 // Live sync with the disk: files changed by anything (a script, a build,
198 // another editor) show up without waiting for a tool call to end.
199 let polling = false
200 $.clock.every(POLL_MS, () => {
201 if (polling) return
202 polling = true
203 void (async () => {
204 if (await read($, watching)) await rescan($, new Set())
205 })().finally(() => {
206 polling = false
207 })
208 })
209
210 return next(e)
211 })
212
213 on('command.run', { command: 'watch' }, async $ => {
214 const isOn = !(await read($, watching))
215 await update($, watching, () => isOn)
216 isWatching = isOn
217 await $.store.set('watching', isOn).catch(() => undefined)
218 if (!isOn) {
219 await $.ui.close({ id: PANE }).catch(() => undefined)
220 setStatus($, undefined)
221 return { text: 'Claude Work Visualized is off. /watch turns it back on.' }
222 }
223 await $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
224 startAnimating(await $.clock.now())
225 void rescan($, new Set())
226 return { text: 'Claude Work Visualized is on: watching the project live.' }
227 })
228
229 // Closing the pane by hand switches watching off too, so /watch reopens it.
230 on('ui.close', { id: PANE }, async ($, e, next) => {
231 if (e.origin.kind === 'person') {
232 await update($, watching, () => false)
233 isWatching = false
234 await $.store.set('watching', false).catch(() => undefined)
235 setStatus($, undefined)
236 }
237 return next(e)
238 })
239
240 on('turn.start', async ($, e, next) => {
241 const now = await $.clock.now()
242 await update($, turn, () => ({ ...emptyTurn(), phase: 'working' as const, at: now }))
243 startAnimating(now)
244 return next(e)
245 })
246
247 on('turn.complete', async ($, e, next) => {
248 if (e.agentId === undefined) {
249 const now = await $.clock.now()
250 await update($, turn, info => ({ ...info, phase: 'complete' as const, at: now }))
251 startAnimating(now)
252 setStatus($, undefined)
253 }
254 return next(e)
255 })
256
257 on('tool.call', async ($, e, next) => {
258 const tool = String(e.tool)
259 const mutates = tool === 'Bash' || tool === 'Write' || tool === 'Edit' || tool === 'MultiEdit' || tool === 'NotebookEdit'
260 const cwd = await $.session.cwd()
261 const targets = await targetsOf($, tool, e as unknown as Record<string, unknown>, cwd).catch(() => [] as Target[])
262 if (targets.length === 0 && !mutates) return next(e)
263
264 const startedAt = await $.clock.now()
265 startAnimating(startedAt)
266 // A new file appears the moment Claude starts creating it; a file the
267 // last scan missed joins the tree as Claude reaches it.
268 const known = new Set((await read($, tree)).map(t => t.p))
269 const added = targets.filter(t => !known.has(t.path))
270 if (added.length > 0) {
271 const isDir = (t: Target) => tool === 'Bash' && /\bmkdir\b/.test(String((e as { command?: unknown }).command ?? '')) && t.op === 'create'
272 await update($, tree, list => added.reduce((acc, t) => withPath(acc, t.path, isDir(t)), list))
273 }
274 if (targets.length > 0) {
275 await update($, activity, map => {
276 const out = { ...map }
277 for (const t of targets) out[t.path] = { op: t.op, startedAt, running: (map[t.path]?.running ?? 0) + 1 }
278 return out
279 })
280 const first = targets[targets.length - 1]
281 if (first) setStatus($, `${ICON[first.op]} ${LABEL[first.op]} ${first.path}`)
282 }
283
284 const ran = await next(e)
285 const failed = ran.deny !== undefined || ran.isError === true
286
287 const endedAt = await $.clock.now()
288 startAnimating(endedAt)
289 if (targets.length > 0) {
290 await update($, activity, map => {
291 const out = { ...map }
292 for (const t of targets) {
293 const a = out[t.path]
294 if (a === undefined) continue
295 const running = Math.max(0, a.running - 1)
296 out[t.path] = running > 0 ? { ...a, running } : { ...a, running: 0, endedAt }
297 if (failed && t.op !== 'view' && running === 0) delete out[t.path]
298 }
299 return out
300 })
301 if (!failed) {
302 await update($, turn, info => targets.reduce((acc, t) => (t.op === 'delete' ? acc : counted(acc, t.op, t.path)), info))
303 }
304 const left = await read($, activity)
305 if (focusOf(left) === undefined) setStatus($, undefined)
306 }
307 if (mutates) {
308 // Deletes become ghosts through the rescan, so they play their exit.
309 const claimed = new Set(targets.filter(t => t.op !== 'delete').map(t => t.path))
310 if (failed) {
311 await update($, tree, list => added.reduce((acc, t) => withoutPath(acc, t.path), list))
312 }
313 void rescan($, claimed)
314 }
315 return ran
316 })
317
318 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
319 const { Box, Text } = $.ui.resolve(e)
320 const now = await $.clock.now()
321 const entries = await read($, tree)
322 const act = await read($, activity)
323 const gh = await read($, ghosts)
324 const info = await read($, turn)
325 const root = (await $.session.cwd()).replace(/\/+$/, '').split('/').pop() ?? ''
326 const columns = Math.max(24, (e.props.bodyColumns ?? 40) - 1)
327 const height = Math.max(8, e.props.scroll?.bodyRows ?? (e.viewport?.rows ?? 30) - 3)
328
329 const line = (segs: Seg[], key: string) => (
330 <Box key={key} flexDirection="row" height={1}>
331 {merge(segs).map(s => (
332 <Text color={s.c} backgroundColor={s.bg} bold={s.b} dimColor={s.dim} strikethrough={s.s} wrap="truncate">
333 {s.t}
334 </Text>
335 ))}
336 </Box>
337 )
338 // `── label ─────`, a section rule.
339 const rule = (label: string, key: string, c = FAINT) =>
340 line([{ t: '── ', c: FAINT }, { t: label, c, b: true }, { t: ' ' + '─'.repeat(Math.max(0, columns - label.length - 4)), c: FAINT }], key)
341
342 const rows = buildRows({ tree: entries, activity: act, ghosts: gh, now, columns, folders })
343 const summary = summaryLines(info, now, columns - 4)
344 const legend = legendLines(columns)
345 const chrome = 3 + 1 + legend.length + 1 + (summary.length > 0 ? summary.length + 2 : 0)
346 const room = Math.max(3, height - chrome)
347 const focus = focusOf(act)
348 scroll = scrollTo(scroll, rows, focus, room)
349 const visible = rows.slice(scroll, scroll + room)
350 const above = scroll
351 const below = Math.max(0, rows.length - scroll - room)
352 const fileCount = entries.filter(t => !t.d).length
353 const age = now - info.at
354 const card = Math.min(smoothIn(age / 500), info.phase === 'complete' && age > T.summary - 1500 ? Math.max(0, (T.summary - age) / 1500) : 1)
355
356 return (
357 <Box flexDirection="column" width={columns}>
358 {line(headerLine(now, focus !== undefined || info.phase === 'working', root), 'h')}
359 {line(statusLine(act, info, now, fileCount, columns), 's')}
360 {rule(above > 0 ? `tree ↑${above}` : 'tree', 'r1')}
361 {rows.length === 0 ? line([{ t: ' scanning…', c: MUTED }], 'empty') : visible.map(r => line(r.segs, r.key))}
362 {below > 0 && line([{ t: ` ↓ ${below} more`, c: FAINT }], 'down')}
363 {summary.length > 0 && (
364 <Box flexDirection="column" borderStyle="round" borderColor={mix(FAINT, COLOR.create, card * 0.8)} paddingX={1}>
365 {summary.map((segs, i) => line(segs, `sum${i}`))}
366 </Box>
367 )}
368 <Box flexGrow={1} />
369 {rule('legend', 'r2')}
370 {legend.map((segs, i) => line(segs, `leg${i}`))}
371 </Box>
372 )
373 })
374}
375
376const smoothIn = (t: number) => {
377 const k = Math.min(1, Math.max(0, t))
378 return k * k * (3 - 2 * k)
379}
380
381/** Joins neighbouring cells of one style, so a painted row stays a few Texts. */
382function merge(segs: readonly Seg[]): Seg[] {
383 const out: Seg[] = []
384 for (const s of segs) {
385 if (s.t === '') continue
386 const last = out[out.length - 1]
387 if (last && last.c === s.c && last.bg === s.bg && !!last.b === !!s.b && !!last.dim === !!s.dim && !!last.s === !!s.s) {
388 out[out.length - 1] = { ...last, t: last.t + s.t }
389 } else out.push(s)
390 }
391 return out
392}
393hooks/model.ts 706 lines1// Pure logic of the work map: classifying tool calls, diffing scans, and
2// turning the tree plus Claude's activity into styled rows for a given instant.
3// Nothing here touches `$`, so tests drive it with plain values.
4
5import type { Activity, Ghost, Op, TreeEntry, TurnInfo } from '../types'
6
7export const OPS: readonly Op[] = ['view', 'edit', 'create', 'delete']
8
9export const COLOR: Record<Op, string> = {
10 view: '#58a6ff',
11 edit: '#f0883e',
12 create: '#3fb950',
13 delete: '#f85149',
14}
15/** Each op's colour at full brightness, for highlights riding over it. */
16const HOT: Record<Op, string> = {
17 view: '#cfe6ff',
18 edit: '#ffe2c4',
19 create: '#d2f8dc',
20 delete: '#ffd7d5',
21}
22export const TINT: Record<Op, string> = {
23 view: '#0c2d4f',
24 edit: '#3d2410',
25 create: '#0f3a1e',
26 delete: '#4a1316',
27}
28// One terminal cell each, so the tree's columns never drift.
29export const ICON: Record<Op, string> = { view: '◉', edit: '±', create: '+', delete: '✗' }
30export const LABEL: Record<Op, string> = {
31 view: 'read',
32 edit: 'edit',
33 create: 'new',
34 delete: 'delete',
35}
36export const VERB: Record<Op, string> = { view: 'reading', edit: 'editing', create: 'creating', delete: 'deleting' }
37
38export const BASE = '#c9d1d9'
39export const MUTED = '#6e7681'
40const FOLDER = '#8b98a5'
41export const FAINT = '#30363d'
42export const CLAUDE = '#d97757'
43
44// Timeline, in milliseconds.
45export const T = {
46 entrance: 700, // create: name grows into the tree
47 done: 1800, // completed flash
48 recent: 20_000, // op icon kept, colour decaying
49 trail: 60_000, // faint check mark, then back to normal
50 keepOpen: 120_000, // folders with activity this recent stay expanded
51 ghostHighlight: 450,
52 ghostShake: 1050,
53 ghostGone: 2300,
54 expand: 450,
55 collapse: 500,
56 summary: 20_000,
57}
58
59export const IGNORED = new Set([
60 '.git', 'node_modules', '.next', '.nuxt', '.svelte-kit', '.turbo', '.cache', '.venv', 'venv',
61 '__pycache__', '.mypy_cache', '.pytest_cache', 'dist', 'build', 'out', 'target', 'coverage',
62 '.idea', '.DS_Store',
63])
64
65// ---------------------------------------------------------------- paths
66
67export function relPath(cwd: string, path: string): string | undefined {
68 const root = cwd.replace(/\/+$/, '')
69 let p = path.trim()
70 if (p === '') return undefined
71 if (p.startsWith('./')) p = p.slice(2)
72 if (p.startsWith('/')) {
73 if (p === root) return undefined
74 if (!p.startsWith(root + '/')) return undefined
75 p = p.slice(root.length + 1)
76 }
77 const parts: string[] = []
78 for (const part of p.split('/')) {
79 if (part === '' || part === '.') continue
80 if (part === '..') {
81 if (parts.length === 0) return undefined
82 parts.pop()
83 } else parts.push(part)
84 }
85 if (parts.length === 0) return undefined
86 if (parts.some(part => IGNORED.has(part))) return undefined
87 return parts.join('/')
88}
89
90export function parentOf(path: string): string {
91 const i = path.lastIndexOf('/')
92 return i < 0 ? '' : path.slice(0, i)
93}
94
95export function baseName(path: string): string {
96 return path.slice(path.lastIndexOf('/') + 1)
97}
98
99export function ancestors(path: string): string[] {
100 const out: string[] = []
101 for (let p = parentOf(path); p !== ''; p = parentOf(p)) out.push(p)
102 return out
103}
104
105/** Adds `path` (and any missing parent folders) to a tree. */
106export function withPath(tree: readonly TreeEntry[], path: string, d: boolean): TreeEntry[] {
107 const have = new Set(tree.map(t => t.p))
108 const add: TreeEntry[] = []
109 for (const dir of ancestors(path)) if (!have.has(dir)) add.push({ p: dir, d: true, m: 0 })
110 if (!have.has(path)) add.push({ p: path, d, m: 0 })
111 return add.length === 0 ? [...tree] : [...tree, ...add]
112}
113
114export function withoutPath(tree: readonly TreeEntry[], path: string): TreeEntry[] {
115 return tree.filter(t => t.p !== path && !t.p.startsWith(path + '/'))
116}
117
118// ---------------------------------------------------------------- bash
119
120export type Target = { op: Op; path: string }
121
122const VIEWERS = new Set([
123 'cat', 'head', 'tail', 'less', 'more', 'bat', 'wc', 'grep', 'rg', 'ag', 'diff', 'file', 'stat',
124 'nl', 'jq', 'awk', 'sed', 'cut', 'sort', 'uniq', 'xxd', 'hexdump', 'od', 'strings', 'md5sum',
125 'sha256sum', 'ls', 'tree', 'find',
126])
127const DELETERS = new Set(['rm', 'unlink', 'rmdir', 'trash', 'shred'])
128
129function tokenize(segment: string): string[] {
130 const out: string[] = []
131 const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g
132 for (let m = re.exec(segment); m !== null; m = re.exec(segment)) {
133 out.push(m[1] ?? m[2] ?? m[3] ?? '')
134 }
135 return out
136}
137
138/**
139 * Best-effort reading of a shell command: which paths it reads, writes,
140 * creates or deletes. The scan diff after the command catches what this
141 * misses, so it only has to be right about what to animate while it runs.
142 */
143export function classifyBash(command: string): Target[] {
144 const targets: Target[] = []
145 const segments = command.split(/&&|\|\||;|\||\n/)
146 for (const segment of segments) {
147 let words = tokenize(segment.trim())
148 // Redirections: `> file` writes, `< file` reads.
149 const rest: string[] = []
150 for (let i = 0; i < words.length; i++) {
151 const w = words[i] ?? ''
152 const redirect = /^(\d?>>?|&>)(.*)$/.exec(w)
153 if (redirect) {
154 const target = redirect[2] !== '' ? redirect[2] : words[++i]
155 if (target && !target.startsWith('&') && target !== '/dev/null') targets.push({ op: 'edit', path: target })
156 continue
157 }
158 if (w === '<' && words[i + 1]) {
159 targets.push({ op: 'view', path: words[++i] ?? '' })
160 continue
161 }
162 rest.push(w)
163 }
164 words = rest
165 while (words[0] !== undefined && (/^\w+=/.test(words[0]) || ['sudo', 'command', 'exec', 'time'].includes(words[0]))) words = words.slice(1)
166 let cmd = words[0]
167 if (cmd === undefined) continue
168 cmd = baseName(cmd)
169 let args = words.slice(1)
170 if (cmd === 'git' && (args[0] === 'rm' || args[0] === 'mv')) {
171 cmd = args[0]
172 args = args.slice(1)
173 }
174 const operands = args.filter(a => !a.startsWith('-') && !/[*?$`(){}]/.test(a))
175 if (DELETERS.has(cmd)) {
176 for (const p of operands) targets.push({ op: 'delete', path: p })
177 } else if (cmd === 'touch' || cmd === 'mkdir') {
178 for (const p of operands) targets.push({ op: 'create', path: p })
179 } else if (cmd === 'mv' && operands.length >= 2) {
180 const dest = operands[operands.length - 1] ?? ''
181 for (const p of operands.slice(0, -1)) targets.push({ op: 'delete', path: p })
182 targets.push({ op: 'create', path: dest })
183 } else if (cmd === 'cp' && operands.length >= 2) {
184 const dest = operands[operands.length - 1] ?? ''
185 for (const p of operands.slice(0, -1)) targets.push({ op: 'view', path: p })
186 targets.push({ op: 'create', path: dest })
187 } else if (cmd === 'tee') {
188 for (const p of operands) targets.push({ op: 'edit', path: p })
189 } else if ((cmd === 'sed' || cmd === 'perl') && args.some(a => /^-[a-zA-Z]*i/.test(a))) {
190 for (const p of operands.slice(1)) targets.push({ op: 'edit', path: p })
191 } else if (VIEWERS.has(cmd)) {
192 // grep/sed/awk/jq take a pattern or program first.
193 const skip = ['grep', 'rg', 'ag', 'sed', 'awk', 'jq'].includes(cmd) ? 1 : 0
194 for (const p of operands.slice(skip)) targets.push({ op: 'view', path: p })
195 }
196 }
197 return targets
198}
199
200// ---------------------------------------------------------------- diffs
201
202export type Diff = { created: TreeEntry[]; deleted: TreeEntry[]; edited: string[] }
203
204export function diffTrees(before: readonly TreeEntry[], after: readonly TreeEntry[]): Diff {
205 const old = new Map(before.map(t => [t.p, t]))
206 const now = new Map(after.map(t => [t.p, t]))
207 const created: TreeEntry[] = []
208 const edited: string[] = []
209 for (const t of after) {
210 const was = old.get(t.p)
211 if (was === undefined) created.push(t)
212 else if (!t.d && was.m !== 0 && t.m !== was.m) edited.push(t.p)
213 }
214 // A deleted folder shows as one ghost, not one per file inside it.
215 const deleted = before.filter(t => !now.has(t.p) && (parentOf(t.p) === '' || now.has(parentOf(t.p))))
216 return { created, deleted, edited }
217}
218
219// ---------------------------------------------------------------- colour
220
221function hex(c: string): [number, number, number] {
222 const n = parseInt(c.slice(1), 16)
223 return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
224}
225
226export function mix(a: string, b: string, t: number): string {
227 const k = Math.min(1, Math.max(0, t))
228 const [r1, g1, b1] = hex(a)
229 const [r2, g2, b2] = hex(b)
230 const ch = (x: number, y: number) => Math.round(x + (y - x) * k).toString(16).padStart(2, '0')
231 return `#${ch(r1, r2)}${ch(g1, g2)}${ch(b1, b2)}`
232}
233
234const ease = (t: number) => 1 - Math.pow(1 - Math.min(1, Math.max(0, t)), 3)
235
236// ---------------------------------------------------------------- activity
237
238export type Level = 'active' | 'done' | 'recent' | 'trail' | 'none'
239
240export function levelOf(a: Activity | undefined, now: number): Level {
241 if (a === undefined) return 'none'
242 if (a.running > 0 || a.endedAt === undefined) return 'active'
243 const age = now - a.endedAt
244 if (age < T.done) return 'done'
245 if (age < T.recent) return 'recent'
246 if (age < T.trail) return 'trail'
247 return 'none'
248}
249
250/** 1 for the running operation, decaying to 0 as Claude moves on. */
251export function intensityOf(a: Activity | undefined, now: number): number {
252 const level = levelOf(a, now)
253 if (level === 'active') return 1
254 if (level === 'none' || a?.endedAt === undefined) return 0
255 const age = now - a.endedAt
256 if (level === 'done') return 0.85
257 return 0.75 * (1 - ease(age / T.trail))
258}
259
260/** The file Claude is working on right now: the latest running operation. */
261export function focusOf(activity: Readonly<Record<string, Activity>>): string | undefined {
262 let best: string | undefined
263 let at = -Infinity
264 for (const [path, a] of Object.entries(activity)) {
265 if (a.running > 0 && a.startedAt >= at) {
266 best = path
267 at = a.startedAt
268 }
269 }
270 return best
271}
272
273export function isAnimating(
274 activity: Readonly<Record<string, Activity>>,
275 ghosts: Readonly<Record<string, Ghost>>,
276 turn: TurnInfo,
277 now: number,
278): boolean {
279 if (Object.keys(ghosts).length > 0) return true
280 if (turn.phase === 'complete' && now - turn.at < T.summary + 1000) return true
281 return Object.values(activity).some(a => levelOf(a, now) !== 'none')
282}
283
284/** Activity records old enough to have no effect on the drawing. */
285export function staleKeys(activity: Readonly<Record<string, Activity>>, now: number): string[] {
286 return Object.entries(activity)
287 .filter(([, a]) => levelOf(a, now) === 'none' && now - (a.endedAt ?? now) > T.keepOpen)
288 .map(([p]) => p)
289}
290
291// ---------------------------------------------------------------- rows
292
293export type Seg = {
294 t: string
295 c?: string
296 bg?: string
297 b?: boolean
298 dim?: boolean
299 s?: boolean
300}
301export type Row = { key: string; path: string; segs: Seg[] }
302
303/** Per-folder open/closed memory, so expansion and collapse can animate. */
304export type FolderMemory = Map<string, { open: boolean; at: number }>
305
306type Node = { p: string; d: boolean; ghost?: Ghost }
307
308const clamp01 = (t: number) => Math.min(1, Math.max(0, t))
309/** Smoothstep: eases in and out, so motion has no visible start or stop. */
310const smooth = (t: number) => {
311 const k = clamp01(t)
312 return k * k * (3 - 2 * k)
313}
314const pulse = (now: number, period: number, phase = 0) => (Math.sin(((now - phase) / period) * Math.PI * 2) + 1) / 2
315
316function sortNodes(a: Node, b: Node): number {
317 if (a.d !== b.d) return a.d ? -1 : 1
318 return baseName(a.p).localeCompare(baseName(b.p), undefined, { sensitivity: 'base' })
319}
320
321function truncate(text: string, room: number): string {
322 if (room <= 0) return ''
323 const chars = [...text]
324 if (chars.length <= room) return text
325 return chars.slice(0, Math.max(0, room - 1)).join('') + '…'
326}
327
328export const cells = (segs: readonly Seg[]) => segs.reduce((w, s) => w + [...s.t].length, 0)
329
330/** A string as one segment per cell, each styled by its index. */
331function paint(text: string, style: (i: number, n: number) => Omit<Seg, 't'>): Seg[] {
332 const chars = [...text]
333 return chars.map((t, i) => ({ t, ...style(i, chars.length) }))
334}
335
336/** A moving highlight band at fractional position `at`, `radius` cells wide. */
337function band(i: number, at: number, radius: number): number {
338 return smooth(1 - Math.abs(i - at) / radius)
339}
340
341export type Scene = {
342 tree: readonly TreeEntry[]
343 activity: Readonly<Record<string, Activity>>
344 ghosts: Readonly<Record<string, Ghost>>
345 now: number
346 columns: number
347 folders: FolderMemory
348}
349
350type Guide = { more: boolean; lit: number; op?: Op }
351
352/** The tree as styled rows for this instant. */
353export function buildRows(scene: Scene): Row[] {
354 const { tree, activity, ghosts, now, columns, folders } = scene
355 const nodes = new Map<string, Node>()
356 for (const t of tree) nodes.set(t.p, { p: t.p, d: t.d })
357 for (const [p, g] of Object.entries(ghosts)) {
358 if (now - g.at >= T.ghostGone) continue
359 nodes.set(p, { p, d: g.d, ghost: g })
360 for (const dir of ancestors(p)) if (!nodes.has(dir)) nodes.set(dir, { p: dir, d: true })
361 }
362 const children = new Map<string, Node[]>()
363 for (const n of nodes.values()) {
364 const parent = parentOf(n.p)
365 if (parent !== '' && !nodes.has(parent)) continue
366 const list = children.get(parent) ?? []
367 list.push(n)
368 children.set(parent, list)
369 }
370 for (const list of children.values()) list.sort(sortNodes)
371
372 const focus = focusOf(activity)
373 const focusOp = focus === undefined ? undefined : activity[focus]?.op
374 const focusChain = new Set(focus === undefined ? [] : ancestors(focus))
375 const onChain = (p: string) => p === focus || focusChain.has(p)
376
377 // Strongest activity at or under each folder, weakened per level climbed.
378 const heat = new Map<string, { v: number; op: Op }>()
379 const busy = new Set<string>()
380 for (const [p, a] of Object.entries(activity)) {
381 const v = intensityOf(a, now)
382 if (a.endedAt === undefined || a.running > 0 || now - a.endedAt < T.keepOpen) {
383 for (const dir of ancestors(p)) busy.add(dir)
384 if (nodes.get(p)?.d) busy.add(p)
385 }
386 if (v <= 0) continue
387 ancestors(p).forEach((dir, i) => {
388 const w = v * Math.pow(0.68, i + 1)
389 const had = heat.get(dir)
390 if (had === undefined || had.v < w) heat.set(dir, { v: w, op: a.op })
391 })
392 }
393 for (const p of Object.keys(ghosts)) for (const dir of ancestors(p)) busy.add(dir)
394
395 const fileCount = tree.filter(t => !t.d).length
396 const wantsOpen = (dir: string, depth: number) =>
397 fileCount <= 40 || busy.has(dir) || (depth === 0 && tree.length <= 150)
398
399 // The signal running down the tree toward the focused file.
400 const signal = (depth: number) => 0.45 + 0.55 * pulse(now, 1300, depth * 140)
401
402 function guideSegs(guides: readonly Guide[], isLast: boolean, own: Guide | undefined): Seg[] {
403 if (guides.length === 0 && own === undefined) return []
404 const segs: Seg[] = guides.slice(0, -1).map((g, level) => ({
405 t: g.more ? '│ ' : ' ',
406 c: g.lit > 0 && g.op ? mix(FAINT, COLOR[g.op], g.lit * signal(level)) : FAINT,
407 }))
408 const level = guides.length - 1
409 const lit = own?.lit ?? 0
410 const op = own?.op
411 segs.push({ t: isLast ? '└╴' : '├╴', c: lit > 0 && op ? mix(FAINT, COLOR[op], lit * signal(level)) : FAINT })
412 return segs
413 }
414
415 function fadeIn(segs: Seg[], fade: number): Seg[] {
416 if (fade <= 0.02) return segs
417 return segs.map(s => ({ ...s, c: mix(s.c ?? BASE, FAINT, fade), bg: undefined, b: false }))
418 }
419
420 const rows: Row[] = []
421 const walk = (parent: string, depth: number, fade: number, guides: Guide[]) => {
422 const list = children.get(parent) ?? []
423 const chainAt = list.findIndex(n => onChain(n.p))
424 list.forEach((n, i) => {
425 const isLast = i === list.length - 1
426 // Siblings above the chain child carry the lit line down to it.
427 const lit = chainAt < 0 || i > chainAt ? 0 : i === chainAt ? 1 : 0.6
428 const own: Guide = { more: !isLast, lit, op: focusOp }
429 const line = depth === 0 ? [] : guideSegs([...guides, own], isLast, own)
430 rows.push(n.d ? folderRow(n, depth, fade, line) : fileRow(n, depth, fade, line))
431 if (!n.d) return
432 const want = wantsOpen(n.p, depth)
433 let mem = folders.get(n.p)
434 if (mem === undefined) {
435 mem = { open: want, at: -Infinity }
436 folders.set(n.p, mem)
437 } else if (mem.open !== want) {
438 mem = { open: want, at: now }
439 folders.set(n.p, mem)
440 }
441 // A child's guide at this level is lit only while the line runs past it.
442 const below: Guide = { more: !isLast, lit: chainAt >= 0 && i < chainAt ? 0.6 : 0, op: focusOp }
443 const next = depth === 0 ? [] : [...guides, below]
444 const since = now - mem.at
445 if (mem.open) walk(n.p, depth + 1, Math.max(fade, 1 - smooth(since / T.expand)), next)
446 else if (since < T.collapse) walk(n.p, depth + 1, Math.max(fade, smooth(since / T.collapse)), next)
447 })
448 }
449
450 function folderRow(n: Node, depth: number, fade: number, line: Seg[]): Row {
451 const name = baseName(n.p) + '/'
452 const open = folders.get(n.p)?.open ?? wantsOpen(n.p, depth)
453 const own = activity[n.p]
454 const h = heat.get(n.p)
455 const ownV = intensityOf(own, now)
456 const v = Math.max(h?.v ?? 0, ownV)
457 const op = ownV >= (h?.v ?? 0) ? own?.op : h?.op
458 const room = columns - 1 - cells(line) - 2 - 3
459 if (n.ghost) return { key: n.p, path: n.p, segs: ghostSegs([{ t: ' ' }, ...line], n, name, room) }
460 const tone = op ? mix(FOLDER, COLOR[op], Math.min(1, v * 1.2)) : FOLDER
461 const segs: Seg[] = [{ t: ' ' }, ...line]
462 segs.push({ t: open ? '▾' : '▸', c: op && v > 0.05 ? tone : MUTED }, { t: ' ' })
463 segs.push({ t: truncate(name, room), c: tone, b: v > 0.4 })
464 if (focusChain.has(n.p) && focusOp) {
465 const away = (focus?.split('/').length ?? 1) - n.p.split('/').length
466 const strength = Math.pow(0.72, away - 1)
467 const glyph = strength > 0.65 ? '●' : strength > 0.4 ? '•' : '·'
468 segs.push({ t: ' ' + glyph, c: mix(FAINT, COLOR[focusOp], strength * signal(depth)) })
469 } else if (v > 0.05 && op) {
470 segs.push({ t: ' ·', c: mix(FAINT, COLOR[op], v) })
471 } else if (!open) {
472 const count = children.get(n.p)?.length ?? 0
473 if (count > 0) segs.push({ t: ` +${count}`, c: FAINT })
474 }
475 return { key: n.p, path: n.p, segs: fadeIn(segs, fade) }
476 }
477
478 function ghostSegs(lead: Seg[], n: Node, name: string, room: number): Seg[] {
479 const g = n.ghost as Ghost
480 const age = now - g.at
481 const col = COLOR.delete
482 const segs = [...lead]
483 const shown = truncate(name, room)
484 if (age < T.ghostHighlight) {
485 const k = smooth(age / T.ghostHighlight)
486 const bg = mix(TINT.delete, '#8e1519', k)
487 segs.push({ t: ICON.delete, c: mix(col, '#ffffff', k), b: true }, { t: ' ', bg }, { t: shown, c: mix(col, '#ffffff', k), bg, b: true })
488 } else if (age < T.ghostShake) {
489 // A damped shake: big jolts first, settling to still.
490 const t = (age - T.ghostHighlight) / (T.ghostShake - T.ghostHighlight)
491 const amp = 3 * Math.pow(1 - t, 2)
492 const off = Math.round(amp * (0.5 + 0.5 * Math.sin(t * Math.PI * 2 * 5)))
493 const bg = mix('#8e1519', TINT.delete, smooth(t))
494 segs.push({ t: ' '.repeat(off) }, { t: ICON.delete, c: col, b: true }, { t: ' ', bg }, { t: truncate(name, room - off), c: mix('#ffffff', HOT.delete, t), bg, b: true })
495 } else {
496 // Dissolves from the right, each cell dimming before it goes.
497 const p = smooth((age - T.ghostShake) / (T.ghostGone - T.ghostShake))
498 const chars = [...shown]
499 const keep = chars.slice(0, Math.ceil(chars.length * (1 - p))).join('')
500 segs.push({ t: p < 0.8 ? ICON.delete : ' ', c: mix(col, FAINT, p) }, { t: ' ' })
501 segs.push(...paint(keep, (i, len) => ({ c: mix(col, FAINT, clamp01(p * 1.2 + (i / Math.max(1, len)) * 0.5)), s: true })))
502 }
503 return segs
504 }
505
506 function fileRow(n: Node, depth: number, fade: number, line: Seg[]): Row {
507 const name = baseName(n.p)
508 const a = activity[n.p]
509 const level = n.ghost ? 'none' : levelOf(a, now)
510 const isFocus = n.p === focus
511 const room = columns - 1 - cells(line) - 2 - (isFocus ? 13 : 1)
512 if (n.ghost) return { key: n.p, path: n.p, segs: ghostSegs([{ t: ' ' }, ...line], n, name, room) }
513 const shown = truncate(name, room)
514
515 if (a === undefined || level === 'none') {
516 return { key: n.p, path: n.p, segs: fadeIn([{ t: ' ' }, ...line, { t: '·', c: FAINT }, { t: ' ' }, { t: shown, c: BASE }], fade) }
517 }
518 const col = COLOR[a.op]
519 const hot = HOT[a.op]
520
521 if (level === 'active') {
522 const bg = isFocus ? TINT[a.op] : undefined
523 const segs: Seg[] = [isFocus ? { t: '▌', c: mix(col, hot, pulse(now, 1000) * 0.6) } : { t: ' ' }, ...line]
524 if (!isFocus) {
525 // Running in the background: present, but quiet.
526 segs.push({ t: ICON[a.op], c: col }, { t: ' ' }, { t: shown, c: mix(col, BASE, 0.15 + 0.25 * pulse(now, 1800)) })
527 return { key: n.p, path: n.p, segs }
528 }
529 if (a.op === 'view') {
530 // A soft band of light scans the name, back and forth.
531 const len = [...shown].length
532 const at = (len + 4) * (0.5 - 0.5 * Math.cos((now / 1400) * Math.PI)) - 2
533 segs.push({ t: ICON.view, c: mix(col, hot, pulse(now, 700) * 0.7), b: true }, { t: ' ', bg })
534 segs.push(...paint(shown, i => {
535 const k = band(i, at, 2.6)
536 return { c: mix(col, hot, k), bg: mix(TINT.view, '#1f6feb', k * 0.8), b: k > 0.5 }
537 }))
538 } else if (a.op === 'edit') {
539 // A ripple runs along the name like keystrokes landing, then a cursor.
540 segs.push({ t: ICON.edit, c: mix(col, hot, pulse(now, 520) * 0.8), b: true }, { t: ' ', bg })
541 segs.push(...paint(shown, i => ({ c: mix(col, hot, 0.75 * pulse(now, 900, i * 55)), bg, b: true })))
542 segs.push({ t: '▏', c: mix(bg ?? FAINT, hot, pulse(now, 1060)), bg })
543 } else if (a.op === 'create') {
544 const age = now - a.startedAt
545 if (age < T.entrance) {
546 // The name types itself in, its leading edge bright.
547 const p = smooth(age / T.entrance)
548 const chars = [...shown]
549 const reveal = chars.length * p
550 const seed = ['·', '∙', '•', '+'][Math.min(3, Math.floor(p * 4))] ?? '+'
551 segs.push({ t: seed, c: mix(FAINT, col, p), b: true }, { t: ' ', bg })
552 segs.push(...paint(chars.slice(0, Math.ceil(reveal)).join(''), i => {
553 const edge = reveal - i
554 return { c: edge < 1 ? mix(TINT.create, hot, edge) : mix(hot, col, clamp01((edge - 1) / 4)), bg, b: true }
555 }))
556 } else {
557 segs.push({ t: ICON.create, c: mix(col, hot, pulse(now, 800) * 0.7), b: true }, { t: ' ', bg })
558 segs.push(...paint(shown, i => ({ c: mix(col, hot, 0.6 * pulse(now, 1100, i * 70)), bg: mix(TINT.create, '#196c2e', 0.6 * pulse(now, 1600)), b: true })))
559 }
560 } else {
561 const k = pulse(now, 760)
562 const red = mix(TINT.delete, '#8e1519', k)
563 segs.push({ t: ICON.delete, c: mix(col, hot, k), b: true }, { t: ' ', bg: red })
564 segs.push({ t: shown, c: mix(col, '#ffffff', k * 0.8), bg: red, b: true })
565 }
566 // A leader flows from the right edge into the file, ending in what Claude is doing.
567 const label = ` ${VERB[a.op]} `
568 const pad = Math.max(1, columns - cells(segs) - label.length - 1)
569 segs.push({ t: ' ', bg })
570 segs.push(...paint('╌'.repeat(Math.max(0, pad - 1)), j => ({ c: mix(FAINT, col, 0.15 + 0.6 * pulse(now, 1000, -j * 70)), bg })))
571 segs.push({ t: '◂', c: col, bg, b: true })
572 segs.push(...paint(label, i => ({ c: mix(col, hot, band(i, ((now / 90) % (label.length + 8)) - 4, 2.2)), bg, b: true })))
573 return { key: n.p, path: n.p, segs }
574 }
575
576 const age = now - (a.endedAt ?? now)
577 const segs: Seg[] = [{ t: ' ' }, ...line]
578 if (level === 'done') {
579 const p = smooth(age / T.done)
580 segs.push({ t: '✓', c: mix(hot, col, p), b: p < 0.6 }, { t: ' ' }, { t: shown, c: mix(hot, mix(col, BASE, 0.15), p), b: p < 0.4 })
581 } else if (level === 'recent') {
582 const p = smooth((age - T.done) / (T.recent - T.done))
583 segs.push({ t: ICON[a.op], c: mix(col, mix(col, FAINT, 0.6), p) }, { t: ' ' }, { t: shown, c: mix(col, BASE, 0.15 + 0.6 * p) })
584 } else {
585 const p = smooth((age - T.recent) / (T.trail - T.recent))
586 segs.push({ t: '✓', c: mix(mix(col, FAINT, 0.6), FAINT, p) }, { t: ' ' }, { t: shown, c: mix(mix(col, BASE, 0.75), BASE, p) })
587 }
588 return { key: n.p, path: n.p, segs: fadeIn(segs, fade) }
589 }
590
591 walk('', 0, 0, [])
592 return rows
593}
594
595/** Eases the visible window toward keeping the focused row in its upper third. */
596export function scrollTo(current: number, rows: readonly Row[], focus: string | undefined, room: number): number {
597 const max = Math.max(0, rows.length - room)
598 let target = Math.min(current, max)
599 const at = focus === undefined ? -1 : rows.findIndex(r => r.path === focus)
600 if (at >= 0 && (at < current + 1 || at > current + room - 2)) target = Math.min(max, Math.max(0, at - Math.floor(room / 3)))
601 const delta = target - current
602 if (delta === 0) return Math.min(current, max)
603 const step = Math.max(1, Math.ceil(Math.abs(delta) / 4))
604 return current + Math.sign(delta) * Math.min(step, Math.abs(delta))
605}
606
607// ---------------------------------------------------------------- chrome
608
609// Glyphs every common monospace font has, so the spark never falls back to a wider font.
610const SPARK = ['·', '∙', '•', '◆', '✶', '◆', '•', '∙']
611
612/** `✶ work-map ~/project`, the spark turning while Claude works. */
613export function headerLine(now: number, isWorking: boolean, root: string): Seg[] {
614 const spark = isWorking ? SPARK[Math.floor(now / 110) % SPARK.length] ?? '✶' : '✶'
615 return [
616 { t: spark, c: isWorking ? mix(CLAUDE, '#ffd2bf', pulse(now, 900) * 0.6) : CLAUDE, b: true },
617 { t: ' work-map', c: BASE, b: true },
618 { t: ` ~/${root}`, c: MUTED },
619 ]
620}
621
622/** The prompt line: what Claude is doing right now, with a live cursor. */
623export function statusLine(activity: Readonly<Record<string, Activity>>, turn: TurnInfo, now: number, fileCount: number, columns: number): Seg[] {
624 const focus = focusOf(activity)
625 const a = focus === undefined ? undefined : activity[focus]
626 const cursor = (c: string): Seg => ({ t: '█', c: mix(c, FAINT, pulse(now, 1060)) })
627 const shimmer = (text: string, c: string): Seg[] =>
628 paint(text, i => ({ c: mix(c, '#ffffff', 0.7 * band(i, ((now / 70) % (text.length + 10)) - 5, 2.5)), b: true }))
629 if (a !== undefined && focus !== undefined) {
630 const col = COLOR[a.op]
631 const others = Object.values(activity).filter(x => x.running > 0).length - 1
632 const secs = `${(Math.max(0, now - a.startedAt) / 1000).toFixed(1)}s`
633 const dir = parentOf(focus)
634 const tail = ` ${secs}${others > 0 ? ` +${others}` : ''}`
635 const room = columns - 2 - VERB[a.op].length - 1 - tail.length - 2 // '❯ ', the verb, a space, the tail, ' █'
636 const path = truncate(focus, room)
637 const cut = dir !== '' && path === focus ? dir.length + 1 : 0
638 return [
639 { t: '❯ ', c: CLAUDE, b: true },
640 ...shimmer(VERB[a.op], col),
641 { t: ' ' },
642 { t: path.slice(0, cut), c: MUTED },
643 { t: path.slice(cut), c: BASE },
644 { t: tail, c: MUTED },
645 { t: ' ' },
646 cursor(col),
647 ]
648 }
649 if (turn.phase === 'working') return [{ t: '❯ ', c: CLAUDE, b: true }, ...shimmer('thinking', MUTED), { t: ' ' }, cursor(MUTED)]
650 return [{ t: '❯ ', c: MUTED }, { t: `idle · ${fileCount} files`, c: MUTED }, { t: ' ' }, cursor(MUTED)]
651}
652
653export function legendLines(columns: number): Seg[][] {
654 const item = (op: Op): Seg[] => [{ t: ICON[op], c: COLOR[op], b: true }, { t: ' ' + LABEL[op], c: MUTED }]
655 const gap: Seg = { t: ' ' }
656 const one = [...item('view'), gap, ...item('edit'), gap, ...item('create'), gap, ...item('delete')]
657 if (cells(one) <= columns) return [one]
658 return [
659 [...item('view'), gap, ...item('edit')],
660 [...item('create'), gap, ...item('delete')],
661 ]
662}
663
664/** The completion card's lines, bars filling as it enters; none once it has gone. */
665export function summaryLines(turn: TurnInfo, now: number, columns = 40): Seg[][] {
666 if (turn.phase !== 'complete') return []
667 const age = now - turn.at
668 if (age > T.summary) return []
669 const out = smooth((age - (T.summary - 1500)) / 1500)
670 const fadeOut = (s: Seg): Seg => (out > 0 ? { ...s, c: mix(s.c ?? BASE, FAINT, out), b: false } : s)
671 const lines: Seg[][] = []
672 const head = smooth(age / 450)
673 lines.push(paint('✓ TASK COMPLETE', i => ({ c: mix(FAINT, COLOR.create, smooth(head * 1.6 - i / 30)), b: true })))
674 const words: Record<Op, string> = { view: 'viewed', edit: 'edited', create: 'created', delete: 'deleted' }
675 const most = Math.max(1, ...OPS.map(op => turn.counts[op].length))
676 const barWidth = Math.max(4, Math.min(14, columns - 22))
677 OPS.forEach((op, i) => {
678 const start = 250 + i * 120
679 if (age < start) return
680 const n = turn.counts[op].length
681 const grow = smooth((age - start) / 800)
682 const shown = Math.round(n * grow)
683 const filled = (barWidth * n * grow) / most
684 const whole = Math.floor(filled)
685 const bar: Seg[] = paint('━'.repeat(barWidth), j => ({
686 c: j < whole ? COLOR[op] : j === whole && filled > whole ? mix(FAINT, COLOR[op], filled - whole) : FAINT,
687 }))
688 lines.push([
689 { t: ICON[op] + ' ', c: n > 0 ? COLOR[op] : FAINT, b: true },
690 { t: words[op].padEnd(8), c: n > 0 ? BASE : MUTED },
691 { t: String(shown).padStart(3) + ' ', c: n > 0 ? COLOR[op] : MUTED, b: n > 0 },
692 ...bar,
693 ])
694 })
695 return lines.map(l => l.map(fadeOut))
696}
697
698export function emptyTurn(): TurnInfo {
699 return { phase: 'idle', at: 0, counts: { view: [], edit: [], create: [], delete: [] } }
700}
701
702export function counted(turn: TurnInfo, op: Op, path: string): TurnInfo {
703 if (turn.counts[op].includes(path)) return turn
704 return { ...turn, counts: { ...turn.counts, [op]: [...turn.counts[op], path] } }
705}
706types/index.d.ts 36 lines1export type Op = 'view' | 'edit' | 'create' | 'delete'
2
3/** One path of the scanned project, relative to the session's directory. */
4export type TreeEntry = { p: string; d: boolean; m: number }
5
6/** What Claude last did to a path, and when (ms since the epoch). */
7export type Activity = {
8 op: Op
9 startedAt: number
10 /** Absent while the operation runs. */
11 endedAt?: number
12 /** Tool calls currently running on the path. */
13 running: number
14}
15
16/** A deleted path kept on screen while its exit plays. */
17export type Ghost = { d: boolean; at: number }
18
19export type TurnInfo = {
20 phase: 'idle' | 'working' | 'complete'
21 at: number
22 counts: Record<Op, string[]>
23}
24
25declare module 'claude-code' {
26 interface PluginState {
27 'work-visualized': {
28 tree: TreeEntry[]
29 activity: Record<string, Activity>
30 ghosts: Record<string, Ghost>
31 turn: TurnInfo
32 watching: boolean
33 }
34 }
35}
36