SLOPSHOPPER

footprints

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

newpaneguardcommandtoaststatus
★ 1v0.1.2MITupdated 2026-10-14modmyclaude/claude-code-mods/mods/footprints
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · footprints
│ ┃ Footprints ✕ › fix the failing auth test and add an audit log call │ ┃ read 2 · edited 3 · new 0 │ ┃ t: touched only app (not a git repo) ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ▾ src/ ⏺ Bash(bun test) │ ┃ audit.ts ⎿ 3 pass, 1 fail │ ┃ auth.ts │ ┃ cache.ts ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ package.json │ ┃ README.md ✻ Worked for 42s · done 4:20 PM │ ┃ src │ ┃ › /footprints │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ footprints: edited 3

Draws

Pane · Footprints
read 2 · edited 3 · new 0 t: touched only app (not a git repo) ▾ src/ audit.ts auth.ts cache.ts package.json README.md src
README

<img src="../../brand/pixel-icons/svg/footprints.svg" width="64" height="64" alt="">

Footprints

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

Install

/plugin install footprints --marketplace modmyclaude/claude-code-mods

Use it

  • /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.
  • As Claude works, names change color:
  • blue - read (Read; a folder Glob or Grep searched)
  • amber - edited; it shimmers while the edit is still running (only while the pane is on screen)
  • green - edited, then committed with git commit
  • new - created this session
  • struck through - deleted (a file created this session and then deleted leaves no trace)
  • (shell) - changed by a Bash command rather than an edit tool
  • The header counts it up: read 12 · edited 4 · new 1, plus deleted and committed once there are any. New, deleted and committed files are counted in edited too.
  • Touched only: press 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.
  • Status line: edited 4 under the prompt, so you see activity with the pane closed.
  • Untouched folders with more than 40 files start collapsed, showing their file count. Files Claude touched outside the project are listed under outside <folder>.
  • /clear starts a clean trail.

Settings

None.

What it can touch

  • Reads: file names and modification times only, never contents. Names come from 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.
  • Runs (all read-only, no index lock):
  • git rev-parse --show-toplevel --show-prefix to find the repo
  • git 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 open
  • git --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.
  • Writes: nothing on disk; its marks live in the session's state.
  • Network: none. Model calls: none.

Limits

  • In a repo where 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.
  • Shell changes need git. Outside a git repo the tree still works (a walk that skips node_modules, .git and a few cache folders, without .gitignore), but Bash changes are not marked.
  • Shell attribution compares 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.
  • Green needs a Bash command that runs git commit and moves HEAD. Commits you make in your own terminal are not seen.
  • Files read through the shell (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.
  • Subagents' tool calls count too.
  • Big repos: it keeps up to 20,000 file names and draws up to 400 rows (the rest is one "more" line).
  • An edit still running when the mod reloads is not recorded.
  • A name holding control characters is drawn with color codes dropped and each other control character shown as � (git's file lists are read NUL-separated, so its path quoting never applies).
  • Tested with POSIX paths (macOS, Linux). Colors follow your theme's suggestion, warning and success.
  • The pane opens only when you ask. If a surface cannot place panes, or something refuses the pane, a one-line toast says so.
  • Not done yet: the tree is built in one go, not lazily, and a collapsed folder cannot be expanded by hand.

Built from a prompt

prompts/footprints.md

More mods: the full catalogue.

Source 5 files
hooks/register.tsx 654 lines
1// 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}
654
hooks/bolt-draw.tsx 86 lines
1// 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}
86
hooks/model.ts 398 lines
1// 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}
398
hooks/bolt.ts 109 lines
1// 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}
109
types/index.d.ts 55 lines
1/**
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