SLOPSHOPPER

work-visualized

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

newpaneguardcommandstatustimer
v0.1.1MITupdated 2026-10-03GrzeskoByte/claude-work-visualized
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · work-visualized
│ ┃ work-map ✕ › fix the failing auth test and add an audit log call │ ┃ ✶ work-map ~/app │ ┃ ❯ idle · 3 files █ ⏺ Read(src/auth.ts) │ ┃ ── tree ──────────────────────────────────── ⎿ Read 6 lines │ ┃ · package.json ⏺ Update(src/auth.ts) │ ┃ · README.md ⎿ Added 2 lines, removed 1 line │ ┃ ✓ src ⏺ Bash(bun test) │ ┃ ╭─────────────────────────────────────────── ⎿ 3 pass, 1 fail │ ┃ │ ✓ TASK COMPLETE │ ┃ ╰─────────────────────────────────────────── ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ── legend ────────────────────────────────── │ ┃ ◉ read ± edit + new ✗ delete ✻ Worked for 42s · done 4:20 PM │ │ › /watch │ ⎿ work-visualized: Claude Work Visualized is off. /watch turns it │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · work-map
✶ work-map ~/app ❯ idle · 3 files █ ── tree ─────────────────────────────────────────────── · package.json · README.md ✓ src ╭─────────────────────────────────────────────────────╮ │ ✓ TASK COMPLETE │ ╰─────────────────────────────────────────────────────╯ ── legend ───────────────────────────────────────────── ◉ read ± edit + new ✗ delete
README

Claude Work Visualized

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">

Features

  • Live file tree: the project is scanned when the session starts and rescanned every 1.5 s, so changes made by scripts, builds or your editor show up too.
  • One look per operation: each of read, edit, create and delete has its own colour, glyph and animation, so you can tell them apart without reading labels.
  • Current file: the file Claude is on right now gets the strongest animation, a ▌ marker, a tinted row and a ◂ editing label.
  • Path to the current file: the tree guides and parent folders light up along the way down to it, weaker the further up they are.
  • Fading trail: files Claude has touched stay marked and fade back to normal over about 60 s.
  • Prompt line: shows what Claude is doing, on which path, for how long, with a blinking cursor.
  • Completion card: when a turn ends, a card shows how many files were viewed, edited, created and deleted, as filling bars.
  • Terminal style: only one-cell glyphs and box-drawing characters, up to 30 fps while anything moves, and idle when nothing does.

Usage

CommandWhat it does
/watchTurns the work map on or off (opens or closes the pane). The choice is remembered across sessions.
  • When it's on, the pane opens by itself at session start once the terminal is at least 144 columns wide. /watch opens it at any width.
  • Closing the pane by hand also turns watching off. The next /watch reopens it.
  • While a tool runs, the status line also shows the current operation, e.g. ± edit src/app.ts.

Visual language

GlyphOperationColourWhile runningAfter
◉readbluea band of light sweeps across the name✓, colour fades
±editorangea ripple runs along the name, then a blinking cursor ▏✓, colour fades
+creategreenthe name types itself in, then glowssettles into the tree
✗deleteredred pulsehighlight → 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.

Installation

Requirements

  • Claude Code with function-hooks plugins, tested on 2.1.288. The function-hooks API is in early access and may change between releases.
  • A terminal at least 144 columns wide for the pane to open on its own as a sidebar. It opens at any width with /watch.
  • A dark terminal theme is recommended; the colours are tuned for one.

Option 1: install from the marketplace (recommended)

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

Option 2: run from a local clone

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"
  }
}

Check that it works

  1. Start Claude Code in any project and run /watch. The pane should show ✶ work-map ~/<your project> and the project tree.
  2. Ask Claude to read or edit a file. That file should light up as Claude works on it.

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.

Project layout

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

Documentation

Limitations

  • Shell commands: files touched by 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.
  • Changes made outside Claude: they show up too, animated as if Claude had made them.
  • Skipped folders: node_modules, .git, dist, build, caches and virtual environments are left out of the tree.
  • Size limit: a scan covers at most 2,000 entries, 12 levels deep.
  • Colours: tuned for dark terminal themes.

License

MIT © 2026 Grzegorz Sierocki

Source 3 files
hooks/register.tsx 393 lines
1import { 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}
393
hooks/model.ts 706 lines
1// 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}
706
types/index.d.ts 36 lines
1export 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