A live file tree beside the chat that shows where Claude has been.

<img src="../../brand/pixel-icons/svg/footprints.svg" width="64" height="64" alt="">
A live file tree beside the chat that shows where Claude has been.
/plugin install footprints --marketplace modmyclaude/claude-code-mods
/footprints opens a pane with the project's file tree: docked beside the transcript in fullscreen, above the prompt otherwise. Because you asked, it opens at any terminal width, and it works while Claude is mid-turn. It adds nothing to the transcript (the model never reads it); the pane, or a toast, is the answer.git commitread 12 · edited 4 · new 1, plus deleted and committed once there are any. New, deleted and committed files are counted in edited too.t while the pane has the keys (click it, or ctrl+x tab), or type /footprints touched. Every folder Claude did not touch collapses, and untouched files fold into one + N untouched files line. A project with more than 50 folders opens this way, with a note under the header saying so; one press shows the whole tree, and your choice then stands for the session.edited 4 under the prompt, so you see activity with the pane closed.outside <folder>./clear starts a clean trail.None.
git ls-files (or a folder listing outside git). Around a Bash call it also reads the modification time of each file git reports as changed (up to 400, the ones the call moved first). It also reads the project folder's real path, and for a Glob or Grep target, whether it is a folder.git rev-parse --show-toplevel --show-prefix to find the repogit ls-files -z --cached --others --exclude-standard -- . ':(exclude,glob)/node_modules/' when you open the pane, and after a commit or checkout while it is opengit --no-optional-locks status --porcelain=v2 ... -- ':/' ':(top,exclude,glob)/node_modules/' before and after each Bash call, pane open or not (the "after" is skipped when the call was read-only). That is two runs per Bash call: tens of milliseconds in most repos, about 0.3 s together in a 100,000-file one. If one run takes over 2 seconds or times out, shell tracking pauses and the transcript says so once: Footprints: shell tracking paused, git status is slow in this repo. Read and edit tracking keep working, and running /footprints again turns shell tracking back on.git status is slow (over 2 s), shell tracking pauses for the session.node_modules is always skipped, even where .gitignore does not list it, so files Claude reads or writes there show only when touched by an edit tool or Read.node_modules, .git and a few cache folders, without .gitignore), but Bash changes are not marked.git status before and after the call plus file times, so: a branch switch that leaves git clean on both sides is not marked; a file you save in your own editor while a Bash command runs can be counted as changed by the shell; changes made by a background Bash command are not marked. With more than 400 files dirty, a file that was already dirty and is changed again may be missed.git commit and moves HEAD. Commits you make in your own terminal are not seen.cat, rg) do not turn blue, and neither does a file you @-mention until Claude reads it with Read; only Read, Glob and Grep count. Native macOS and Linux builds of Claude Code have no Glob or Grep tools, so folder marks mostly come from builds that do.t works only while the pane holds the keyboard; from the prompt, use /footprints touched.� (git's file lists are read NUL-separated, so its path quoting never applies).suggestion, warning and success.More mods: the full catalogue.
hooks/register.tsx 654 lines1// footprints: a live file tree beside the chat that shows where Claude has been.
2//
3// Hooks watch what Claude reads (Read, Glob, Grep, @-mentions), edits (Edit,
4// Write, MultiEdit, NotebookEdit) and changes from the shell (Bash, checked
5// with `git status` before and after). The marks live in $.state; a Pane
6// draws them over the project's file list, and the status line counts edits.
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, Register, Timer, ToolCallResult } from 'claude-code'
9
10import type { FootprintsMark as Mark, FootprintsTree as Tree } from '../types'
11import { boltSprite } from './bolt-draw'
12import {
13 applyShell,
14 BIG_FOLDER,
15 BIG_TREE_FOLDERS,
16 buildRows,
17 clean,
18 countMarks,
19 endEdit,
20 folderCount,
21 HEADER_SEPARATOR,
22 headerParts,
23 isEditing,
24 isTouched,
25 looksLikeCommit,
26 markRead,
27 MAX_FILES,
28 MAX_ROWS,
29 MAX_STATS,
30 MAX_WALK_DIRS,
31 normalize,
32 orNothing,
33 parseNulList,
34 parsePorcelain,
35 relativeTo,
36 settleEdits,
37 shellChanges,
38 shimmerParts,
39 SKIP_DIRS,
40 startEdit,
41 statOrder,
42 statusText,
43 toAbs,
44 type Counts,
45 type HeaderPart,
46 type Porcelain,
47 type Repo,
48 type Row,
49 type Stat,
50} from './model'
51
52type Engine = EngineInterface
53
54const PANE = 'footprints'
55const SHIMMER_MS = 120
56/** A git snapshot slower than this pauses shell tracking for the session. */
57const SLOW_GIT_MS = 2000
58const SLOW_GIT_LINE = 'Footprints: shell tracking paused, git status is slow in this repo'
59const MAX_OUTSIDE = 50
60/** Narrower than this, the pane keeps every column for the tree and Bolt steps out. */
61const BOLT_MIN_COLUMNS = 40
62
63const MARKS = { plugin: 'footprints', key: 'marks' } as const
64const TREE = { plugin: 'footprints', key: 'tree' } as const
65const marksAtom = atom(MARKS, {})
66const touchedOnlyAtom = atom({ plugin: 'footprints', key: 'touchedOnly' } as const, null as boolean | null)
67
68/** Whether a tree is big, remembered by the state version it came with. */
69let bigTree: { version: unknown; isBig: boolean; folders: number } | null = null
70function foldersOf(tree: Tree | null, version: unknown): number {
71 if (tree === null) return 0
72 if (bigTree === null || bigTree.version !== version) {
73 const folders = folderCount(tree.files)
74 bigTree = { version, isBig: folders > BIG_TREE_FOLDERS, folders }
75 }
76 return bigTree.folders
77}
78
79/** Touched-only as it stands: the person's choice, else on for a big tree (over BIG_TREE_FOLDERS folders). */
80function touchedOnlyFor(chosen: boolean | null, tree: Tree | null, version: unknown): boolean {
81 return chosen ?? (tree !== null && foldersOf(tree, version) > BIG_TREE_FOLDERS)
82}
83const phaseAtom = atom({ plugin: 'footprints', key: 'phase' } as const, 0)
84const SHELL_PAUSED = { plugin: 'footprints', key: 'shellPaused' } as const
85const shellPausedAtom = atom(SHELL_PAUSED, false)
86
87// node_modules is skipped even where .gitignore does not list it: git prints untracked names
88// first, so one unignored node_modules would flood the name cap (git never lists .git).
89const NO_NODE_MODULES_HERE = ':(exclude,glob)**/node_modules/**'
90const NO_NODE_MODULES_ANYWHERE = ':(top,exclude,glob)**/node_modules/**'
91
92const READ_TOOLS = new Set(['Read', 'Glob', 'Grep'])
93const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
94
95// Module state a reload may lose without harm: the shimmer's timer, the last
96// status text sent, each root's real path, and the drawn rows by state version.
97let shimmer: Timer | undefined
98let lastStatus: string | undefined | null = null
99const realRoots = new Map<string, string>()
100const repos = new Map<string, Repo | null>() // per root; null = not a git work tree
101let cache: { key: string; view: View } | undefined
102
103// Bookkeeping never gets in the way of a tool call: a failure here is dropped.
104const quietly = <T,>(work: Promise<T>): Promise<T | undefined> => work.catch(() => undefined)
105
106const succeeded = (ran: ToolCallResult): boolean => ran.deny === undefined && ran.isError !== true
107
108/** One field of a tool's record, when it is there (records differ per tool and build). */
109const field = (value: unknown, name: string): unknown =>
110 typeof value === 'object' && value !== null ? (value as Record<string, unknown>)[name] : undefined
111
112// ---------------------------------------------------------------- where we are
113
114/** The session's project root, and its real path when a link leads there (macOS /tmp). */
115async function rootsOf($: Engine): Promise<{ root: string; realRoot: string }> {
116 const root = normalize(await $.session.root(), '/')
117 let realRoot = realRoots.get(root)
118 if (realRoot === undefined) {
119 realRoot = (await $.fs.stat(root, { resolve: true }).catch(() => undefined))?.realPath ?? root
120 realRoots.set(root, realRoot)
121 }
122 return { root, realRoot }
123}
124
125/** An absolute path in the root's own spelling, so one file has one mark. */
126async function canonical($: Engine, path: string): Promise<string> {
127 const abs = normalize(path, path.startsWith('/') ? '/' : await $.session.cwd())
128 const { root, realRoot } = await rootsOf($)
129 if (realRoot === root || relativeTo(abs, [root]) !== undefined) return abs
130 const rel = relativeTo(abs, [realRoot])
131 return rel === undefined ? abs : rel === '' ? root : `${root}/${rel}`
132}
133
134// ---------------------------------------------------------------- marks and status
135
136function refreshStatus($: Engine, marks: Record<string, Mark>): void {
137 const text = statusText(countMarks(marks))
138 if (text === lastStatus) return
139 lastStatus = text
140 $.ui.status(text === undefined ? undefined : clean(text))
141}
142
143async function changeMark($: Engine, path: string, change: (mark: Mark | undefined) => Mark): Promise<void> {
144 const marks = await update($, marksAtom, current => {
145 const next = { ...current }
146 const mark = orNothing(change(next[path]))
147 if (mark) next[path] = mark
148 else delete next[path]
149 return next
150 })
151 refreshStatus($, marks)
152}
153
154async function recordRead($: Engine, tool: string, target: string): Promise<void> {
155 const path = await canonical($, target)
156 if (path === (await rootsOf($)).root) return // searching the whole project marks nothing
157 // Glob and Grep take a folder or a file; only the kind is looked up, never the contents.
158 const isDir = tool !== 'Read' && (await $.fs.stat(path).catch(() => undefined))?.kind === 'dir'
159 await changeMark($, path, mark => markRead(mark, isDir))
160}
161
162// ---------------------------------------------------------------- shimmer
163
164function startShimmer($: Engine): void {
165 try {
166 shimmer ??= $.clock.every(SHIMMER_MS, () => void quietly(tick($)))
167 } catch {
168 // no animation is better than a stuck edit mark
169 }
170}
171
172function stopShimmer(): void {
173 shimmer?.cancel()
174 shimmer = undefined
175}
176
177/** One frame: the only place the timer stops, so a call that never reported back cannot leave it running. */
178async function tick($: Engine): Promise<void> {
179 if (!Object.values(await read($, marksAtom)).some(isEditing)) return stopShimmer()
180 // Frames matter only to a pane on screen: stop while it is closed or behind a tab (opening it starts them again).
181 const isShown = (await $.ui.panes()).some(pane => pane.id === PANE && pane.isShown)
182 if (!isShown) return stopShimmer()
183 await update($, phaseAtom, phase => (phase + 1) % 10000)
184}
185
186// ---------------------------------------------------------------- git and the disk
187// The only places footprints looks at the disk: read-only git commands, folder
188// listings and stats. Names and timestamps only; no file's contents are read.
189
190async function repoOf($: Engine, root: string): Promise<Repo | null> {
191 const known = repos.get(root)
192 if (known !== undefined) return known
193 const out = await $.process
194 .run(['git', '-C', root, 'rev-parse', '--show-toplevel', '--show-prefix'], { timeoutMs: 5000 })
195 .catch(() => undefined)
196 const [top, prefix = ''] = out?.exitCode === 0 ? out.stdout.split('\n') : []
197 const repo = top ? { root, top, prefix } : null
198 repos.set(root, repo)
199 return repo
200}
201
202/** Read-only, and without the index lock, so it never races the model's own git. */
203async function gitStatus($: Engine, repo: Repo): Promise<Porcelain | null> {
204 const out = await $.process.run(
205 [
206 'git', '--no-optional-locks', '-C', repo.root, 'status', '--porcelain=v2', '-z', '--branch',
207 '--untracked-files=all', '--no-renames', '--', ':/', NO_NODE_MODULES_ANYWHERE,
208 ],
209 { timeoutMs: SLOW_GIT_MS + 1000 },
210 )
211 return out.exitCode === 0 && !out.isStdoutTruncated ? parsePorcelain(out.stdout) : null
212}
213
214/**
215 * A snapshot on the clock. One that takes over SLOW_GIT_MS, or times out, pauses
216 * shell tracking for the rest of the session, so a big repo never taxes every
217 * Bash call; reads and edits are still tracked.
218 */
219async function timedStatus($: Engine, repo: Repo): Promise<Porcelain | null> {
220 const startedAt = await $.clock.now()
221 const status = await gitStatus($, repo).catch(() => null)
222 if ((await $.clock.now()) - startedAt > SLOW_GIT_MS) await pauseShell($)
223 return status
224}
225
226/** Pauses once, and says so once, however many calls find git slow at the same time. */
227async function pauseShell($: Engine): Promise<void> {
228 const held = await $.state.get(SHELL_PAUSED)
229 if (held.value === true) return
230 const { isSet } = await $.state.set(SHELL_PAUSED, true, { ifVersion: held.version })
231 if (isSet) $.ui.log(SLOW_GIT_LINE)
232}
233
234/** mtime or absence of each path (relative to git's top), at most MAX_STATS of them. */
235async function statAll($: Engine, repo: Repo, paths: readonly string[]): Promise<Record<string, Stat>> {
236 const stats: Record<string, Stat> = {}
237 await Promise.all(
238 paths.slice(0, MAX_STATS).map(async path => {
239 const abs = `${repo.top}/${path}`
240 try {
241 stats[path] = { exists: true, mtimeMs: (await $.fs.stat(abs)).mtimeMs }
242 } catch {
243 // a stat can fail for reasons other than absence; only a confirmed absence counts
244 if (!(await $.fs.exists(abs).catch(() => true))) stats[path] = { exists: false }
245 }
246 }),
247 )
248 return stats
249}
250
251/** The project's files: git's list (tracked + untracked, .gitignore honoured), else a capped walk. */
252async function scanTree($: Engine, root: string, repo: Repo | null): Promise<Tree> {
253 if (repo) {
254 const out = await $.process
255 .run(['git', '-C', root, 'ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', '.', NO_NODE_MODULES_HERE], {
256 timeoutMs: 15000,
257 })
258 .catch(() => undefined)
259 if (out?.exitCode === 0) {
260 const files = parseNulList(out.stdout)
261 const tree: Tree = { root, files: files.slice(0, MAX_FILES), source: 'git', total: files.length }
262 if (out.isStdoutTruncated || files.length > MAX_FILES) {
263 tree.note = `showing ${Math.min(files.length, MAX_FILES)} files of a longer list`
264 }
265 return tree
266 }
267 }
268 return walkTree($, root)
269}
270
271/** Outside git: list folders breadth first, skipping SKIP_DIRS, up to MAX_WALK_DIRS of them. */
272async function walkTree($: Engine, root: string): Promise<Tree> {
273 const files: string[] = []
274 const queue: string[] = ['']
275 let total = 0
276 let listed = 0
277 while (queue.length > 0 && listed < MAX_WALK_DIRS) {
278 const rel = queue.shift() as string
279 listed++
280 const entries = await $.fs.list(rel === '' ? root : `${root}/${rel}`).catch(() => [])
281 for (const entry of entries) {
282 const path = rel === '' ? entry.name : `${rel}/${entry.name}`
283 if (entry.kind === 'dir') {
284 if (!SKIP_DIRS.has(entry.name)) queue.push(path)
285 } else {
286 total++
287 if (files.length < MAX_FILES) files.push(path)
288 }
289 }
290 }
291 const tree: Tree = { root, files, source: 'walk', total }
292 if (queue.length > 0) tree.note = `listed the first ${MAX_WALK_DIRS} folders only`
293 else if (total > MAX_FILES) tree.note = `showing ${MAX_FILES} of ${total} files`
294 return tree
295}
296
297// ---------------------------------------------------------------- the tree
298
299async function paneIsOpen($: Engine): Promise<boolean> {
300 return (await $.ui.panes()).some(pane => pane.id === PANE)
301}
302
303async function rescan($: Engine): Promise<void> {
304 repos.clear() // a `git init` since the last look counts
305 const { root, realRoot } = await rootsOf($)
306 const tree = await scanTree($, root, await repoOf($, root))
307 if (realRoot !== root) tree.realRoot = realRoot
308 await $.state.set(TREE, tree)
309}
310
311async function openPane($: Engine): Promise<boolean> {
312 const opened = await $.ui.open({ id: PANE, title: 'Footprints' })
313 if (!opened.isPlaced) $.ui.toast(clean(`Cannot show its pane here: ${opened.reason}`))
314 if (Object.values(await read($, marksAtom)).some(isEditing)) startShimmer($) // an edit already running shimmers
315 await quietly(rescan($))
316 return opened.isPlaced
317}
318
319// ---------------------------------------------------------------- the shell
320
321type Watch = { repo: Repo; before: Porcelain; startedAt: number }
322
323async function beforeShell($: Engine): Promise<Watch | undefined> {
324 if (await read($, shellPausedAtom)) return undefined
325 const repo = await repoOf($, (await rootsOf($)).root)
326 if (!repo) return undefined
327 const before = await timedStatus($, repo)
328 if (!before || (await read($, shellPausedAtom))) return undefined
329 return { repo, before, startedAt: await $.clock.now() }
330}
331
332async function afterShell($: Engine, watch: Watch, command: string, ran: ToolCallResult): Promise<void> {
333 const { repo, before, startedAt } = watch
334 const after = await timedStatus($, repo)
335 if (!after) return
336 const paths = statOrder(before.dirty, after.dirty) // what the command moved first, so the cap never hides it
337 const changes = Object.entries(shellChanges(before.dirty, after.dirty, await statAll($, repo, paths), startedAt))
338 // A commit moved HEAD; a `git commit` that failed (nothing staged, a hook said no) did not.
339 const isCommit =
340 after.head !== null &&
341 after.head !== before.head &&
342 (looksLikeCommit(command) || field(field(ran.result, 'gitOperation'), 'commit') !== undefined)
343 if (changes.length > 0 || isCommit) {
344 const { root } = await rootsOf($)
345 const topRelative = (abs: string): string | undefined => {
346 const rel = relativeTo(abs, [root])
347 return rel !== undefined ? repo.prefix + rel : relativeTo(abs, [repo.top])
348 }
349 const marks = await update($, marksAtom, current => {
350 const next = { ...current }
351 for (const [path, change] of changes) {
352 const abs = toAbs(repo, path)
353 const mark = orNothing(applyShell(next[abs], change))
354 if (mark) next[abs] = mark
355 else delete next[abs]
356 }
357 if (isCommit) {
358 for (const [abs, mark] of Object.entries(next)) {
359 const rel = topRelative(abs)
360 const isClean = rel !== undefined && after.dirty[rel] === undefined
361 if (mark.edited && isClean && !isEditing(mark)) next[abs] = { ...mark, committed: true }
362 }
363 }
364 return next
365 })
366 refreshStatus($, marks)
367 }
368 // A commit or a checkout can change the file list; keep an open pane honest.
369 if (after.head !== before.head && (await paneIsOpen($))) await rescan($)
370}
371
372// ---------------------------------------------------------------- the pane's model
373
374type View = { counts: Counts; rows: Row[]; outside: [string, Mark][] }
375
376/** Rows and counts for the pane, rebuilt only when the marks, the tree or the mode changed. */
377function viewOf(tree: Tree | null, marks: Record<string, Mark>, touchedOnly: boolean, key: string): View {
378 if (cache?.key === key) return cache.view
379 const roots = tree ? [tree.root, ...(tree.realRoot ? [tree.realRoot] : [])] : []
380 const inside: Record<string, Mark> = {}
381 const outside: [string, Mark][] = []
382 for (const [abs, mark] of Object.entries(marks)) {
383 const rel = relativeTo(abs, roots)
384 if (rel !== undefined) inside[rel] = mark
385 else if (isTouched(mark)) outside.push([abs, mark])
386 }
387 outside.sort(([a], [b]) => (a < b ? -1 : 1))
388 // Touched-only with nothing touched here yet says so, rather than listing collapsed folders.
389 const isBlank = touchedOnly && !Object.values(inside).some(isTouched)
390 const rows = tree && !isBlank ? buildRows(tree.files, inside, { touchedOnly, maxRows: MAX_ROWS, bigFolder: BIG_FOLDER }) : []
391 const view = { counts: countMarks(marks), rows, outside }
392 cache = { key, view }
393 return view
394}
395
396const PART_COLOR: Record<HeaderPart['kind'], string> = {
397 read: 'suggestion',
398 edited: 'warning',
399 new: 'text',
400 deleted: 'inactive',
401 committed: 'success',
402}
403
404// ---------------------------------------------------------------- hooks
405
406export const register: Register = on => {
407 on('session.start', async ($, e, next) => {
408 await $.command.register({
409 name: 'footprints',
410 description: 'Show where Claude has been: a live file tree beside the chat',
411 argumentHint: '[touched]',
412 immediate: true, // opens while Claude works; the handler reads no turn state
413 })
414 // An edit in flight when the module reloaded cannot report back here.
415 const marks = await update($, marksAtom, settleEdits)
416 refreshStatus($, marks)
417 return next(e)
418 })
419
420 on('session.end', async ($, e, next) => {
421 if (e.reason === 'clear') {
422 // A fresh conversation starts with a clean trail.
423 stopShimmer()
424 await $.state.set(MARKS, {})
425 refreshStatus($, {})
426 }
427 return next(e)
428 })
429
430 // Answers {} every time: the pane and a toast are for the person, and the model needs none of it.
431 on('command.run', { command: 'footprints' }, async ($, e) => {
432 const arg = e.args.trim().toLowerCase()
433 if (arg === 'touched') {
434 const treeRead = await $.state.get(TREE)
435 const isOn = await update($, touchedOnlyAtom, value => !touchedOnlyFor(value, treeRead.value ?? null, treeRead.version))
436 $.ui.toast(isOn ? 'Touched files only' : 'All files')
437 if (!(await paneIsOpen($))) await openPane($)
438 } else if (arg !== '') {
439 $.ui.toast('/footprints opens the pane, /footprints touched toggles touched-only')
440 } else {
441 await $.state.set(SHELL_PAUSED, false) // asking again is the person's go-ahead to retry a slow git
442 await openPane($)
443 }
444 return {}
445 }).catch($ => {
446 try {
447 $.ui.toast('Could not open its pane here.')
448 } catch {
449 // nothing more to say where even a toast is refused
450 }
451 return {}
452 })
453
454 // An observer, never a guard: whatever happens in here, the call goes on.
455 on('tool.call', async ($, e, next) => {
456 const tool = String(e.tool) // Glob, Grep and MultiEdit are not in every build's table
457 const args = e as unknown as Record<string, unknown>
458
459 if (READ_TOOLS.has(tool)) {
460 const target = typeof args.file_path === 'string' ? args.file_path : args.path
461 const ran = await next(e)
462 if (typeof target === 'string' && succeeded(ran)) await quietly(recordRead($, tool, target))
463 return ran
464 }
465
466 if (EDIT_TOOLS.has(tool)) {
467 const target = tool === 'NotebookEdit' ? args.notebook_path : args.file_path
468 const path = typeof target === 'string' ? await quietly(canonical($, target)) : undefined
469 if (path === undefined) return next(e)
470 const id = e.tool_use_id
471 await quietly(changeMark($, path, mark => startEdit(mark, id)))
472 startShimmer($)
473 let outcome: 'failed' | 'edited' | 'created' = 'failed'
474 try {
475 const ran = await next(e)
476 if (succeeded(ran) && field(ran.result, 'staged') !== true) {
477 outcome = tool === 'Write' && field(ran.result, 'type') === 'create' ? 'created' : 'edited'
478 }
479 return ran
480 } finally {
481 // the shimmer stops itself on its next frame once no edit is running
482 await quietly(changeMark($, path, mark => endEdit(mark, id, outcome)))
483 }
484 }
485
486 if (tool === 'Bash') {
487 const command = typeof args.command === 'string' ? args.command : ''
488 const watch = await quietly(beforeShell($))
489 const ran = await next(e)
490 // A denied call ran nothing; one the tool held read-only changed nothing.
491 if (watch && ran.deny === undefined && ran.isReadOnly !== true) {
492 await quietly(afterShell($, watch, command, ran))
493 }
494 return ran
495 }
496
497 return next(e)
498 }).catch(($, e, next) => next(e))
499
500 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
501 const { Box, Text, Button } = $.ui.resolve(e)
502 const [marksRead, treeRead, chosen, phase] = await Promise.all([
503 $.state.get(MARKS),
504 $.state.get(TREE),
505 read($, touchedOnlyAtom),
506 read($, phaseAtom),
507 ])
508 const tree = treeRead.value ?? null
509 // A big tree opens touched-only until the person chooses; the note under the controls says so.
510 const touchedOnly = touchedOnlyFor(chosen, tree, treeRead.version)
511 const isDefaultTouched = chosen === null && touchedOnly
512 const view = viewOf(tree, marksRead.value ?? {}, touchedOnly, `${marksRead.version}:${treeRead.version}:${touchedOnly}`)
513
514 /** A name styled by what Claude did to it: blue read, amber edited (shimmering while it runs), green committed. */
515 // Every name and path is cleaned where it is drawn: one control character would blank the pane.
516 const name = (raw: string, mark: Mark | undefined, isUntouched: boolean) => {
517 const label = clean(raw)
518 if (isEditing(mark)) {
519 const [head, band, tail] = shimmerParts(label, phase)
520 return (
521 <Text color="warning">
522 <Text dimColor>{head}</Text>
523 <Text bold>{band}</Text>
524 <Text dimColor>{tail}</Text>
525 </Text>
526 )
527 }
528 if (mark?.deleted) return <Text strikethrough dimColor>{label}</Text>
529 if (mark?.committed) return <Text color="success">{label}</Text>
530 if (mark?.edited) return <Text color="warning">{label}</Text>
531 if (mark?.read) return <Text color="suggestion">{label}</Text>
532 return <Text dimColor={isUntouched}>{label}</Text>
533 }
534
535 const tags = (mark: Mark | undefined) => (
536 <Text>
537 {mark?.isNew ? <Text bold> new</Text> : ''}
538 {mark?.viaShell ? <Text dimColor> (shell)</Text> : ''}
539 </Text>
540 )
541
542 const drawRow = (row: Row) => {
543 if (row.kind === 'cut') {
544 return (
545 <Box key="cut">
546 <Text dimColor>{`... ${row.count} more rows not shown${touchedOnly ? '' : ' - press t for touched only'}`}</Text>
547 </Box>
548 )
549 }
550 const indent = ' '.repeat(row.depth)
551 if (row.kind === 'hidden') {
552 return (
553 <Box>
554 <Text dimColor wrap="truncate-end">{`${indent} + ${row.count} untouched file${row.count === 1 ? '' : 's'}`}</Text>
555 </Box>
556 )
557 }
558 if (row.kind === 'dir') {
559 return (
560 <Box key={`d:${clean(row.path)}`}>
561 <Text wrap="truncate-end" bold={row.isTouched} dimColor={!row.isTouched}>
562 {`${indent}${row.isOpen ? '▾' : '▸'} `}
563 {name(`${row.name}/`, row.mark, !row.isTouched)}
564 {row.isOpen ? '' : <Text dimColor>{` ${row.files}`}</Text>}
565 </Text>
566 </Box>
567 )
568 }
569 return (
570 <Box key={`f:${clean(row.path)}`}>
571 <Text wrap="truncate-end">
572 {`${indent} `}
573 {name(row.name, row.mark, !isTouched(row.mark))}
574 {tags(row.mark)}
575 </Text>
576 </Box>
577 )
578 }
579
580 const rootName = clean(tree ? (tree.root.split('/').filter(Boolean).pop() ?? tree.root) : '')
581 // Bolt waits in the touched-only view while nothing is touched: a static
582 // sprite beside that line alone, only where the pane has room for it.
583 const roomy = e.props.bodyColumns >= BOLT_MIN_COLUMNS
584
585 return (
586 <Box flexDirection="column">
587 <Box key="header">
588 <Text wrap="truncate-end">
589 {headerParts(view.counts).map((part, at) => (
590 <Text>
591 {at > 0 ? <Text dimColor>{HEADER_SEPARATOR}</Text> : ''}
592 <Text color={PART_COLOR[part.kind]}>{part.text}</Text>
593 </Text>
594 ))}
595 </Text>
596 </Box>
597 <Box key="controls" flexDirection="row" gap={2}>
598 <Button
599 key="touched"
600 hotkey="t"
601 plain
602 label={touchedOnly ? 'show all files' : 'touched only'}
603 onPress={() => update($, touchedOnlyAtom, value => !touchedOnlyFor(value, tree, treeRead.version))}
604 />
605 {tree ? (
606 <Text dimColor wrap="truncate-end">{`${rootName}${tree.source === 'walk' ? ' (not a git repo)' : ''}`}</Text>
607 ) : (
608 ''
609 )}
610 </Box>
611 {isDefaultTouched ? (
612 <Box key="big">
613 <Text dimColor wrap="truncate-end">{`touched only, since this project has ${foldersOf(tree, treeRead.version)} folders · t or /footprints touched shows all files`}</Text>
614 </Box>
615 ) : (
616 ''
617 )}
618 {tree?.note ? (
619 <Box key="note">
620 <Text dimColor wrap="truncate-end">{clean(tree.note)}</Text>
621 </Box>
622 ) : (
623 ''
624 )}
625 {view.rows.length === 0 ? (
626 <Box key="empty" flexDirection="row" gap={2} alignItems="center">
627 {tree && touchedOnly && roomy ? boltSprite($.ui.resolve(e), e.surface, { size: 'tiny', pose: 'idle' }) : null}
628 <Text dimColor>{!tree ? 'Reading the file list...' : touchedOnly ? 'Nothing touched yet.' : 'No files here.'}</Text>
629 </Box>
630 ) : (
631 ''
632 )}
633 {view.rows.map(drawRow)}
634 {view.outside.length > 0 ? (
635 <Box key="outside" flexDirection="column" marginTop={1}>
636 <Text dimColor>{tree ? `outside ${rootName}` : 'touched so far'}</Text>
637 {view.outside.slice(0, MAX_OUTSIDE).map(([abs, mark]) => (
638 <Box key={`o:${clean(abs)}`}>
639 <Text wrap="truncate-start">
640 {name(abs, mark, false)}
641 {tags(mark)}
642 </Text>
643 </Box>
644 ))}
645 {view.outside.length > MAX_OUTSIDE ? <Text dimColor>{`... ${view.outside.length - MAX_OUTSIDE} more`}</Text> : ''}
646 </Box>
647 ) : (
648 ''
649 )}
650 </Box>
651 )
652 })
653}
654hooks/bolt-draw.tsx 86 lines1// Draws Bolt, the modmyclaude mascot (brand/bolt-sprites), on the surface a hook draws for.
2// Same file in every mod that shows Bolt: mods install on their own, so they cannot share code.
3// Terminal: one Raster, a cell grid. Desktop: one code-font cell per character, since its Text is
4// proportional and a row of half-blocks would drift. Elsewhere: coloured Text runs.
5// Bolt is static here: no timer, no redraw of its own. Never draw it over a safety hold or in a toast.
6
7import type { BoxProps, ElementConstructor, RasterProps, RenderElement, TextProps } from 'claude-code'
8
9import { boltCells, boltRuns } from './bolt'
10import type { BoltCell, BoltSmallPose, BoltTinyPose } from './bolt'
11
12type Elements = {
13 Box: ElementConstructor<BoxProps>
14 Text: ElementConstructor<TextProps>
15 Raster?: ElementConstructor<RasterProps>
16}
17
18export type BoltPick = { size: 'small'; pose: BoltSmallPose } | { size: 'tiny'; pose: BoltTinyPose }
19
20/** Columns a pose takes: small is 10, tiny is 3. */
21export const BOLT_COLUMNS = { small: 10, tiny: 3 } as const
22
23/** The cells of a pose: half-blocks, two pixel rows per cell. */
24export function boltOf(pick: BoltPick): BoltCell[][] {
25 return pick.size === 'small' ? boltCells('small', pick.pose) : boltCells('tiny', pick.pose)
26}
27
28/** Bolt as one element under `key`, drawn the way `surface` lays out cells. */
29export function boltSprite(elements: Elements, surface: string, pick: BoltPick, key = 'bolt'): RenderElement {
30 const { Box, Text, Raster } = elements
31 const cells = boltOf(pick)
32 const columns = cells[0]?.length ?? 0
33 if (surface === 'terminal' && Raster !== undefined) {
34 return <Raster key={key} columns={columns} rows={cells.length} cells={rasterCells(cells)} />
35 }
36 const paint = (cell: BoltCell): TextProps => ({
37 ...(cell.fg !== undefined ? { color: cell.fg } : {}),
38 ...(cell.bg !== undefined ? { backgroundColor: cell.bg } : {}),
39 })
40 if (surface === 'desktop') {
41 return (
42 <Box key={key} flexDirection="column" flexShrink={0}>
43 {cells.map(line => (
44 <Box flexDirection="row" flexWrap="nowrap">
45 {line.map(cell =>
46 cell.fg === undefined && cell.bg === undefined ? <Box width={1} /> : <Box width={1}><Text {...paint(cell)}>{cell.ch}</Text></Box>,
47 )}
48 </Box>
49 ))}
50 </Box>
51 )
52 }
53 return (
54 <Box key={key} flexDirection="column" flexShrink={0}>
55 {cells.map(line => (
56 <Text>
57 {boltRuns(line).map(run => {
58 const text = run.ch.replace(/ /g, ' ')
59 return run.fg === undefined && run.bg === undefined ? text : <Text {...paint(run)}>{text}</Text>
60 })}
61 </Text>
62 ))}
63 </Box>
64 )
65}
66
67/** RasterProps.cells: standard base64 of little-endian u32 [codePoint, fg, bg] per cell, row-major. */
68export function rasterCells(cells: readonly (readonly BoltCell[])[]): string {
69 const DEFAULT = 0x01000000
70 const count = cells.reduce((sum, line) => sum + line.length, 0)
71 const view = new DataView(new ArrayBuffer(count * 12))
72 let at = 0
73 for (const line of cells) {
74 for (const cell of line) {
75 view.setUint32(at, cell.ch.codePointAt(0) ?? 32, true)
76 view.setUint32(at + 4, cell.fg === undefined ? DEFAULT : parseInt(cell.fg.slice(1), 16), true)
77 view.setUint32(at + 8, cell.bg === undefined ? DEFAULT : parseInt(cell.bg.slice(1), 16), true)
78 at += 12
79 }
80 }
81 let binary = ''
82 const bytes = new Uint8Array(view.buffer)
83 for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i] ?? 0)
84 return btoa(binary)
85}
86hooks/model.ts 398 lines1// Pure logic for footprints: paths, git output, mark transitions, counts and
2// the tree's rows. Nothing here touches `$`, so every rule is unit-tested.
3import type { FootprintsMark as Mark } from '../types'
4
5/** Rows the pane draws at most; the rest is one "more" line. */
6export const MAX_ROWS = 400
7/** An untouched folder with more files than this starts collapsed. */
8export const BIG_FOLDER = 40
9/** File systems with 1 s timestamps can stamp a change up to 1 s early. */
10export const MTIME_SLACK_MS = 1000
11/** Files kept from a scan; the rest is counted, not stored. */
12export const MAX_FILES = 20000
13/** Folders a walk outside git lists before it stops. */
14export const MAX_WALK_DIRS = 400
15/** Over this many folders a project opens in touched-only view, the full tree one press away. */
16export const BIG_TREE_FOLDERS = 50
17/** Dirty paths a Bash call stats at most. */
18export const MAX_STATS = 400
19/** Skipped by the walk outside git, where there is no .gitignore to ask. */
20export const SKIP_DIRS: ReadonlySet<string> = new Set(['node_modules', '.git', '.hg', '.svn', '__pycache__', '.venv', '.next', '.cache'])
21
22// ---------------------------------------------------------------- paths
23
24/** An absolute, '/'-separated path with no '.', '..' or trailing slash. */
25export function normalize(path: string, cwd: string): string {
26 const joined = path.startsWith('/') ? path : `${cwd}/${path}`
27 const parts: string[] = []
28 for (const part of joined.split('/')) {
29 if (part === '' || part === '.') continue
30 if (part === '..') parts.pop()
31 else parts.push(part)
32 }
33 return `/${parts.join('/')}`
34}
35
36/** `abs` relative to the first root it lies under ('' for a root itself). */
37export function relativeTo(abs: string, roots: readonly string[]): string | undefined {
38 for (const root of roots) {
39 if (abs === root) return ''
40 const base = root.endsWith('/') ? root : `${root}/`
41 if (abs.startsWith(base)) return abs.slice(base.length)
42 }
43 return undefined
44}
45
46// ---------------------------------------------------------------- drawing
47
48/**
49 * Text safe to draw. A file name may legally hold control characters, and the
50 * engine refuses a whole tree that carries one, so ANSI sequences are dropped
51 * and every other C0, DEL or C1 character is shown as U+FFFD.
52 */
53export function clean(text: string): string {
54 return text
55 .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '') // CSI: colors, cursor moves
56 .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') // OSC: titles, links
57 .replace(/[\x00-\x1f\x7f-\x9f]/g, '\uFFFD')
58}
59
60// ---------------------------------------------------------------- git output
61
62/** The project root inside a git work tree: git's top and the root's prefix under it. */
63export type Repo = { root: string; top: string; prefix: string }
64
65/** A path git printed (relative to its top) as an absolute path, in the root's spelling when under it. */
66export function toAbs(repo: Repo, path: string): string {
67 return path.startsWith(repo.prefix) ? `${repo.root}/${path.slice(repo.prefix.length)}` : `${repo.top}/${path}`
68}
69
70/** What `git status --porcelain=v2 -z --branch` says, paths relative to the top. */
71export type Porcelain = {
72 /** The commit HEAD names; null before the first commit. */
73 head: string | null
74 /** Each dirty path's two-letter XY code, `??` for an untracked file. */
75 dirty: Record<string, string>
76}
77
78export function parsePorcelain(stdout: string): Porcelain {
79 let head: string | null = null
80 const dirty: Record<string, string> = {}
81 const entries = stdout.split('\0')
82 for (let i = 0; i < entries.length; i++) {
83 const entry = entries[i] ?? ''
84 const kind = entry.slice(0, 2)
85 if (entry.startsWith('# branch.oid ')) {
86 const oid = entry.slice('# branch.oid '.length)
87 head = oid === '(initial)' ? null : oid
88 } else if (kind === '1 ' || kind === 'u ' || kind === '2 ') {
89 // ordinary, unmerged and renamed entries carry 8, 10 and 9 fields before the path
90 const path = afterFields(entry, kind === '1 ' ? 8 : kind === 'u ' ? 10 : 9)
91 if (path !== undefined) dirty[path] = entry.slice(2, 4)
92 if (kind === '2 ') i++ // a rename's original path follows as its own entry
93 } else if (kind === '? ') {
94 dirty[entry.slice(2)] = '??'
95 }
96 }
97 return { head, dirty }
98}
99
100/** What follows the first `n` space-separated fields (a path may hold spaces). */
101function afterFields(entry: string, n: number): string | undefined {
102 let at = 0
103 for (let i = 0; i < n; i++) {
104 at = entry.indexOf(' ', at) + 1
105 if (at === 0) return undefined
106 }
107 return entry.slice(at)
108}
109
110/** The NUL-separated paths `git ls-files -z` prints, each once. */
111export function parseNulList(stdout: string): string[] {
112 return [...new Set(stdout.split('\0').filter(path => path !== ''))]
113}
114
115/** True when a simple command of the line runs `git ... commit`. */
116export function looksLikeCommit(command: string): boolean {
117 return command.split(/[;&|\n()]+/).some(part => {
118 const words = part.trim().split(/\s+/)
119 const git = words.findIndex(word => word === 'git' || word.endsWith('/git'))
120 return git >= 0 && words.slice(git + 1).includes('commit')
121 })
122}
123
124// ---------------------------------------------------------------- shell changes
125
126export type ShellChange = 'edited' | 'new' | 'deleted'
127export type Stat = { exists: false } | { exists: true; mtimeMs: number }
128
129/**
130 * Which paths a Bash command changed, from git's dirty lists before and after
131 * it and each path's stat after it. Paths are relative to the git top.
132 *
133 * A file that still exists counts only when its mtime falls inside the
134 * command's run: staging, committing or a soft reset moves a path between
135 * git's lists without touching the file, and that is not a change.
136 */
137export function shellChanges(
138 before: Record<string, string>,
139 after: Record<string, string>,
140 stats: Record<string, Stat>,
141 startedAt: number,
142): Record<string, ShellChange> {
143 const changes: Record<string, ShellChange> = {}
144 for (const path of new Set([...Object.keys(before), ...Object.keys(after)])) {
145 const stat = stats[path]
146 if (stat === undefined) continue // not looked at (past the stat cap)
147 const was = before[path]
148 const is = after[path]
149 if (!stat.exists) {
150 // gone now: news only if it was still there when the command started
151 if (was === undefined || !was.includes('D')) changes[path] = 'deleted'
152 continue
153 }
154 const appeared = was === undefined && is !== undefined && (is === '??' || is[0] === 'A')
155 if (stat.mtimeMs >= startedAt - MTIME_SLACK_MS) changes[path] = appeared ? 'new' : 'edited'
156 else if (appeared) changes[path] = 'new' // a move (git mv) keeps the old mtime
157 }
158 return changes
159}
160
161/**
162 * The paths to stat after a Bash call: first those whose git status moved
163 * (appeared, cleared or changed code), then the ones dirty and unchanged, so a
164 * tree with hundreds of dirty files never pushes the command's own work past
165 * the stat cap.
166 */
167export function statOrder(before: Record<string, string>, after: Record<string, string>): string[] {
168 const all = [...new Set([...Object.keys(before), ...Object.keys(after)])]
169 return [...all.filter(path => before[path] !== after[path]), ...all.filter(path => before[path] === after[path])]
170}
171
172// ---------------------------------------------------------------- marks
173
174/** True for a mark that says anything at all. */
175export function isTouched(mark: Mark | undefined): boolean {
176 return mark !== undefined && Object.keys(mark).some(key => key !== 'isDir')
177}
178
179/** Undefined for an empty mark, so the store keeps no "untouched" entries. */
180export function orNothing(mark: Mark): Mark | undefined {
181 return isTouched(mark) ? mark : undefined
182}
183
184export function markRead(mark: Mark | undefined, isDir: boolean): Mark {
185 return isDir ? { ...mark, read: true, isDir: true } : { ...mark, read: true }
186}
187
188export function startEdit(mark: Mark | undefined, id: string): Mark {
189 return { ...mark, editing: [...(mark?.editing ?? []).filter(other => other !== id), id] }
190}
191
192/** An edit call ended: `failed` (denied, errored, held for review), or it wrote. */
193export function endEdit(mark: Mark | undefined, id: string, outcome: 'failed' | 'edited' | 'created'): Mark {
194 const next: Mark = { ...mark }
195 const still = (next.editing ?? []).filter(other => other !== id)
196 if (still.length > 0) next.editing = still
197 else delete next.editing
198 if (outcome !== 'failed') {
199 next.edited = true
200 delete next.committed
201 delete next.deleted
202 delete next.viaShell // an edit tool touched it, so it is no longer shell-only
203 if (outcome === 'created') next.isNew = true
204 }
205 return next
206}
207
208/** A shell change on a mark; a file created this session and then deleted leaves no trace (read aside). */
209export function applyShell(mark: Mark | undefined, change: ShellChange): Mark {
210 if (change === 'deleted' && mark?.isNew) return mark.read ? { read: true } : {}
211 const next: Mark = { ...mark }
212 const isShellOnly = next.edited !== true || next.viaShell === true
213 next.edited = true
214 delete next.committed
215 if (change === 'deleted') next.deleted = true
216 else delete next.deleted
217 if (change === 'new') next.isNew = true
218 if (isShellOnly) next.viaShell = true
219 return next
220}
221
222/** Drops every in-flight edit (a reload cannot finish the old module's calls). */
223export function settleEdits(marks: Record<string, Mark>): Record<string, Mark> {
224 const next: Record<string, Mark> = {}
225 for (const [path, mark] of Object.entries(marks)) {
226 const { editing: _dropped, ...rest } = mark
227 const kept = orNothing(rest)
228 if (kept) next[path] = kept
229 }
230 return next
231}
232
233export function isEditing(mark: Mark | undefined): boolean {
234 return (mark?.editing?.length ?? 0) > 0
235}
236
237// ---------------------------------------------------------------- counts
238
239export type Counts = { read: number; edited: number; isNew: number; deleted: number; committed: number }
240
241/** Files only (a searched folder is not a file read); new, deleted and committed are kinds of edited. */
242export function countMarks(marks: Record<string, Mark>): Counts {
243 const counts: Counts = { read: 0, edited: 0, isNew: 0, deleted: 0, committed: 0 }
244 for (const mark of Object.values(marks)) {
245 if (mark.isDir) continue
246 if (mark.read) counts.read++
247 if (mark.edited) counts.edited++
248 if (mark.edited && mark.isNew) counts.isNew++
249 if (mark.edited && mark.deleted) counts.deleted++
250 if (mark.edited && mark.committed) counts.committed++
251 }
252 return counts
253}
254
255export type HeaderPart = { kind: 'read' | 'edited' | 'new' | 'deleted' | 'committed'; text: string }
256
257/** "read 12 · edited 4 · new 1", then deleted and committed when there are any. */
258export function headerParts(counts: Counts): HeaderPart[] {
259 const parts: HeaderPart[] = [
260 { kind: 'read', text: `read ${counts.read}` },
261 { kind: 'edited', text: `edited ${counts.edited}` },
262 { kind: 'new', text: `new ${counts.isNew}` },
263 ]
264 if (counts.deleted > 0) parts.push({ kind: 'deleted', text: `deleted ${counts.deleted}` })
265 if (counts.committed > 0) parts.push({ kind: 'committed', text: `committed ${counts.committed}` })
266 return parts
267}
268
269export const HEADER_SEPARATOR = ' · '
270
271export function headerText(counts: Counts): string {
272 return headerParts(counts).map(part => part.text).join(HEADER_SEPARATOR)
273}
274
275/** The status line: the edited count, or nothing before the first edit. */
276export function statusText(counts: Counts): string | undefined {
277 return counts.edited > 0 ? `edited ${counts.edited}` : undefined
278}
279
280// ---------------------------------------------------------------- tree rows
281
282/** How many folders a file list spans: every ancestor of every file, the root itself not counted. */
283export function folderCount(files: readonly string[]): number {
284 const dirs = new Set<string>()
285 for (const path of files) {
286 const parts = path.split('/').filter(part => part !== '')
287 for (let i = 1; i < parts.length; i++) dirs.add(parts.slice(0, i).join('/'))
288 }
289 return dirs.size
290}
291
292export type Row =
293 | { kind: 'dir'; depth: number; name: string; path: string; isOpen: boolean; files: number; isTouched: boolean; mark?: Mark }
294 | { kind: 'file'; depth: number; name: string; path: string; mark?: Mark }
295 | { kind: 'hidden'; depth: number; count: number }
296 | { kind: 'cut'; count: number }
297
298export type Layout = { touchedOnly: boolean; maxRows: number; bigFolder: number }
299
300type Dir = { dirs: Map<string, Dir>; files: Set<string>; size: number; isTouched: boolean }
301
302const newDir = (): Dir => ({ dirs: new Map(), files: new Set(), size: 0, isTouched: false })
303
304function dirAt(root: Dir, parts: readonly string[]): Dir {
305 let dir = root
306 for (const part of parts) {
307 let child = dir.dirs.get(part)
308 if (!child) {
309 child = newDir()
310 dir.dirs.set(part, child)
311 }
312 dir = child
313 }
314 return dir
315}
316
317const byName = (a: string, b: string): number => {
318 const x = a.toLowerCase()
319 const y = b.toLowerCase()
320 return x < y ? -1 : x > y ? 1 : a < b ? -1 : a > b ? 1 : 0
321}
322
323/**
324 * The rows of the tree: folders first, then files, each sorted by name.
325 *
326 * `marks` is keyed by path relative to the root. A touched folder (it holds a
327 * touched file, or was searched) is always open. Otherwise a folder is open
328 * when it is small, and in touched-only mode never; touched-only mode also
329 * folds each folder's untouched files into one "hidden" row.
330 */
331export function buildRows(files: readonly string[], marks: Readonly<Record<string, Mark>>, layout: Layout): Row[] {
332 const root = newDir()
333 const addFile = (path: string): void => {
334 const parts = path.split('/').filter(part => part !== '')
335 const name = parts.pop()
336 if (name !== undefined) dirAt(root, parts).files.add(name)
337 }
338 for (const path of files) addFile(path)
339 for (const [path, mark] of Object.entries(marks)) {
340 if (path === '') continue
341 if (mark.isDir) dirAt(root, path.split('/').filter(part => part !== ''))
342 else addFile(path) // a touched file shows even where the scan did not list it
343 }
344
345 const tally = (dir: Dir, prefix: string): void => {
346 dir.size = dir.files.size
347 dir.isTouched = isTouched(marks[prefix.slice(0, -1)])
348 for (const name of dir.files) if (isTouched(marks[prefix + name])) dir.isTouched = true
349 for (const [name, child] of dir.dirs) {
350 tally(child, `${prefix}${name}/`)
351 dir.size += child.size
352 if (child.isTouched) dir.isTouched = true
353 }
354 }
355 tally(root, '')
356
357 const rows: Row[] = []
358 let cut = 0
359 const push = (row: Row): void => {
360 if (rows.length < layout.maxRows) rows.push(row)
361 else cut++
362 }
363 const walk = (dir: Dir, prefix: string, depth: number): void => {
364 for (const name of [...dir.dirs.keys()].sort(byName)) {
365 const child = dir.dirs.get(name) as Dir
366 const path = prefix + name
367 const isOpen = child.isTouched || (!layout.touchedOnly && child.size <= layout.bigFolder)
368 const mark = marks[path]
369 push({ kind: 'dir', depth, name, path, isOpen, files: child.size, isTouched: child.isTouched, ...(mark ? { mark } : {}) })
370 if (isOpen) walk(child, `${path}/`, depth + 1)
371 }
372 let hidden = 0
373 for (const name of [...dir.files].sort(byName)) {
374 const path = prefix + name
375 const mark = marks[path]
376 if (layout.touchedOnly && !isTouched(mark)) {
377 hidden++
378 continue
379 }
380 push({ kind: 'file', depth, name, path, ...(mark ? { mark } : {}) })
381 }
382 if (hidden > 0) push({ kind: 'hidden', depth, count: hidden })
383 }
384 walk(root, '', 0)
385 if (cut > 0) rows.push({ kind: 'cut', count: cut })
386 return rows
387}
388
389// ---------------------------------------------------------------- shimmer
390
391/** The name split around a bright band that slides one cell per frame. */
392export function shimmerParts(name: string, phase: number, band = 3): [string, string, string] {
393 const span = name.length + band
394 const start = Math.max(0, (phase % span) - band)
395 const end = Math.min(name.length, (phase % span))
396 return [name.slice(0, start), name.slice(start, end), name.slice(end)]
397}
398hooks/bolt.ts 109 lines1// Bolt sprites for mods, v1.0.0. GENERATED by brand/bolt-sprites/tools/build.py: edit design.py, not this file.
2// Copy this file into your mod's hooks/ folder (mods install on their own, so they cannot import from brand/).
3// Rules (brand/BRAND.md): pink is the antenna light only, blush the cheeks only. Never recolour, rotate or stretch.
4// Never draw Bolt over a safety hold, and never in a toast (toasts are uncoloured text).
5
6export const BOLT_PALETTE = {
7 B: '#C8F53C',
8 S: '#9BC21A',
9 O: '#FF5C8A',
10 K: '#FF8FB1',
11 E: '#101114',
12 W: '#FBFBF8',
13 G: '#8E95A3',
14} as const
15
16export type BoltSize = 'small' | 'tiny'
17export type BoltSmallPose = 'idle' | 'run1' | 'run2' | 'jump' | 'crash' | 'cheer' | 'sleep' | 'sweat' | 'think' | 'egg' | 'egg-wobble-l' | 'egg-wobble-r' | 'egg-crack-1' | 'egg-crack-2' | 'egg-crack-3' | 'egg-hatch'
18export type BoltTinyPose = 'idle' | 'run1' | 'run2' | 'jump' | 'crash' | 'cheer' | 'sleep'
19export type BoltPose = BoltSmallPose | BoltTinyPose
20
21/** One text cell: a character and its colours (absent = the surface's own). */
22export type BoltCell = { ch: string; fg?: string; bg?: string }
23
24/** Pixel rows: one palette letter per pixel, '.' empty. Heights are even (two pixel rows per cell). */
25export const BOLT_SPRITES = {
26 small: {
27 'idle': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEBBBB', '.SSSSSSSS.', '..SS..SS..'],
28 'run1': ['...OO.....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBWEBBWEB', 'BBBEEBBEEB', 'BKKBBBBBKB', 'BBBBBEEBBB', '.SSSSSSSS.', 'SS......SS'],
29 'run2': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBWEBBWEB', 'BBBEEBBEEB', 'BKKBBBBBKB', 'BBBBBEEBBB', '.SSSSSSSS.', '...SSSS...'],
30 'jump': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEBBBB', '.SSSSSSSS.', '...S..S...'],
31 'crash': ['......OO..', '.....S....', '..BBBBBB..', '.BBBBBBBB.', 'BEBEBBEBEB', 'BBEBBBBEBB', 'BEBEBBEBEB', 'BBBBEEBBBB', '.SSSSSSSS.', 'SS......SS'],
32 'cheer': ['S...OO...S', 'S...SS...S', 'SBBBBBBBBS', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBEEEEBBB', '.SSSSSSSS.', '..SS..SS..'],
33 'sleep': ['....SS....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBBBBBBBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBBBBBBB', '.SSSSSSSS.', '..SS..SS..'],
34 'sweat': ['....OO....', '....SS...G', '..BBBBBB.G', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEEBBB', '.SSSSSSSS.', '..SS..SS..'],
35 'think': ['....OO....', '....SS....', '..BBBBBB..', '.BBWEBWEB.', 'BBBEEBEEBB', 'BBBBBBBBBB', 'BKKBBBBKKB', 'BBBBBEBBBB', '.SSSSSSSS.', '..SS..SS..'],
36 'egg': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
37 'egg-wobble-l': ['...OO.....', '...SS.....', '..BWBB....', '.BWBBBB...', '.BBBBBB...', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
38 'egg-wobble-r': ['.....OO...', '.....SS...', '....BWBB..', '...BWBBBB.', '...BBBBBB.', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
39 'egg-crack-1': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBBBBBB.', '.BBEBBBBS.', '..BBBBBS..', '...SSSS...'],
40 'egg-crack-2': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBEBBBB.', '.BBEBEBBS.', '..BBBBBS..', '...SSSS...'],
41 'egg-crack-3': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBEBEBB.', '.BBEBEBES.', '..BBBBBS..', '...SSSS...'],
42 'egg-hatch': ['......OO..', '......SS..', '....BWBBBB', '....B.B.B.', '..........', '..........', '.B.B.B.BB.', '.BSSSSSSB.', '..BBBBBS..', '...SSSS...'],
43 },
44 tiny: {
45 'idle': ['.O.', 'BEB', 'BBB', 'S.S'],
46 'run1': ['.O.', 'BEB', 'BBB', 'S.S'],
47 'run2': ['.O.', 'BEB', 'BBB', '.S.'],
48 'jump': ['.O.', 'BEB', 'BBB', '.S.'],
49 'crash': ['...', '.SO', 'BEB', 'SSS'],
50 'cheer': ['SOS', 'BEB', 'BBB', 'S.S'],
51 'sleep': ['.S.', 'BSB', 'BBB', 'S.S'],
52 },
53} as const
54
55/** Letters drawn over a whole cell (cell coordinates). */
56const GLYPHS: Record<string, readonly { row: number; col: number; ch: string; color: keyof typeof BOLT_PALETTE }[]> = {
57 'small/sleep': [{ row: 0, col: 8, ch: 'z', color: 'G' }],
58}
59
60/** The two run frames, in order. Swap them about every 120 ms while running. */
61export const BOLT_RUN = ['run1', 'run2'] as const
62
63function colour(key: string | undefined): string | undefined {
64 if (key === undefined || key === '.') return undefined
65 return (BOLT_PALETTE as Record<string, string>)[key]
66}
67
68/**
69 * Packs a pose into half-block text cells: rows / 2 lines of `cols` cells.
70 * Terminal: draw with Raster (see hatchling's rasterCells). Desktop: one fixed-width cell per character.
71 */
72export function boltCells(size: 'small', pose: BoltSmallPose): BoltCell[][]
73export function boltCells(size: 'tiny', pose: BoltTinyPose): BoltCell[][]
74export function boltCells(size: BoltSize, pose: BoltPose): BoltCell[][] {
75 const rows: readonly string[] = (BOLT_SPRITES[size] as Record<string, readonly string[]>)[pose] ?? []
76 const out: BoltCell[][] = []
77 for (let r = 0; r + 1 < rows.length; r += 2) {
78 const top = rows[r] ?? ''
79 const bottom = rows[r + 1] ?? ''
80 const line: BoltCell[] = []
81 for (let c = 0; c < top.length; c += 1) {
82 const t = colour(top[c])
83 const b = colour(bottom[c])
84 if (t === undefined && b === undefined) line.push({ ch: ' ' })
85 else if (b === undefined) line.push({ ch: '\u2580', fg: t })
86 else if (t === undefined) line.push({ ch: '\u2584', fg: b })
87 else if (t === b) line.push({ ch: '\u2588', fg: t })
88 else line.push({ ch: '\u2580', fg: t, bg: b })
89 }
90 out.push(line)
91 }
92 for (const glyph of GLYPHS[`${size}/${pose}`] ?? []) {
93 const line = out[glyph.row]
94 if (line !== undefined && glyph.col < line.length) line[glyph.col] = { ch: glyph.ch, fg: BOLT_PALETTE[glyph.color] }
95 }
96 return out
97}
98
99/** Merges a row of cells into runs of one style, for coloured Text spans. */
100export function boltRuns(line: readonly BoltCell[]): BoltCell[] {
101 const runs: BoltCell[] = []
102 for (const cell of line) {
103 const last = runs[runs.length - 1]
104 if (last !== undefined && last.fg === cell.fg && last.bg === cell.bg) last.ch += cell.ch
105 else runs.push({ ...cell })
106 }
107 return runs
108}
109types/index.d.ts 55 lines1/**
2 * What footprints knows about one path Claude touched this session.
3 * Every flag is absent until it is true, so an empty mark means "untouched".
4 */
5export type FootprintsMark = {
6 /** Read with Read, or searched with Glob/Grep (then the mark is a folder's). */
7 read?: true
8 /** tool_use ids of edit calls still running on this path (the shimmer). */
9 editing?: string[]
10 /** Changed this session: an edit tool, or the shell (see viaShell). */
11 edited?: true
12 /** Created this session. */
13 isNew?: true
14 /** Deleted this session. */
15 deleted?: true
16 /** Edited, and a later git commit left it clean. */
17 committed?: true
18 /** Changed by a Bash command only, never by an edit tool. */
19 viaShell?: true
20 /** The path is a folder (a Glob/Grep target), not a file. */
21 isDir?: true
22}
23
24/** The project's file list, names only, as the last scan found it. */
25export type FootprintsTree = {
26 /** The folder the tree is drawn from, absolute. */
27 root: string
28 /** The same folder with every link resolved, when that spells it differently. */
29 realRoot?: string
30 /** File paths relative to root, '/'-separated, at most the scan cap. */
31 files: string[]
32 /** Where the list came from: git (honours .gitignore) or a folder walk. */
33 source: 'git' | 'walk'
34 /** How many files the scan found before the cap. */
35 total: number
36 /** One line about a scan that fell short, when it did. */
37 note?: string
38}
39
40declare module 'claude-code' {
41 interface PluginState {
42 footprints: {
43 /** Absolute path to its mark. */
44 marks: Record<string, FootprintsMark>
45 tree: FootprintsTree | null
46 /** Touched-only as the person chose it with t or /footprints touched; null until they do, when a big tree (over 50 folders) opens touched-only. */
47 touchedOnly: boolean | null
48 /** Animation frame of the shimmer; only moves while an edit runs. */
49 phase: number
50 /** True once git status proved too slow here: Bash calls are no longer checked. */
51 shellPaused: boolean
52 }
53 }
54}
55