SLOPSHOPPER

folder-map

Side panel that opens when Claude edits files: the whole folder tree, every edited file numbered in order, and a highlighted trail from the project root to the…

paneguardcommand
★ 1v0.1.1MITupdated 2026-10-18letusnotc/folder-map
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · folder-map
│ ┃ folder-map ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ app › src › cache.ts │ ┃ 3 files · 1 folders · 3 edits · last ③ Write ⏺ Read(src/auth.ts) │ ┃ ◎ Edited branches ● Auto-open ↻ Refresh ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ▾ app /work/app ⎿ Added 2 lines, removed 1 line │ ┃ ├─ • package.json ⏺ Bash(bun test) │ ┃ ├─ • README.md ⎿ 3 pass, 1 fail │ ┃ └─ • src │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /folder-map │ ⎿ folder-map: Folder map closed. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · folder-map
▣ app › src › cache.ts 3 files · 1 folders · 3 edits · last ③ Write ◎ Edited branches ● Auto-open ↻ Refresh ▾ app /work/app ├─ • package.json ├─ • README.md └─ • src
README

🗺️ folder-map

See where Claude is working, as it works.

A Claude Code mod that opens a live map of your project whenever Claude edits a file:<br> the folder tree, every edited file numbered in order, and a trail to the file being edited right now.

Claude Code Mod License: MIT<br> reach validates

<img src="assets/desktop-pane.svg" alt="The folder-map pane in the Claude desktop app: stat cards, the last-edit breadcrumb, and the project tree with edited files numbered 1 to 4" width="480">


✨ Features

🌳 Live folder treeThe project tree, rescanned on each edit so new files and folders show up right away.
🔢 Numbered editsEach edited file gets a number (①②③…) in the order Claude first edited it, ×N for repeat edits, and a NEW tag for files Claude created.
📍 Trail to nowA highlighted path runs from the project root to the file being edited, with a breadcrumb at the top.
🎯 Edited branches modeHides untouched folders so you only see the folders that hold edits.
🔕 Stays out of the wayIf you close the pane, it stays closed for edits in that folder and opens again when Claude moves somewhere else.
🖥️ Terminal and desktopBox-drawn text rows in the terminal, a drawn tree in the desktop app's Code tab.

📸 In the terminal

<img src="assets/terminal-pane.svg" alt="folder-map drawn as text in the terminal, with box-drawing guides, numbered edits and a ◀ now marker on the latest file" width="600">

<sub>Both images are rendered from the mod's own drawing code for a sample project in which Claude edits four files. The desktop image above follows your light or dark theme.</sub>

📦 Install

Requires Claude Code 2.1.287 or later.

In a Claude Code terminal session

/plugin install folder-map --marketplace letusnotc/folder-map

Answer y to add the marketplace, then pick a scope. The user scope loads it in every session.

From a shell

claude plugin install folder-map --marketplace letusnotc/folder-map

In the desktop app (where /plugin does not run): click + next to the prompt box, choose Plugins, then Add marketplace with letusnotc/folder-map, then Add plugin.

🚀 Use

Ask Claude to edit something and the map opens on its own.

ControlWhat it does
/folder-mapShow or hide the pane
◎ Edited branches / ▤ Full treeShow only the folders that hold edits, or the whole tree
● Auto-openTurn opening on edits on or off
↻ RefreshRescan the folders

Heavy folders (node_modules, .git, dist, build, .venv, and others) are listed but never opened. Big trees are capped so the pane stays fast.

🔒 Access

What the mod uses, as reported by claude plugin validate:

📂 ReadsFolder listings and file-exists checks ($.fs.list, $.fs.exists) in the project being edited
🪝 HooksEdit, Write, MultiEdit and NotebookEdit tool calls. It records the path after the edit runs and never blocks or changes a tool call.
🪟 UIOne side pane and the /folder-map command
🚫 NeverWrites files, runs processes or uses the network

🛠️ Development

git clone https://github.com/letusnotc/folder-map
claude --plugin-dir ./folder-map
claude plugin validate ./folder-map
claude plugin test ./folder-map

📄 License

MIT © 2026 Arnav Pandey

Source 2 files
hooks/register.tsx 848 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { EditMark, Entry, Listing, View } from '../types'
5
6const roots = atom({ plugin: 'folder-map', key: 'roots' } as const, [])
7const dirs = atom({ plugin: 'folder-map', key: 'dirs' } as const, {})
8const edits = atom({ plugin: 'folder-map', key: 'edits' } as const, [])
9const last = atom({ plugin: 'folder-map', key: 'last' } as const, '')
10const view = atom({ plugin: 'folder-map', key: 'view' } as const, {
11  mode: 'tree',
12  autoOpen: true,
13  quietFolder: '',
14})
15
16const PANE = 'folder-map'
17const TITLE = 'Folder map'
18
19// Shared with savvy-progress so the two panels read as one family.
20const ACCENT = '#8f8cf4'
21const DONE = '#5fbf8f'
22const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
23
24const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
25const SKIP = new Set([
26  'node_modules', '.git', '__pycache__', '.venv', 'venv', 'dist', 'build', '.next', '.nuxt',
27  '.cache', 'target', '.turbo', '.pytest_cache', '.mypy_cache', 'coverage', '.gradle', 'Pods', '.idea',
28])
29const MARKERS = ['.git', '.claude-plugin', 'package.json', 'pyproject.toml', 'Cargo.toml', 'go.mod']
30const MAX_DIRS = 300
31const MAX_DEPTH = 10
32const MAX_PER_DIR = 200
33const MAX_ROWS = 600
34const MAX_ROOTS = 4
35const ROW_H = 22
36const CHUNK = 80
37const INDENT = 20
38
39// ---------------------------------------------------------------------------
40// Paths: always `/`-separated; compared case-blind on Windows drives.
41
42const norm = (p: string): string => {
43  const s = p.replace(/\\/g, '/').replace(/\/+$/, '')
44  return /^[A-Za-z]:$/.test(s) ? s + '/' : s || '/'
45}
46const keyOf = (p: string): string => (/^[A-Za-z]:/.test(p) ? p.toLowerCase() : p)
47const join = (dir: string, name: string): string => (dir.endsWith('/') ? dir + name : `${dir}/${name}`)
48const isUnder = (p: string, root: string): boolean => {
49  const a = keyOf(p)
50  const b = keyOf(root)
51  return a === b || a.startsWith(b.endsWith('/') ? b : b + '/')
52}
53const parentOf = (p: string): string => {
54  const i = p.lastIndexOf('/')
55  if (i < 0) return p
56  return norm(p.slice(0, i) || '/')
57}
58const baseName = (p: string): string => p.slice(p.lastIndexOf('/') + 1) || p
59const absolute = (p: string, cwd: string): string => (/^[A-Za-z]:|^\//.test(p.replace(/\\/g, '/')) ? norm(p) : norm(join(cwd, p)))
60
61/** Directory names from `root` (exclusive) down to `p` (inclusive). */
62const segmentsBelow = (root: string, p: string): string[] =>
63  keyOf(p) === keyOf(root) ? [] : p.slice(root.endsWith('/') ? root.length : root.length + 1).split('/').filter(Boolean)
64
65/** Every directory from `root` down to `dir`, both included. */
66const chainDirs = (root: string, dir: string): string[] => {
67  const out = [root]
68  let at = root
69  for (const seg of segmentsBelow(root, dir)) {
70    at = join(at, seg)
71    out.push(at)
72  }
73  return out
74}
75
76// ---------------------------------------------------------------------------
77// Scanning.
78
79// Folders first, then by name.
80const byKindThenName = (a: Entry, b: Entry): number =>
81  (a.kind === 'dir' ? 0 : 1) - (b.kind === 'dir' ? 0 : 1) || a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
82
83async function listDir($: EngineInterface, abs: string): Promise<Listing> {
84  const raw = await $.fs.list(abs)
85  const entries: Entry[] = raw
86    .map((r): Entry => {
87      const kind = r.kind === 'dir' ? 'dir' : r.kind === 'file' ? 'file' : 'other'
88      return kind === 'dir' && SKIP.has(r.name) ? { name: r.name, kind, isSkipped: true } : { name: r.name, kind }
89    })
90    .sort(byKindThenName)
91  return { entries: entries.slice(0, MAX_PER_DIR), more: Math.max(0, entries.length - MAX_PER_DIR) }
92}
93
94// Breadth first, a level at a time, so a huge repo still shows its top.
95async function scanTree($: EngineInterface, root: string): Promise<Record<string, Listing>> {
96  const out: Record<string, Listing> = {}
97  let level = [root]
98  let count = 0
99  for (let depth = 0; level.length && depth <= MAX_DEPTH && count < MAX_DIRS; depth++) {
100    const batch = level.slice(0, MAX_DIRS - count)
101    count += batch.length
102    const lists = await Promise.all(batch.map(d => listDir($, d).catch(() => null)))
103    const next: string[] = []
104    batch.forEach((d, i) => {
105      const l = lists[i]
106      if (!l) return
107      out[keyOf(d)] = l
108      for (const en of l.entries) if (en.kind === 'dir' && !en.isSkipped) next.push(join(d, en.name))
109    })
110    level = next
111  }
112  return out
113}
114
115// The folder a file belongs to: a root already shown, the session's folder, or
116// the nearest folder above it holding a project marker.
117async function findRoot($: EngineInterface, file: string, cwd: string, shown: string[]): Promise<string> {
118  const existing = shown.find(r => isUnder(file, r))
119  if (existing) return existing
120  if (cwd && isUnder(file, cwd)) return cwd
121  let dir = parentOf(file)
122  for (let i = 0; i < 6; i++) {
123    const found = await Promise.all(MARKERS.map(m => $.fs.exists(join(dir, m)).catch(() => false)))
124    if (found.some(Boolean)) return dir
125    const up = parentOf(dir)
126    if (up === dir) break
127    dir = up
128  }
129  return parentOf(file)
130}
131
132async function rememberRoot($: EngineInterface, root: string, scanned: Record<string, Listing>): Promise<void> {
133  const list = await update($, roots, prev => {
134    // A wider root swallows the narrower ones beneath it.
135    const kept = prev.filter(r => !isUnder(r, root))
136    return [root, ...kept].slice(0, MAX_ROOTS)
137  })
138  await update($, dirs, prev => {
139    const merged: Record<string, Listing> = {}
140    for (const [k, v] of Object.entries({ ...prev, ...scanned })) if (list.some(r => isUnder(k, keyOf(r)))) merged[k] = v
141    return merged
142  })
143}
144
145async function track($: EngineInterface, file: string, tool: string, isNew: boolean): Promise<void> {
146  const cwd = norm(await $.session.cwd())
147  const root = await findRoot($, file, cwd, await read($, roots))
148  const known = await read($, dirs)
149  const scanned = known[keyOf(root)] ? {} : await scanTree($, root)
150  // The file's own chain is listed again, so a new file or folder shows.
151  const chain = chainDirs(root, parentOf(file))
152  const lists = await Promise.all(chain.map(d => listDir($, d).catch(() => null)))
153  chain.forEach((d, i) => {
154    const l = lists[i]
155    if (l) scanned[keyOf(d)] = l
156  })
157  await rememberRoot($, root, scanned)
158
159  await update($, edits, list => {
160    const at = list.findIndex(m => keyOf(m.path) === keyOf(file))
161    if (at >= 0) return list.map((m, i) => (i === at ? { ...m, tool, count: m.count + 1 } : m))
162    const seq = list.reduce((n, m) => Math.max(n, m.seq), 0) + 1
163    return [...list, { path: file, tool, seq, count: 1, isNew }].slice(-200)
164  })
165  await update($, last, () => file)
166
167  const v = await read($, view)
168  if (!v.autoOpen) return
169  if (v.quietFolder && keyOf(v.quietFolder) === keyOf(parentOf(file))) return
170  if (v.quietFolder) await update($, view, prev => ({ ...prev, quietFolder: '' }))
171  const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
172  if (!isOpen) await $.ui.open({ id: PANE, title: TITLE })
173}
174
175async function rescanAll($: EngineInterface): Promise<void> {
176  const cwd = norm(await $.session.cwd())
177  const list = await read($, roots)
178  const targets = list.length ? list : [cwd]
179  const scans = await Promise.all(targets.map(r => scanTree($, r)))
180  const merged: Record<string, Listing> = Object.assign({}, ...scans)
181  if (!list.length) await update($, roots, () => [cwd])
182  await update($, dirs, () => merged)
183}
184
185// Closing the map quiets the folder of the latest edit until Claude edits elsewhere.
186async function quietLastFolder($: EngineInterface): Promise<void> {
187  const file = await read($, last)
188  if (file) await update($, view, prev => ({ ...prev, quietFolder: parentOf(file) }))
189}
190
191async function togglePane($: EngineInterface): Promise<boolean> {
192  const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
193  if (isOpen) {
194    // A close by command is the person's too; the `ui.close` hook sees only the plugin.
195    await quietLastFolder($)
196    await $.ui.close({ id: PANE })
197    return false
198  }
199  await update($, view, prev => ({ ...prev, quietFolder: '' }))
200  if (!(await read($, roots)).length) await rescanAll($)
201  await $.ui.open({ id: PANE, title: TITLE })
202  return true
203}
204
205// ---------------------------------------------------------------------------
206// Flattening a root into drawable rows.
207
208type Row = {
209  abs: string
210  name: string
211  depth: number
212  kind: 'dir' | 'file' | 'other' | 'more'
213  isOpen: boolean
214  isSkipped: boolean
215  isLastSibling: boolean
216  /** Per column above this row: whether that ancestor's line continues. */
217  guides: boolean[]
218  /** Keys of this row's ancestors below the root, itself last. */
219  ancestors: string[]
220  mark?: EditMark
221  /** Edited files beneath a folder. */
222  touched: number
223  more: number
224}
225
226type Tree = {
227  root: string
228  rows: Row[]
229  trail: string[]
230  index: Map<string, number>
231  hidden: number
232  isFocused: boolean
233}
234
235function flatten(root: string, listings: Record<string, Listing>, marks: EditMark[], lastFile: string, mode: View['mode']): Tree {
236  const byKey = new Map(marks.map(m => [keyOf(m.path), m]))
237  const touched = new Map<string, number>()
238  for (const m of marks) {
239    if (!isUnder(m.path, root)) continue
240    for (const d of chainDirs(root, parentOf(m.path))) touched.set(keyOf(d), (touched.get(keyOf(d)) ?? 0) + 1)
241  }
242  const trail = lastFile && isUnder(lastFile, root) ? [...chainDirs(root, parentOf(lastFile)), lastFile].map(keyOf) : []
243
244  // A listing past its cap may have left out an edited file, or a folder on
245  // the way to one: put those back so every edit keeps its row.
246  const withTracked = (dir: string, l: Listing): Listing => {
247    if (!l.more) return l
248    const have = new Set(l.entries.map(en => keyOf(join(dir, en.name))))
249    const extra: Entry[] = []
250    for (const m of marks) {
251      if (keyOf(m.path) === keyOf(dir) || !isUnder(m.path, dir)) continue
252      const segs = segmentsBelow(dir, m.path)
253      const name = segs[0]
254      if (!name) continue
255      const k = keyOf(join(dir, name))
256      if (have.has(k)) continue
257      have.add(k)
258      extra.push({ name, kind: segs.length === 1 ? 'file' : 'dir' })
259    }
260    if (!extra.length) return l
261    return { entries: [...l.entries, ...extra].sort(byKindThenName), more: Math.max(0, l.more - extra.length) }
262  }
263
264  const build = (focus: boolean): Row[] => {
265    const rows: Row[] = []
266    const walk = (dir: string, depth: number, guides: boolean[], ancestors: string[]) => {
267      const listed = listings[keyOf(dir)]
268      if (!listed) return
269      const l = withTracked(dir, listed)
270      // Edited branches: folders with no edits beneath them are left out.
271      const entries = focus
272        ? l.entries.filter(en => en.kind !== 'dir' || (touched.get(keyOf(join(dir, en.name))) ?? 0) > 0)
273        : l.entries
274      entries.forEach((en, i) => {
275        const isLastSibling = i === entries.length - 1 && !l.more
276        const abs = join(dir, en.name)
277        const k = keyOf(abs)
278        const below = touched.get(k) ?? 0
279        const isOpen = en.kind === 'dir' && !!listings[k] && (!focus || below > 0)
280        const chain = [...ancestors, k]
281        rows.push({
282          abs,
283          name: en.name,
284          depth,
285          kind: en.kind,
286          isOpen,
287          isSkipped: !!en.isSkipped,
288          isLastSibling,
289          guides,
290          ancestors: chain,
291          mark: en.kind === 'file' ? byKey.get(k) : undefined,
292          touched: en.kind === 'dir' ? below : 0,
293          more: 0,
294        })
295        if (isOpen) walk(abs, depth + 1, [...guides, !isLastSibling], chain)
296      })
297      if (l.more)
298        rows.push({
299          abs: join(dir, '…'),
300          name: `${l.more} more`,
301          depth,
302          kind: 'more',
303          isOpen: false,
304          isSkipped: false,
305          isLastSibling: true,
306          guides,
307          ancestors: [...ancestors, keyOf(join(dir, '…'))],
308          touched: 0,
309          more: l.more,
310        })
311    }
312    walk(root, 0, [], [])
313    return rows
314  }
315
316  let isFocused = mode === 'focus'
317  let rows = build(isFocused)
318  // A whole tree too big to read falls back to the edited branches.
319  if (!isFocused && rows.length > MAX_ROWS) {
320    isFocused = true
321    rows = build(true)
322  }
323  const hidden = Math.max(0, rows.length - MAX_ROWS)
324  rows = rows.slice(0, MAX_ROWS)
325  const index = new Map(rows.map((r, i) => [keyOf(r.abs), i]))
326  return { root, rows, trail, index, hidden, isFocused }
327}
328
329// How each guide column of a row is lit on the trail from the root to the
330// latest file: column j belongs to the folder at depth j-1 (the root for 0).
331function lit(t: Tree, r: Row, rowIdx: number, j: number): { up: boolean; down: boolean; across: boolean } {
332  const none = { up: false, down: false, across: false }
333  const owner = j === 0 ? keyOf(t.root) : r.ancestors[j - 1]
334  if (!t.trail.length || t.trail[j] !== owner) return none
335  const target = t.trail[j + 1]
336  if (!target) return none
337  const targetIdx = t.index.get(target)
338  if (targetIdx === undefined) return none
339  const self = r.ancestors[j]
340  if (self === undefined) return none
341  const selfIdx = j === r.depth ? rowIdx : t.index.get(self) ?? Infinity
342  if (j < r.depth) return selfIdx < targetIdx ? { up: true, down: true, across: false } : none
343  if (self === target) return { up: true, down: false, across: true }
344  return selfIdx < targetIdx ? { up: true, down: true, across: false } : none
345}
346
347// ---------------------------------------------------------------------------
348// Look: file colors by extension, the same SVG idiom as savvy-progress.
349
350const EXT_COLOR: Record<string, string> = {
351  ts: '#3178c6', tsx: '#3178c6', mts: '#3178c6', cts: '#3178c6',
352  js: '#d4a514', jsx: '#d4a514', mjs: '#d4a514', cjs: '#d4a514',
353  py: '#4b8bbe', ipynb: '#f37726', json: '#cb8a2c', md: '#7d7d79', mdx: '#7d7d79',
354  css: '#a35bd6', scss: '#c6538c', html: '#e2582c', rs: '#ce7c4a', go: '#00a7d0',
355  java: '#e76f00', kt: '#a97bff', sh: '#5fbf8f', ps1: '#5fbf8f', yml: '#c94a7a', yaml: '#c94a7a',
356  toml: '#9c4221', sql: '#e38c00', csv: '#5fbf8f', txt: '#9a9a96', svg: '#e2a03f', png: '#b07cc6', jpg: '#b07cc6',
357}
358const extOf = (name: string): string => (name.includes('.') ? name.slice(name.lastIndexOf('.') + 1).toLowerCase() : '')
359const colorOfFile = (name: string): string => EXT_COLOR[extOf(name)] ?? '#9a9a96'
360
361const xml = (s: string): string =>
362  s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c)
363
364const charEm = (ch: string): number => (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Z@%mw]/.test(ch) ? 0.72 : 0.56)
365const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
366const fitText = (s: string, size: number, maxW: number): string => {
367  if (textWidth(s, size) <= maxW) return s
368  let out = ''
369  for (const ch of s) {
370    if (textWidth(out + ch + '…', size) > maxW) break
371    out += ch
372  }
373  return out + '…'
374}
375
376const CSS = `<style>
377.t{fill:#1f1f1f}.s{fill:#6b6b68}.m{fill:#9a9a96}.k{fill:#ecebe8}.tile{fill:#f4f3f0}.fd{fill:#c9c6bd}.fdo{fill:#e2dfd6}
378.g{stroke:#dddcd7}.bg{fill:#1f1f1f;opacity:0}.r:hover .bg{opacity:.05}
379@media (prefers-color-scheme: dark){.t{fill:#ececec}.s{fill:#a8a8a4}.m{fill:#7d7d79}.k{fill:#2c2c2b}.tile{fill:#262625}.fd{fill:#6b6964}.fdo{fill:#55534f}.g{stroke:#3a3a38}.bg{fill:#ffffff}}
380.live{animation:p 1.6s ease-in-out infinite}@keyframes p{50%{opacity:.3}}
381@media (prefers-reduced-motion: reduce){.live{animation:none}}
382</style>`
383
384// Pixel Clawd, as savvy-progress draws it (from DockCrab / Clawdy, via
385// github.com/JohnnyVizz/claude-kit): a 24×18 crab on a 30×28 grid.
386const CLAY = '#D97757'
387const INK = '#1F1E1D'
388
389type Fill = (x: number, y: number, w: number, h: number, c: string, cls?: string) => void
390
391const stamp = (f: Fill, x: number, y: number, rows: string[], map: Record<string, string>, cls?: string): void =>
392  rows.forEach((row, dy) => [...row].forEach((ch, dx) => map[ch] && f(x + dx, y + dy, 1, 1, map[ch] ?? '', cls)))
393
394const crabBody = (f: Fill): void => {
395  f(7, 10, 16, 12, CLAY)
396  f(3, 14, 4, 4, CLAY)
397  f(23, 14, 4, 4, CLAY)
398  f(9, 12, 2, 2, INK)
399  f(19, 12, 2, 2, INK)
400  f(7, 22, 2, 4, CLAY, 'la')
401  f(17, 22, 2, 4, CLAY, 'la')
402  f(11, 22, 2, 4, CLAY, 'lb')
403  f(21, 22, 2, 4, CLAY, 'lb')
404}
405
406// `builder` wears the hard hat and turns a wrench: Claude made a new file.
407const COSTUMES: Record<string, (f: Fill) => void> = {
408  builder: f => {
409    crabBody(f)
410    stamp(f, 6, 4, ['.....yyyyyyyy.....', '...yyyyyhhyyyyy...', '..yyyyyyhhyyyyyy..', '..yyyyyyhhyyyyyy..', '.yyyyyyyhhyyyyyyy.', 'dddddddddddddddddd'], { y: '#F5C542', h: '#FBE08A', d: '#C99A1E' })
411    f(13, 5, 4, 2, ACCENT)
412    stamp(f, 0, 10, ['.s.s', 'sss.', '.s..', '.s..'], { s: '#8E929A' }, 'it')
413  },
414  plain: f => crabBody(f),
415}
416
417// Pure CSS, run by the compositor: legs step, the body bobs, a patrolling crab
418// walks the row and turns at each end; a resting one breathes.
419const CRAB_CSS = `<style>
420.run .la{animation:st .5s steps(1) infinite}.run .lb{animation:st .5s steps(1) infinite -.25s}
421.run .bd{animation:bob .5s steps(1) infinite -.125s}
422.run g{transform-box:fill-box}
423@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
424.c-builder.run .it{transform-origin:100% 100%;animation:twist .5s ease-in-out infinite}
425@keyframes twist{50%{transform:rotate(-35deg)}}
426.patrol{transform-box:fill-box;transform-origin:center;animation:patrol var(--t) linear infinite}
427@keyframes patrol{0%{transform:translateX(0) scaleX(1)}49.9%{transform:translateX(var(--d)) scaleX(1)}50%{transform:translateX(var(--d)) scaleX(-1)}100%{transform:translateX(0) scaleX(-1)}}
428.rest{transform-box:fill-box;transform-origin:50% 100%;animation:breathe 2.4s ease-in-out infinite}
429@keyframes breathe{50%{transform:scaleY(.94)}}
430@media (prefers-reduced-motion: reduce){.run,.run g,.patrol,.rest{animation:none!important}}
431</style>`
432
433const crab = (x: number, y: number, costume: string, isWalking: boolean, scale: number, dim = false): string => {
434  const groups = new Map<string, string[]>([['bd', []]])
435  const f: Fill = (cx, cy, w, h, c, cls = 'bd') => {
436    if (!groups.has(cls)) groups.set(cls, [])
437    groups.get(cls)?.push(`<rect x="${cx}" y="${cy}" width="${w}" height="${h}" fill="${c}"/>`)
438  }
439  ;(COSTUMES[costume] ?? crabBody)(f)
440  const group = (cls: string) => `<g class="${cls}">${(groups.get(cls) ?? []).join('')}</g>`
441  const props = [...groups.keys()].filter(k => k !== 'bd' && k !== 'la' && k !== 'lb')
442  const body = `<g class="bd">${(groups.get('bd') ?? []).join('')}${props.map(group).join('')}</g>`
443  return `<g transform="translate(${x},${y}) scale(${scale})" opacity="${dim ? 0.5 : 1}" shape-rendering="crispEdges"><g class="c-${costume}${isWalking ? ' run' : ''}">${body}${group('la')}${group('lb')}</g></g>`
444}
445
446// A crab that walks between two x positions, turning at each end.
447const patrolCrab = (x0: number, x1: number, y: number, scale: number, costume: string): string => {
448  const d = Math.max(0, Math.round(x1 - x0))
449  if (d < 12) return crab(x0, y, costume, true, scale)
450  return `<g class="patrol" style="--d:${d}px;--t:${Math.max(2, d / 22).toFixed(1)}s">${crab(x0, y, costume, true, scale)}</g>`
451}
452
453const costumeOf = (m: EditMark | undefined): string => (m?.isNew ? 'builder' : 'plain')
454
455const svg = (W: number, H: number, body: string): string =>
456  `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${CSS}${CRAB_CSS}${body}</svg>`
457
458const folderIcon = (x: number, y: number, isOpen: boolean, fill: string | null): string => {
459  const cls = fill ? '' : ' class="fd"'
460  const f = fill ? ` fill="${fill}"` : ''
461  const back = `<path${cls}${f} d="M${x} ${y + 2.5}a1.5 1.5 0 0 1 1.5-1.5h4l1.6 1.8h7.4a1.5 1.5 0 0 1 1.5 1.5v8.2a1.5 1.5 0 0 1-1.5 1.5h-13a1.5 1.5 0 0 1-1.5-1.5z"/>`
462  if (!isOpen) return back
463  const front = fill
464    ? `<path fill="${fill}" opacity=".55" d="M${x + 1.2} ${y + 6}h14.6l-1.4 6.6a1.4 1.4 0 0 1-1.4 1.1h-12.4z"/>`
465    : `<path class="fdo" d="M${x + 1.2} ${y + 6}h14.6l-1.4 6.6a1.4 1.4 0 0 1-1.4 1.1h-12.4z"/>`
466  return back + front
467}
468
469const fileIcon = (x: number, y: number, color: string): string =>
470  `<path d="M${x + 2.5} ${y}h6.5l4 4v9.5a1.5 1.5 0 0 1-1.5 1.5h-9a1.5 1.5 0 0 1-1.5-1.5v-12a1.5 1.5 0 0 1 1.5-1.5z" fill="${color}" fill-opacity=".16" stroke="${color}" stroke-width="1.2"/><path d="M${x + 9} ${y}v4h4" fill="none" stroke="${color}" stroke-width="1.2"/>`
471
472const badge = (cx: number, cy: number, n: number): string =>
473  `<circle cx="${cx}" cy="${cy}" r="8" fill="${ACCENT}"/><text x="${cx}" y="${cy + 3.6}" text-anchor="middle" font-family="${FONT}" font-size="${n > 99 ? 7.5 : 10}" font-weight="700" fill="#fff" font-variant-numeric="tabular-nums">${n}</text>`
474
475function rowSvg(t: Tree, r: Row, i: number, y: number, W: number, lastKey: string): string {
476  const mid = y + ROW_H / 2
477  const k = keyOf(r.abs)
478  const isLast = k === lastKey
479  const parts: string[] = []
480  parts.push(`<rect class="bg" x="0" y="${y + 1}" width="${W}" height="${ROW_H - 2}" rx="6"/>`)
481  if (isLast) {
482    parts.push(`<rect x="0" y="${y + 1}" width="${W}" height="${ROW_H - 2}" rx="6" fill="${ACCENT}" fill-opacity=".14"/>`)
483    parts.push(`<rect x="0" y="${y + 4}" width="3" height="${ROW_H - 8}" rx="1.5" fill="${ACCENT}"/>`)
484  }
485  // Guides: dim lines, the trail to the latest file in accent.
486  for (let j = 0; j <= r.depth; j++) {
487    const x = 12 + j * INDENT + 0.5
488    const l = lit(t, r, i, j)
489    const stroke = (on: boolean) => (on ? ` stroke="${ACCENT}" stroke-width="1.6"` : ' class="g" stroke-width="1"')
490    if (j < r.depth) {
491      if (r.guides[j]) parts.push(`<line x1="${x}" y1="${y}" x2="${x}" y2="${y + ROW_H}"${stroke(l.up)}/>`)
492      continue
493    }
494    parts.push(`<path d="M${x} ${y}V${mid - 3}q0 3 3 3H${x + 8}" fill="none"${stroke(l.up)}/>`)
495    if (!r.isLastSibling) parts.push(`<line x1="${x}" y1="${mid}" x2="${x}" y2="${y + ROW_H}"${stroke(l.down)}/>`)
496  }
497  const ix = 12 + r.depth * INDENT + 11
498  const isOnTrail = t.trail.includes(k)
499  if (r.kind === 'dir') parts.push(folderIcon(ix, mid - 7, r.isOpen, r.touched ? ACCENT : null))
500  else if (r.kind === 'file' || r.kind === 'other') parts.push(fileIcon(ix + 1, mid - 7.5, colorOfFile(r.name)))
501
502  // Right side: the edit's number, its count, a "new" tag; a folder's tally.
503  const right: string[] = []
504  let rx = W - 4
505  if (r.mark) {
506    right.push(badge(rx - 8, mid, r.mark.seq))
507    rx -= 22
508    if (r.mark.count > 1) {
509      right.push(`<text class="s" x="${rx}" y="${mid + 4}" text-anchor="end" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">×${r.mark.count}</text>`)
510      rx -= textWidth(`×${r.mark.count}`, 11) + 6
511    }
512    if (r.mark.isNew) {
513      right.push(`<rect x="${rx - 28}" y="${mid - 7}" width="28" height="14" rx="7" fill="${DONE}"/><text x="${rx - 14}" y="${mid + 3.5}" text-anchor="middle" font-family="${FONT}" font-size="9" font-weight="700" fill="#fff">NEW</text>`)
514      rx -= 34
515    }
516  } else if (r.kind === 'dir' && r.touched) {
517    right.push(`<text x="${rx}" y="${mid + 4}" text-anchor="end" font-family="${FONT}" font-size="11" font-weight="600" fill="${ACCENT}" font-variant-numeric="tabular-nums">${r.touched} edited</text>`)
518    rx -= textWidth(`${r.touched} edited`, 11) + 8
519  }
520
521  const nx = ix + 22
522  const maxW = Math.max(30, rx - nx - 6)
523  if (r.kind === 'more') {
524    parts.push(`<text class="m" x="${nx - 22}" y="${mid + 4}" font-family="${FONT}" font-size="12" font-style="italic">${xml(r.name)}</text>`)
525  } else {
526    const name = fitText(r.name + (r.isSkipped ? '  …' : ''), 12.5, maxW)
527    const style = r.mark
528      ? `fill="${ACCENT}" font-weight="600"`
529      : r.isSkipped
530        ? 'class="m"'
531        : `class="t"${isOnTrail || r.touched ? ' font-weight="600"' : r.kind === 'dir' ? ' font-weight="500"' : ''}`
532    parts.push(`<text ${style} x="${nx}" y="${mid + 4.3}" font-family="${FONT}" font-size="12.5">${xml(name)}</text>`)
533    // The crab walks the free stretch of the current file's row.
534    if (isLast) {
535      const CRAB_W = 30 * 0.6
536      const from = nx + textWidth(name, 12.5) + 10
537      right.push(patrolCrab(from, Math.max(from, rx - CRAB_W - 4), mid - 8.6, 0.6, costumeOf(r.mark)))
538    }
539  }
540  const tip = r.mark ? `${r.abs} — #${r.mark.seq}, ${r.mark.count} edit${r.mark.count > 1 ? 's' : ''}` : r.abs
541  return `<g class="r"><title>${xml(tip)}</title>${parts.join('')}${right.join('')}</g>`
542}
543
544function rootRowSvg(t: Tree, W: number, touched: number): string {
545  const mid = ROW_H / 2
546  const label = fitText(baseName(t.root), 13.5, W - 120)
547  const tally = touched
548    ? `<text x="${W - 4}" y="${mid + 4}" text-anchor="end" font-family="${FONT}" font-size="11" font-weight="600" fill="${ACCENT}">${touched} edited</text>`
549    : ''
550  return `<g class="r"><title>${xml(t.root)}</title>${folderIcon(4, mid - 7, true, touched ? ACCENT : null)}<text class="t" x="28" y="${mid + 4.5}" font-family="${FONT}" font-size="13.5" font-weight="700">${xml(label)}</text>${tally}</g>`
551}
552
553function treeSvgs(t: Tree, W: number, lastKey: string, touched: number): { source: string; height: number; alt: string }[] {
554  const out: { source: string; height: number; alt: string }[] = []
555  for (let start = 0; start === 0 || start < t.rows.length; start += CHUNK) {
556    const slice = t.rows.slice(start, start + CHUNK)
557    const head = start === 0 ? ROW_H : 0
558    const body = slice.map((r, n) => rowSvg(t, r, start + n, head + n * ROW_H, W, lastKey)).join('')
559    const height = head + slice.length * ROW_H + 2
560    out.push({
561      source: svg(W, height, (start === 0 ? rootRowSvg(t, W, touched) : '') + body),
562      height,
563      alt: `${baseName(t.root)}: rows ${start + 1}-${start + slice.length} of ${t.rows.length}`,
564    })
565    if (!slice.length) break
566  }
567  return out
568}
569
570type Stats = { files: number; folders: number; edits: number }
571
572const statsOf = (marks: EditMark[]): Stats => ({
573  files: marks.length,
574  folders: new Set(marks.map(m => keyOf(parentOf(m.path)))).size,
575  edits: marks.reduce((n, m) => n + m.count, 0),
576})
577
578// The header: three tiles, then the latest file's place as a breadcrumb.
579function headerSvg(W: number, st: Stats, crumbs: string[], mark: EditMark | undefined): { source: string; height: number } {
580  const gap = 6
581  const tw = (W - gap * 2) / 3
582  const tile = (i: number, k: string, v: string) =>
583    `<rect class="tile" x="${i * (tw + gap)}" y="0" width="${tw}" height="40" rx="8"/>
584<text class="s" x="${i * (tw + gap) + 9}" y="16" font-family="${FONT}" font-size="11">${k}</text>
585<text class="t" x="${i * (tw + gap) + 9}" y="33" font-family="${FONT}" font-size="15" font-weight="600" font-variant-numeric="tabular-nums">${v}</text>`
586  let body = tile(0, 'Files edited', String(st.files)) + tile(1, 'Folders', String(st.folders)) + tile(2, 'Edits', String(st.edits))
587  if (!mark || !crumbs.length) {
588    // Nothing edited yet: the crab rests in an empty card.
589    body += `<rect class="tile" x="0" y="48" width="${W}" height="40" rx="8"/>
590<g class="rest">${crab(10, 54, 'plain', false, 1)}</g>
591<text class="s" x="48" y="72" font-family="${FONT}" font-size="12">No edits yet. The map opens when Claude edits a file.</text>`
592    return { source: svg(W, 92, body), height: 92 }
593  }
594
595  // Chips flow left to right and wrap; the file itself is the accent pill.
596  const top = 48
597  const pad = 10
598  const lineH = 24
599  const CRAB = 36
600  const chips: string[] = []
601  let x = pad
602  let y = top + 34
603  crumbs.forEach((c, i) => {
604    const isFile = i === crumbs.length - 1
605    const label = fitText(c, 12, W - pad * 2 - 40 - CRAB)
606    const w = textWidth(label, 12) + (isFile ? 22 : 20)
607    const sepW = i ? 14 : 0
608    if (i && x + sepW + w > W - pad - CRAB) {
609      x = pad
610      y += lineH
611    } else if (i) {
612      chips.push(`<path d="M${x + 4} ${y - 9}l4 4-4 4" fill="none" stroke="#9a9a96" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>`)
613      x += sepW
614    }
615    if (isFile) {
616      chips.push(`<rect x="${x}" y="${y - 14}" width="${w}" height="19" rx="9.5" fill="${ACCENT}"/><text x="${x + 11}" y="${y - 0.5}" font-family="${FONT}" font-size="12" font-weight="600" fill="#fff">${xml(label)}</text>`)
617    } else {
618      chips.push(`${folderIcon(x + 1, y - 12, false, null)}<text class="t" x="${x + 20}" y="${y - 0.5}" font-family="${FONT}" font-size="12">${xml(label)}</text>`)
619    }
620    x += w
621  })
622  const cardH = y - top + 12
623  const meta = `#${mark.seq} · ${mark.tool}${mark.count > 1 ? ` · ${mark.count} edits` : ''} · depth ${crumbs.length - 1}`
624  body += `<rect class="tile" x="0" y="${top}" width="${W}" height="${cardH}" rx="8"/>
625<circle class="live" cx="${pad + 3}" cy="${top + 13}" r="3.5" fill="${ACCENT}"/>
626<text class="s" x="${pad + 12}" y="${top + 17}" font-family="${FONT}" font-size="11">Last edit</text>
627<text class="m" x="${W - pad - CRAB}" y="${top + 17}" text-anchor="end" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${xml(meta)}</text>
628${chips.join('')}
629${crab(W - pad - 30, top + (cardH - 28) / 2, costumeOf(mark), true, 1)}`
630  return { source: svg(W, top + cardH + 4, body), height: top + cardH + 4 }
631}
632
633// ---------------------------------------------------------------------------
634// Terminal drawing: the same rows in text.
635
636const CIRCLED = '①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳'
637const seqGlyph = (n: number): string => (n >= 1 && n <= 20 ? [...CIRCLED][n - 1] ?? `#${n}` : `#${n}`)
638
639export const register: Register = on => {
640  on('session.start', async ($, e, next) => {
641    const started = await next(e)
642    await $.command.register({
643      name: 'folder-map',
644      description: 'Show or hide the folder map: the project tree with every file Claude edited, in order',
645    })
646    return started
647  })
648
649  on('command.run', { command: 'folder-map' }, async $ => {
650    const isOpen = await togglePane($)
651    return { text: isOpen ? 'Folder map opened.' : 'Folder map closed.' }
652  })
653
654  on('tool.call', async ($, e, next) => {
655    const tool = String(e.tool)
656    if (!EDIT_TOOLS.has(tool)) return next(e)
657    const raw = (e as { file_path?: unknown; notebook_path?: unknown }).file_path ?? (e as { notebook_path?: unknown }).notebook_path
658    if (typeof raw !== 'string' || !raw) return next(e)
659
660    let file = norm(raw)
661    let isNew = false
662    try {
663      file = absolute(raw, norm(await $.session.cwd()))
664      isNew = tool === 'Write' && !(await $.fs.exists(file))
665    } catch {
666      // The map is a view: it never stands in the edit's way.
667    }
668    const ran = await next(e)
669    if (ran.deny !== undefined || ran.isError) return ran
670    try {
671      await track($, file, tool, isNew)
672    } catch {
673      // Same: a failed scan leaves the map as it was.
674    }
675    return ran
676  })
677
678  // The person closed it: stay quiet for edits in that folder.
679  on('ui.close', { id: PANE }, async ($, e, next) => {
680    if (e.origin.kind === 'person') await quietLastFolder($)
681    return next(e)
682  })
683
684  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
685    const ui = $.ui.resolve(e)
686    const { Box, Text, Button } = ui
687    const rootList = await read($, roots)
688    const listings = await read($, dirs)
689    const marks = await read($, edits)
690    const lastFile = await read($, last)
691    const v: View = await read($, view)
692    const lastKey = keyOf(lastFile)
693    const st = statsOf(marks)
694    const trees = rootList.map(r => flatten(r, listings, marks, lastFile, v.mode))
695    const lastRoot = rootList.find(r => lastFile && isUnder(lastFile, r))
696    const crumbs = lastRoot && lastFile ? [baseName(lastRoot), ...segmentsBelow(lastRoot, lastFile)] : []
697    const lastMark = marks.find(m => keyOf(m.path) === lastKey)
698    const touchedIn = (r: string) => marks.filter(m => isUnder(m.path, r)).length
699
700    const controls = (
701      <Box key="controls" flexDirection="row" gap={2}>
702        <Button
703          key="mode"
704          plain
705          label={v.mode === 'tree' ? '◎ Edited branches' : '▤ Full tree'}
706          onPress={() => update($, view, (prev): View => ({ ...prev, mode: prev.mode === 'tree' ? 'focus' : 'tree' }))}
707        />
708        <Button
709          key="auto"
710          plain
711          label={v.autoOpen ? '● Auto-open' : '○ Auto-open'}
712          onPress={() => update($, view, prev => ({ ...prev, autoOpen: !prev.autoOpen }))}
713        />
714        <Button key="refresh" plain label="↻ Refresh" onPress={() => rescanAll($)} />
715      </Box>
716    )
717    const empty = !rootList.length && (
718      <Text key="empty" dimColor>
719        No edits yet. The map opens on its own when Claude edits a file.
720      </Text>
721    )
722    const notes = (t: Tree) =>
723      [
724        t.isFocused && v.mode === 'tree' ? 'Large folder: showing the edited branches.' : '',
725        t.hidden ? `${t.hidden} more rows not shown.` : '',
726      ].filter(Boolean)
727
728    if (e.surface === 'desktop' && 'Svg' in ui) {
729      const { Svg } = ui
730      const W = Math.max(240, Math.min(900, (e.props.bodyColumns || 40) * 8 - 8))
731      const head = headerSvg(W, st, crumbs, lastMark)
732      return (
733        <Box flexDirection="column" gap={1}>
734          <Svg
735            key="header"
736            source={head.source}
737            alt={`${st.files} files edited in ${st.folders} folders${crumbs.length ? `; last: ${crumbs.join(' / ')}` : ''}`}
738            width={W}
739            height={head.height}
740          />
741          {controls}
742          {trees.map(t => (
743            <Box key={`root-${keyOf(t.root)}`} flexDirection="column">
744              {treeSvgs(t, W, lastKey, touchedIn(t.root)).map((c, n) => (
745                <Svg key={`tree-${keyOf(t.root)}-${n}`} source={c.source} alt={c.alt} width={W} height={c.height} isInteractive />
746              ))}
747              {notes(t).map(s => (
748                <Text key={s} dimColor>
749                  {s}
750                </Text>
751              ))}
752            </Box>
753          ))}
754        </Box>
755      )
756    }
757
758    // Terminal and the other surfaces: text rows with box-drawing guides.
759    const line = (t: Tree, r: Row, i: number) => {
760      const k = keyOf(r.abs)
761      const isLast = k === lastKey
762      const cols: { text: string; on: boolean }[] = []
763      for (let j = 0; j <= r.depth; j++) {
764        const l = lit(t, r, i, j)
765        if (j < r.depth) cols.push({ text: r.guides[j] ? '│  ' : '   ', on: l.up && !!r.guides[j] })
766        else cols.push({ text: r.isLastSibling ? '└─ ' : '├─ ', on: l.across || l.up })
767      }
768      const icon = r.kind === 'dir' ? (r.isOpen ? '▾ ' : '▸ ') : r.kind === 'more' ? '' : '• '
769      const iconColor = r.kind === 'dir' ? (r.touched ? ACCENT : undefined) : colorOfFile(r.name)
770      return (
771        <Text key={`${keyOf(t.root)}:${i}`} wrap="truncate-end">
772          {cols.map((c, n) =>
773            c.on ? (
774              <Text key={`g${n}`} color={ACCENT}>
775                {c.text}
776              </Text>
777            ) : (
778              <Text key={`g${n}`} dimColor>
779                {c.text}
780              </Text>
781            ),
782          )}
783          {icon && <Text color={iconColor}>{icon}</Text>}
784          {r.mark ? (
785            <Text color={ACCENT} bold>
786              {r.name}
787            </Text>
788          ) : r.kind === 'more' || r.isSkipped ? (
789            <Text dimColor>{r.isSkipped ? `${r.name} …` : r.name}</Text>
790          ) : (
791            <Text bold={t.trail.includes(k) || r.touched > 0}>{r.name}</Text>
792          )}
793          {r.mark && (
794            <Text color={ACCENT}>
795              {' '}
796              {seqGlyph(r.mark.seq)}
797              {r.mark.count > 1 ? ` ×${r.mark.count}` : ''}
798            </Text>
799          )}
800          {r.mark?.isNew && <Text color={DONE}> new</Text>}
801          {r.kind === 'dir' && r.touched > 0 && <Text color={ACCENT}> ({r.touched})</Text>}
802          {isLast && <Text color={CLAY}> ▣</Text>}
803          {isLast && <Text color={ACCENT}> ◀ now</Text>}
804        </Text>
805      )
806    }
807
808    return (
809      <Box flexDirection="column">
810        {crumbs.length > 0 && (
811          <Text wrap="truncate-start">
812            <Text color={CLAY}>▣ </Text>
813            {crumbs.slice(0, -1).map((c, n) => (
814              <Text key={`c${n}`} dimColor>
815                {c} ›{' '}
816              </Text>
817            ))}
818            <Text color={ACCENT} bold>
819              {crumbs[crumbs.length - 1]}
820            </Text>
821          </Text>
822        )}
823        <Text dimColor>
824          {st.files} files · {st.folders} folders · {st.edits} edits
825          {lastMark ? ` · last ${seqGlyph(lastMark.seq)} ${lastMark.tool}` : ''}
826        </Text>
827        {controls}
828        {empty}
829        {trees.map(t => (
830          <Box key={`root-${keyOf(t.root)}`} flexDirection="column" marginTop={1}>
831            <Text wrap="truncate-end">
832              <Text color={touchedIn(t.root) ? ACCENT : undefined}>▾ </Text>
833              <Text bold>{baseName(t.root)}</Text>
834              <Text dimColor> {t.root}</Text>
835            </Text>
836            {t.rows.map((r, i) => line(t, r, i))}
837            {notes(t).map(s => (
838              <Text key={s} dimColor>
839                {s}
840              </Text>
841            ))}
842          </Box>
843        ))}
844      </Box>
845    )
846  })
847}
848
types/index.d.ts 46 lines
1export type EntryKind = 'file' | 'dir' | 'other'
2
3export type Entry = {
4  name: string
5  kind: EntryKind
6  /** A heavy folder (node_modules, .git, ...): listed, never descended. */
7  isSkipped?: boolean
8}
9
10/** One directory's listing, dirs first; `more` counts entries past the cap. */
11export type Listing = {
12  entries: Entry[]
13  more: number
14}
15
16export type EditMark = {
17  /** Absolute path, `/`-separated. */
18  path: string
19  tool: string
20  /** Order of first edit in the session, from 1. */
21  seq: number
22  count: number
23  isNew: boolean
24}
25
26export type View = {
27  mode: 'tree' | 'focus'
28  autoOpen: boolean
29  /** The folder whose edits stay quiet after the person closed the pane. */
30  quietFolder: string
31}
32
33declare module 'claude-code' {
34  interface PluginState {
35    'folder-map': {
36      /** Project roots shown, the one holding the latest edit first. */
37      roots: string[]
38      /** Listings keyed by directory path (lower-cased on Windows). */
39      dirs: Record<string, Listing>
40      edits: EditMark[]
41      last: string
42      view: View
43    }
44  }
45}
46