SLOPSHOPPER

file-tree

An IDE-style file tree that shows what Claude reads, edits and commits, with git status, search and a file preview

newpanebandguardcommandprocess
★ 4v0.1.0no licenseupdated 2026-10-11ha-ptt0601/cc-mods/file-tree
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · file-tree
│ ┃ Files ✕ › fix the failing auth test and add an audit log call │ ┃ app │ ┃ r: Refresh s: Search t: ◐ ide x: Close ⏺ Read(src/auth.ts) │ ┃ Search: file name ⎿ Read 6 lines │ ┃ click the tree, then ↑↓ move · → ← ⏺ Update(src/auth.ts) │ ┃ open/close · Enter preview ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ▣ client module ./rows.client.tsx ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /tree │ ⎿ file-tree: Files: ↑↓ move · → open · ← close · Enter preview · s │ │ ⟨Claude Code's own drawing⟩ ▤ Files ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ▤ Files
Pane · Files
app r: Refresh s: Search t: ◐ ide x: Close Search: file name click the tree, then ↑↓ move · → ← open/close · Enter preview ▣ client module ./rows.client.tsx
README

cc-mods

Small Claude Code mods: function-hook plugins that add panes, bands and commands inside Claude Code.

Mods

session-switcher

A sidebar listing the current project's sessions. Pick one to resume it.

The Sessions pane beside a conversation (example data)

  • ☰ Sessions (12) above the prompt, or /sessions, opens the pane with the keyboard on it
  • The pane's header counts the sessions, or the matches while searching (3 / 12)
  • 1–9 or Enter resumes a session, r refreshes, x closes, Esc goes back to the prompt
  • s jumps to the search field: typing filters the list by session name or worktree (case and diacritics ignored, so y tuong finds Ý tưởng), Enter resumes the first match
  • /sessions close hides the pane from the prompt
  • Sessions from the project's git worktrees are listed too, tagged with the worktree's name in cyan ([feat-search] 12m ago, lined up at the right edge)
  • A session is titled by its custom title, its AI title, or its first typed prompt; one with none of them is left out
  • The open session is marked ▸ (this session)

file-tree

An IDE-style file tree beside the conversation, showing what Claude reads, edits and commits.

The file tree in its three styles (example data)

  • ▤ Files above the prompt, or /tree, opens the pane (/tree close hides it); it docks beside the transcript in the fullscreen layout ("tui": "fullscreen" in settings, a terminal 110+ columns wide), else above the prompt
  • Click the tree once, then ↑/↓ move, → opens a folder, ← closes it, Enter or a double-click previews a file
  • Files Claude reads light up purple, edits orange, commits green; folders open to them
  • Git status per file and folder, +N -N line counts, branch and ahead/behind in the header
  • s searches every file, opened folders or not (case and diacritics ignored)
  • t cycles three styles: ide, minimal, rich
  • The preview draws Markdown or highlighted code; ↗ opens the file with your Open with command (code -g {path}, phpstorm {path}) or the default app
  • Settings in /config → file-tree: Style, Preview layout (replace or split), Open with, Follow Claude, Icons (nerd or unicode), Show ignored files

Install

/plugin install session-switcher --marketplace ha-ptt0601/cc-mods
/plugin install file-tree --marketplace ha-ptt0601/cc-mods

Answer y to add the marketplace, then pick the user scope.

Develop

Run a mod straight from its folder, reloading on save:

claude --plugin-dir ./file-tree     # or ./session-switcher

Check it:

claude plugin validate ./file-tree
claude plugin test ./file-tree
Source 14 files
hooks/register.tsx 584 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import { FADE_MS, endMarks, isCommit, pruneMarks, startMarks, statusLine, targetOf } from './activity'
5import { parseNumstat, parseStatus, rollUp, withNumstat } from './git'
6import { MAX_BYTES, clip, isBinary, isMarkdown, maxTop, openArgv, windowOf } from './preview'
7import type { RowInput, RowSpec, Seg } from './segments'
8import { effectiveStyle, isStyle, nextStyle, settingsOf } from './settings'
9import type { Settings } from './settings'
10import { STYLES } from './styles'
11import { flatten, foldersTo, isInside, parentOf, relative, searchRows, sortEntries } from './tree'
12import type { Entry, TreeRow } from './tree'
13
14const PANE = 'file-tree'
15const STYLE_KEY = 'styleOverride'
16
17const root = atom({ plugin: 'file-tree', key: 'root' } as const, '')
18const listing = atom({ plugin: 'file-tree', key: 'listing' } as const, {})
19const open = atom({ plugin: 'file-tree', key: 'open' } as const, [])
20const cursor = atom({ plugin: 'file-tree', key: 'cursor' } as const, '')
21const top = atom({ plugin: 'file-tree', key: 'top' } as const, 0)
22const query = atom({ plugin: 'file-tree', key: 'query' } as const, '')
23const files = atom({ plugin: 'file-tree', key: 'files' } as const, [])
24const marks = atom({ plugin: 'file-tree', key: 'marks' } as const, [])
25const git = atom({ plugin: 'file-tree', key: 'git' } as const, null)
26const note = atom({ plugin: 'file-tree', key: 'note' } as const, '')
27const styleName = atom({ plugin: 'file-tree', key: 'style' } as const, 'ide')
28const preview = atom({ plugin: 'file-tree', key: 'preview' } as const, '')
29const previewText = atom({ plugin: 'file-tree', key: 'previewText' } as const, '')
30const previewNote = atom({ plugin: 'file-tree', key: 'previewNote' } as const, '')
31const previewTop = atom({ plugin: 'file-tree', key: 'previewTop' } as const, 0)
32
33let settings: Settings = settingsOf(undefined)
34/** Rows the tree had room for at the last drawing, for keeping the cursor in view. */
35let room = 20
36/** Lines the preview had room for at the last drawing. */
37let previewRoom = 20
38/** The platform, for the default opener: found on first use. */
39let os: 'darwin' | 'linux' | 'win32' | undefined
40/** The repository's top folder: undefined until checked, null outside a repo. */
41let gitTop: string | null | undefined
42
43/**
44 * Lists one folder for the tree, leaving out (or flagging) what git ignores.
45 */
46async function listDir($: EngineInterface, dir: string): Promise<Entry[]> {
47  const raw = await $.fs.list(dir)
48  let ignored = new Set<string>()
49  if (gitTop && raw.length) {
50    const names = raw.map(e => (e.kind === 'dir' ? `${e.name}/` : e.name))
51    const run = await $.process.run(['git', '--no-optional-locks', '-C', dir, 'check-ignore', '--', ...names], { timeoutMs: 5000 })
52      .catch(() => ({ exitCode: 1, stdout: '' }))
53    ignored = new Set(run.stdout.split('\n').filter(Boolean).map(p => p.replace(/\/$/, '').split('/').pop() ?? p))
54  }
55
56  return sortEntries(raw, ignored, settings.showIgnored)
57}
58
59/**
60 * Lists `dirs` again (each one listed before, or asked for), keeping the rest.
61 */
62async function relist($: EngineInterface, dirs: readonly string[]) {
63  const lists = await Promise.all(dirs.map(async dir => [dir, await listDir($, dir).catch(() => null)] as const))
64  await update($, listing, all => {
65    const next = { ...all }
66    for (const [dir, entries] of lists) if (entries) next[dir] = entries
67
68    return next
69  })
70}
71
72let gitTimer: Timer | null = null
73
74/**
75 * Reads git status and line counts for the root's repository into state;
76 * outside a repository, clears them.
77 */
78async function refreshGit($: EngineInterface) {
79  if (!gitTop) {
80    await update($, git, () => null)
81
82    return
83  }
84  const base = ['git', '--no-optional-locks', '-C', gitTop]
85  const [status, numstat] = await Promise.all([
86    $.process.run([...base, 'status', '--porcelain=v2', '--branch', '-z'], { timeoutMs: 8000 }),
87    $.process.run([...base, 'diff', '--numstat', '-z', 'HEAD'], { timeoutMs: 8000 }).catch(() => ({ exitCode: 1, stdout: '' })),
88  ])
89  if (status.exitCode !== 0) return
90  const repo = gitTop
91  await update($, git, () => withNumstat(parseStatus(status.stdout, repo), parseNumstat(numstat.stdout, repo)))
92}
93
94/**
95 * Refreshes git once a burst of changes has settled (300 ms).
96 */
97function scheduleGit($: EngineInterface) {
98  gitTimer?.cancel()
99  gitTimer = $.clock.after(300, () => {
100    void refreshGit($).catch(() => undefined)
101  })
102}
103
104/**
105 * Every file under the root, relative to it, for search: git's list in a
106 * repository (tracked and untracked, ignored left out), else `find`.
107 */
108async function loadFiles($: EngineInterface) {
109  const r = await read($, root)
110  const run = gitTop
111    ? await $.process.run(['git', '-C', r, 'ls-files', '--cached', '--others', '--exclude-standard', '-z'], { timeoutMs: 10000 })
112    : await $.process.run(['find', r, '-maxdepth', '8', '-type', 'f', '-not', '-path', '*/.git/*', '-not', '-path', '*/node_modules/*'], { timeoutMs: 10000 })
113  const list = gitTop
114    ? run.stdout.split('\0').filter(Boolean)
115    : run.stdout.split('\n').filter(Boolean).map(p => relative(r, p))
116  await update($, files, () => list.slice(0, 50000))
117}
118
119/**
120 * Re-roots the tree on the session's working directory: lists its top folder
121 * and forgets what was open.
122 */
123async function reset($: EngineInterface) {
124  const cwd = await $.session.cwd()
125  const check = await $.process.run(['git', '-C', cwd, 'rev-parse', '--show-toplevel'], { timeoutMs: 5000 })
126    .catch(() => ({ exitCode: 1, stdout: '' }))
127  gitTop = check.exitCode === 0 ? check.stdout.trim() : null
128  await update($, root, () => cwd)
129  await update($, listing, () => ({}))
130  await update($, open, () => [])
131  await update($, cursor, () => '')
132  await update($, top, () => 0)
133  await update($, query, () => '')
134  await update($, files, () => [])
135  await refreshGit($).catch(() => undefined)
136  await relist($, [cwd])
137}
138
139/**
140 * Opens or closes a folder, listing it the first time it opens.
141 */
142async function toggle($: EngineInterface, dir: string) {
143  const isOpen = (await read($, open)).includes(dir)
144  if (isOpen) {
145    await update($, open, list => list.filter(d => d !== dir))
146
147    return
148  }
149  if (!(await read($, listing))[dir]) await relist($, [dir])
150  await update($, open, list => [...list, dir])
151}
152
153/**
154 * The rows on screen now: the search's, or the tree's.
155 */
156async function treeRows($: EngineInterface): Promise<TreeRow[]> {
157  const r = await read($, root)
158  const q = await read($, query)
159
160  return q.trim() ? searchRows(r, await read($, files), q) : flatten(r, await read($, listing), await read($, open))
161}
162
163/**
164 * Moves the cursor to `path` and scrolls so that it shows.
165 */
166async function moveTo($: EngineInterface, rows: readonly TreeRow[], path: string) {
167  const at = rows.findIndex(row => row.path === path)
168  if (at < 0) return
169  await update($, cursor, () => path)
170  await update($, top, t => (at < t ? at : at >= t + room ? at - room + 1 : t))
171  const row = rows[at]
172  if (settings.layout === 'split' && (await read($, preview)) && row?.kind === 'file') await showPreview($, row.path).catch(() => undefined)
173}
174
175/**
176 * Opens the folders down to `path` and, when following Claude, scrolls to it.
177 */
178async function reveal($: EngineInterface, path: string) {
179  const r = await read($, root)
180  const dirs = foldersTo(r, path)
181  const listed = await read($, listing)
182  const missing = dirs.filter(dir => !listed[dir])
183  if (missing.length) await relist($, missing)
184  await update($, open, list => [...new Set([...list, ...dirs])])
185  if (!settings.follow || (await read($, query)).trim()) return
186  const rows = await treeRows($)
187  const at = rows.findIndex(row => row.path === path)
188  if (at >= 0) await update($, top, t => (at < t || at >= t + room ? Math.max(0, at - Math.floor(room / 2)) : t))
189}
190
191/**
192 * Drops the marks that finished fading.
193 */
194async function prune($: EngineInterface) {
195  const now = await $.clock.now()
196  await update($, marks, list => pruneMarks(list, now))
197}
198
199/**
200 * After a shell command: marks a new commit's files green, and refreshes the
201 * open folders and git.
202 */
203async function afterBash($: EngineInterface, command: string, failed: boolean) {
204  const r = await read($, root)
205  if (!r) return
206  if (!failed && gitTop && isCommit(command)) {
207    const run = await $.process.run(['git', '-C', gitTop, 'diff-tree', '--no-commit-id', '--name-only', '-r', '-z', 'HEAD'], { timeoutMs: 5000 })
208    const repo = gitTop
209    const paths = run.stdout.split('\0').filter(Boolean).map(p => `${repo}/${p}`).filter(p => isInside(r, p))
210    const now = await $.clock.now()
211    await update($, marks, list => endMarks(startMarks(list, paths, 'commit'), paths, now))
212    if (paths[0]) await reveal($, paths[0])
213    $.clock.after(FADE_MS + 100, () => void prune($))
214  }
215  await relist($, [r, ...(await read($, open))])
216  scheduleGit($)
217}
218
219/**
220 * Reads `path` into the preview: refused when outside the root, too large or
221 * binary, cut to whole lines when long.
222 */
223async function showPreview($: EngineInterface, path: string) {
224  const r = await read($, root)
225  let text = ''
226  let why = ''
227  if (!isInside(r, path)) why = 'outside the tree'
228  else {
229    const st = await $.fs.stat(path)
230    if (st.size > MAX_BYTES) why = `${Math.round(st.size / 1e6)} MB, too large to preview`
231    else {
232      const raw = await $.fs.read(path)
233      if (isBinary(raw)) why = 'binary file'
234      else {
235        const cut = clip(raw)
236        text = cut.text
237        why = cut.note
238      }
239    }
240  }
241  await update($, preview, () => path)
242  await update($, previewText, () => text)
243  await update($, previewNote, () => why)
244  await update($, previewTop, () => 0)
245}
246
247/**
248 * Closes the preview and returns to the tree.
249 */
250async function closePreview($: EngineInterface) {
251  await update($, preview, () => '')
252  await update($, previewText, () => '')
253}
254
255/**
256 * Scrolls the preview by `by` lines, clamped.
257 */
258async function scrollPreview($: EngineInterface, by: number) {
259  const last = maxTop(await read($, previewText), previewRoom)
260  await update($, previewTop, t => Math.max(0, Math.min(last, t + by)))
261}
262
263/**
264 * Opens a file with the Open with command or the default app.
265 */
266async function openExternal($: EngineInterface, path: string) {
267  if (!os) {
268    const uname = await $.process.run(['uname', '-s'], { timeoutMs: 3000 }).catch(() => ({ exitCode: 1, stdout: '' }))
269    os = uname.exitCode !== 0 ? 'win32' : uname.stdout.trim() === 'Darwin' ? 'darwin' : 'linux'
270  }
271  const run = await $.process.run(openArgv(settings.openWith, path, os), { timeoutMs: 10000 })
272    .catch((error: unknown) => ({ exitCode: 1, stdout: '', stderr: String(error) }))
273  if (run.exitCode !== 0) await update($, note, () => `Could not open ${path.split('/').pop()}: ${run.stderr.trim().slice(0, 80)}`)
274}
275
276/**
277 * Moves to the next style and remembers it over the /config value it overrides.
278 */
279async function cycleStyle($: EngineInterface) {
280  const was = await read($, styleName)
281  const next = nextStyle(isStyle(was) ? was : 'ide')
282  await $.store.set(STYLE_KEY, { style: next, over: settings.style })
283  await update($, styleName, () => next)
284}
285
286/**
287 * Acts on a key the rows region sent: move, open, close, page.
288 */
289async function onKey($: EngineInterface, key: string) {
290  // The toolbar's hotkeys reach the rows region instead while it has the focus.
291  if (key === 't') return cycleStyle($)
292  if (key === 's' || key === '/') return void (await $.ui.focus({ requestId: PANE, key: 'search' }))
293  if (key === 'r') return reset($)
294  if (key === 'x') return void (await $.ui.close({ id: PANE }))
295  const shown = await read($, preview)
296  if (shown && settings.layout === 'replace') {
297    const page = Math.max(1, previewRoom - 2)
298    const by: Record<string, number> = { up: -1, k: -1, down: 1, j: 1, pageup: -page, pagedown: page, home: -1e9, end: 1e9 }
299    if (key === 'left' || key === 'h' || key === 'escape') await closePreview($)
300    else if (key in by) await scrollPreview($, by[key] ?? 0)
301
302    return
303  }
304  if (shown && (key === 'pageup' || key === 'pagedown')) {
305    await scrollPreview($, (key === 'pageup' ? -1 : 1) * Math.max(1, previewRoom - 2))
306
307    return
308  }
309  const rows = await treeRows($)
310  const cur = await read($, cursor)
311  const at = Math.max(0, rows.findIndex(row => row.path === cur))
312  const row = rows[at]
313  const go = (delta: number) => {
314    const target = rows[Math.max(0, Math.min(rows.length - 1, at + delta))]
315
316    return target ? moveTo($, rows, target.path) : Promise.resolve()
317  }
318  if (key === 'up' || key === 'k') await go(-1)
319  else if (key === 'down' || key === 'j') await go(1)
320  else if (key === 'pageup') await go(-room)
321  else if (key === 'pagedown') await go(room)
322  else if (key === 'home') await go(-rows.length)
323  else if (key === 'end') await go(rows.length)
324  else if (!row) return
325  else if ((key === 'right' || key === 'l') && row.kind === 'dir' && !row.isOpen) await toggle($, row.path)
326  else if (key === 'left' || key === 'h') {
327    if (row.kind === 'dir' && row.isOpen) await toggle($, row.path)
328    else if (parentOf(row.path) !== (await read($, root))) await moveTo($, rows, parentOf(row.path))
329  } else if (key === 'return' || key === ' ') {
330    if (row.kind === 'dir') await toggle($, row.path)
331    else await showPreview($, row.path).catch(error => update($, note, () => `Could not read ${row.name}: ${String(error)}`))
332  }
333}
334
335/**
336 * The rows with their git status, folder counts and Claude's marks.
337 */
338async function decorate($: EngineInterface, rows: readonly TreeRow[]): Promise<RowInput[]> {
339  const snap = await read($, git)
340  const counts = snap ? rollUp(snap.files, await read($, root)) : {}
341  const live = await read($, marks)
342
343  return rows.map(row => {
344    const fileGit = snap?.files[row.path]
345    const folder = counts[row.path]
346    const mark = live.find(m => m.path === row.path)
347
348    return { ...row, ...(fileGit ? { git: fileGit } : {}), ...(folder ? { counts: folder } : {}), ...(mark ? { mark } : {}) }
349  })
350}
351
352/**
353 * Opens the pane, or brings it forward, with the keyboard on it; lists the
354 * working directory the first time or when it changed.
355 */
356async function show($: EngineInterface): Promise<string> {
357  if ((await read($, root)) !== (await $.session.cwd())) await reset($)
358  await $.ui.open({ id: PANE, title: 'Files', focus: true, columns: 48 })
359
360  return 'Files: ↑↓ move · → open · ← close · Enter preview · s search · t style · x close'
361}
362
363export const register: Register = (on, options) => {
364  settings = settingsOf(options)
365
366  on('session.start', async ($, e, next) => {
367    const started = await next(e)
368    await $.command.register({
369      name: 'tree',
370      description: 'Open the file tree (close: hide it)',
371      argumentHint: '[close]',
372    })
373    const style = effectiveStyle(settings.style, await $.store.get(STYLE_KEY).catch(() => undefined))
374    await update($, styleName, () => style)
375
376    return started
377  })
378
379  on('command.run', { command: 'tree' }, async ($, e) => {
380    if (/^(close|hide|x)$/i.test(e.args.trim())) {
381      await $.ui.close({ id: PANE })
382
383      return { text: 'File tree closed.' }
384    }
385
386    return { text: await show($) }
387  })
388
389  on('classic.SessionStart', async ($, e, next) => {
390    const result = await next(e)
391    const run = await $.process.run(['git', '-C', e.cwd, 'rev-parse', '--absolute-git-dir'], { timeoutMs: 5000 })
392      .catch(() => ({ exitCode: 1, stdout: '' }))
393    const dir = run.exitCode === 0 ? run.stdout.trim() : ''
394
395    return dir ? { ...result, watchPaths: [...(result.watchPaths ?? []), `${dir}/index`, `${dir}/HEAD`] } : result
396  })
397
398  on('classic.FileChanged', async ($, e, next) => {
399    const result = await next(e)
400    if (/\/(index|HEAD)$/.test(e.file_path)) scheduleGit($)
401
402    return result
403  })
404
405  on('tool.call', async ($, e, next) => {
406    let target: { kind: 'read' | 'write' | 'commit'; path: string } | null = null
407    try {
408      const r = await read($, root)
409      const found = r ? targetOf(e as unknown as { tool: string } & Record<string, unknown>, await $.session.cwd()) : null
410      if (found && isInside(r, found.path)) {
411        target = found
412        await update($, marks, list => startMarks(list, [found.path], found.kind))
413        await reveal($, found.path)
414      }
415    } catch {
416      target = null
417    }
418    const result = await next(e)
419    try {
420      if (target) {
421        const done = target
422        const now = await $.clock.now()
423        await update($, marks, list => endMarks(list, [done.path], now))
424        $.clock.after(FADE_MS + 100, () => void prune($))
425        if (done.kind === 'write') {
426          await relist($, [parentOf(done.path)])
427          scheduleGit($)
428        }
429      }
430      if (e.tool === 'Bash') void afterBash($, e.command, Boolean(result.deny || result.isError)).catch(() => undefined)
431    } catch {
432      // The tree's bookkeeping never changes the tool's result.
433    }
434
435    return result
436  })
437
438  on('ui.message', async ($, e, next) => {
439    if (e.requestId !== PANE || e.element !== 'rows' || !e.data || typeof e.data !== 'object') return next(e)
440    const data = e.data as { key?: unknown; press?: unknown; open?: unknown }
441    const rows = await treeRows($)
442    if (typeof data.key === 'string') await onKey($, data.key)
443    else if (typeof data.press === 'string') {
444      await moveTo($, rows, data.press)
445      const row = rows.find(r => r.path === data.press)
446      if (row?.kind === 'dir') await toggle($, row.path)
447    } else if (typeof data.open === 'string') {
448      const row = rows.find(r => r.path === data.open)
449      if (row?.kind === 'file') await showPreview($, row.path).catch(() => undefined)
450      else if (row) await toggle($, row.path)
451    }
452
453    return {}
454  })
455
456  on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e) => {
457    if (await read($, preview)) {
458      await scrollPreview($, Math.sign(e.by) * Math.max(3, Math.abs(e.by)))
459
460      return {}
461    }
462    const total = (await treeRows($)).length
463    await update($, top, t => Math.max(0, Math.min(Math.max(0, total - room), t + Math.sign(e.by) * Math.max(3, Math.abs(e.by)))))
464
465    return {}
466  })
467
468  // Drawn beside what other plugins put above the prompt, not over it.
469  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
470    if (e.props.hasSurvey) return next(e)
471    const below = await next(e)
472    const { Box, Button } = $.ui.resolve(e)
473
474    return (
475      <Box gap={2}>
476        {below}
477        <Button key="files" plain label="▤ Files" dimColor onPress={() => void show($)} />
478      </Box>
479    )
480  })
481
482  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
483    if (e.surface !== 'terminal' && e.surface !== 'desktop') {
484      const { Text } = $.ui.resolve(e)
485
486      return <Text dimColor>The file tree draws in the terminal and the desktop app.</Text>
487    }
488    const { Box, Button, Client, Input, Text } = $.ui.resolve(e)
489    const current = await read($, styleName)
490    const style = STYLES[isStyle(current) ? current : 'ide']
491    const ctx = { icons: settings.icons, now: await $.clock.now() }
492    const r = await read($, root)
493    const snap = await read($, git)
494    const header = style.header({ name: r.split('/').pop() || r, git: snap, dirty: Boolean(snap && Object.keys(snap.files).length), status: statusLine(await read($, marks), r) }, ctx)
495    const shown = await read($, preview)
496    const body = Math.max(6, (e.props.scroll?.bodyRows ?? 24) - header.length - 4)
497    const isSplit = Boolean(shown) && settings.layout === 'split'
498    room = isSplit ? Math.max(3, Math.floor(body / 2)) : body
499    previewRoom = shown ? (isSplit ? body - room - 2 : body - 2) : previewRoom
500    const all = await decorate($, await treeRows($))
501    const from = Math.max(0, Math.min(await read($, top), Math.max(0, all.length - room)))
502    const specs = all.slice(from, from + room).map(row => style.row(row, ctx))
503    const search = await read($, query)
504    const message = await read($, note)
505    const text = await read($, previewText)
506    const win = windowOf(text, await read($, previewTop), previewRoom)
507    const previewProps = { rows: [], active: '', preview: { text: isMarkdown(shown) ? text : win.source, path: shown, startLine: win.startLine, markdown: isMarkdown(shown) } }
508    const snapFile = shown ? snap?.files[shown] : undefined
509    const facts = [
510      `${text.split('\n').length} lines`,
511      snapFile ? `${snapFile.status} +${snapFile.added ?? 0} -${snapFile.removed ?? 0}` : '',
512      await read($, previewNote),
513    ].filter(Boolean).join(' · ')
514    const previewBar = (
515      <Box key="pbar" justifyContent="space-between">
516        <Box gap={1}>
517          {!isSplit && <Button key="back" plain label="← files" onPress={() => void closePreview($)} />}
518          <Text bold>{shown.split('/').pop()}</Text>
519          <Text dimColor>{facts}</Text>
520        </Box>
521        <Box gap={1}>
522          <Button key="open-ext" plain label="↗" onPress={() => void openExternal($, shown)} />
523          {isSplit && <Button key="close-preview" plain label="✕" onPress={() => void closePreview($)} />}
524        </Box>
525      </Box>
526    )
527    const tree = <Client key="rows" module="./rows.client.tsx" props={{ rows: specs, active: await read($, cursor) }} />
528
529    return (
530      <Box flexDirection="column">
531        {header.map((spec: RowSpec, i: number) => (
532          <Box key={`h${i}`} justifyContent="space-between">
533            <Box>{spec.left.map((s: Seg) => <Text color={s.c} bold={s.b} dimColor={s.dim}>{s.t}</Text>)}</Box>
534            <Box>{spec.right.map((s: Seg) => <Text color={s.c} bold={s.b} dimColor={s.dim}>{s.t}</Text>)}</Box>
535          </Box>
536        ))}
537        <Box gap={2}>
538          <Button key="refresh" plain hotkey="r" label="Refresh" dimColor onPress={() => void reset($)} />
539          <Button key="find" plain hotkey="s" label="Search" dimColor onPress={() => void $.ui.focus({ requestId: PANE, key: 'search' })} />
540          <Button
541            key="style"
542            plain
543            hotkey="t"
544            label={`◐ ${current}`}
545            dimColor
546            onPress={() => void cycleStyle($)}
547          />
548          <Button key="close" plain hotkey="x" label="Close" dimColor onPress={() => void $.ui.close({ id: PANE })} />
549        </Box>
550        <Input
551          key="search"
552          label="Search"
553          placeholder="file name"
554          value={search}
555          onInput={(value: string) => {
556            void (async () => {
557              await update($, query, () => value)
558              await update($, top, () => 0)
559              if (value.trim() && (await read($, files)).length === 0) await loadFiles($).catch(() => undefined)
560            })()
561          }}
562          onSubmit={() => {
563            void (async () => {
564              const rows = await treeRows($)
565              const first = rows.find(row => row.kind === 'file')
566              if (first) await moveTo($, rows, first.path)
567            })()
568          }}
569        />
570        {/* The rows region takes the keyboard only from a click; the engine cannot hand it over. */}
571        <Text dimColor>click the tree, then ↑↓ move · → ← open/close · Enter preview</Text>
572        {message !== '' && <Text color="warning">{message}</Text>}
573        {shown && !isSplit && previewBar}
574        {shown && !isSplit
575          ? <Client key="rows" module="./rows.client.tsx" props={previewProps} />
576          : tree}
577        {isSplit && <Text key="div" dimColor>{'─'.repeat(Math.max(10, (e.props.bodyColumns ?? 40) - 2))}</Text>}
578        {isSplit && previewBar}
579        {isSplit && <Client key="preview" module="./rows.client.tsx" props={previewProps} />}
580      </Box>
581    )
582  })
583}
584
hooks/activity.ts 75 lines
1import type { Kind, Mark } from '../types'
2import { join, relative } from './tree'
3
4export type { Kind, Mark }
5
6/** How long a mark stays tinted after its tool call ended. */
7export const FADE_MS = 4000
8
9const READ_KEYS: Record<string, string> = { Read: 'file_path', Grep: 'path', Glob: 'path' }
10const WRITE_KEYS: Record<string, string> = { Edit: 'file_path', Write: 'file_path', NotebookEdit: 'notebook_path' }
11
12/**
13 * `path` made absolute against `cwd`, without a trailing slash.
14 */
15export function absolute(cwd: string, path: string): string {
16  const full = path.startsWith('/') ? path : join(cwd, path.replace(/^\.\//, ''))
17
18  return full.replace(/\/+$/, '') || '/'
19}
20
21/**
22 * The file or folder a tool call reads or writes, or null for any other tool.
23 * Grep and Glob with no path search the working directory.
24 */
25export function targetOf(input: { tool: string } & Record<string, unknown>, cwd: string): { kind: Kind; path: string } | null {
26  const readKey = READ_KEYS[input.tool]
27  const writeKey = WRITE_KEYS[input.tool]
28  const key = readKey ?? writeKey
29  if (!key) return null
30  const raw = input[key]
31  const path = typeof raw === 'string' && raw !== '' ? raw : input.tool === 'Grep' || input.tool === 'Glob' ? cwd : ''
32  if (!path) return null
33
34  return { kind: writeKey ? 'write' : 'read', path: absolute(cwd, path) }
35}
36
37/**
38 * Whether a shell command line runs `git commit` (as a command, not as text).
39 */
40export function isCommit(command: string): boolean {
41  return /(^|[;&|(]\s*)git(\s+-C\s+\S+)*\s+commit(\s|$)/.test(command)
42}
43
44/**
45 * The marks with a live one on each of `paths`, replacing marks on them.
46 */
47export function startMarks(marks: readonly Mark[], paths: readonly string[], kind: Kind): Mark[] {
48  return [...marks.filter(m => !paths.includes(m.path)), ...paths.map(path => ({ path, kind, isLive: true, until: 0 }))]
49}
50
51/**
52 * The marks with those on `paths` turned to fading, until `now + FADE_MS`.
53 */
54export function endMarks(marks: readonly Mark[], paths: readonly string[], now: number): Mark[] {
55  return marks.map(m => (m.isLive && paths.includes(m.path) ? { ...m, isLive: false, until: now + FADE_MS } : m))
56}
57
58/**
59 * The marks still showing at `now`.
60 */
61export function pruneMarks(marks: readonly Mark[], now: number): Mark[] {
62  return marks.filter(m => m.isLive || m.until > now)
63}
64
65const VERB: Record<Kind, string> = { read: 'reading', write: 'editing', commit: 'committing' }
66
67/**
68 * The rich style's header line: what Claude is doing to which file now, or ''.
69 */
70export function statusLine(marks: readonly Mark[], root: string): string {
71  const live = marks.filter(m => m.isLive).at(-1)
72
73  return live ? `Claude is ${VERB[live.kind]} ${relative(root, live.path) || '.'}` : ''
74}
75
hooks/git.ts 95 lines
1import type { FileGit, GitSnapshot, GitStatus } from '../types'
2import { isInside, join, parentOf } from './tree'
3
4export type { FileGit, GitSnapshot, GitStatus }
5export type Counts = { M: number; U: number; D: number }
6
7/**
8 * A file's status from porcelain v2's `XY`: deleted, added, else modified.
9 */
10function xyStatus(xy: string): GitStatus {
11  if (xy.includes('D')) return 'D'
12  if (xy.startsWith('A')) return 'A'
13
14  return 'M'
15}
16
17/**
18 * Reads `git status --porcelain=v2 --branch -z`: the branch line facts and a
19 * status per changed path, keyed by absolute path under `top`.
20 */
21export function parseStatus(out: string, top: string): GitSnapshot {
22  const snap: GitSnapshot = { branch: '', upstream: '', ahead: 0, behind: 0, files: {} }
23  const fields = out.split('\0')
24  const put = (path: string, status: GitStatus) => {
25    snap.files[join(top, path.replace(/\/+$/, ''))] = { status }
26  }
27  for (let i = 0; i < fields.length; i++) {
28    const field = fields[i] ?? ''
29    if (field.startsWith('# branch.head ')) snap.branch = field.slice('# branch.head '.length)
30    else if (field.startsWith('# branch.upstream ')) snap.upstream = field.slice('# branch.upstream '.length)
31    else if (field.startsWith('# branch.ab ')) {
32      const [ahead, behind] = field.slice('# branch.ab '.length).split(' ')
33      snap.ahead = Math.abs(Number(ahead) || 0)
34      snap.behind = Math.abs(Number(behind) || 0)
35    } else if (field.startsWith('1 ')) put(field.split(' ').slice(8).join(' '), xyStatus(field.split(' ')[1] ?? ''))
36    else if (field.startsWith('2 ')) {
37      put(field.split(' ').slice(9).join(' '), 'R')
38      i++
39    } else if (field.startsWith('u ')) put(field.split(' ').slice(10).join(' '), '!')
40    else if (field.startsWith('? ')) put(field.slice(2), 'U')
41  }
42
43  return snap
44}
45
46/**
47 * Reads `git diff --numstat -z HEAD`: added and removed lines per path, keyed
48 * by absolute path under `top`; a binary file counts as 0 and 0.
49 */
50export function parseNumstat(out: string, top: string): Record<string, { added: number; removed: number }> {
51  const stats: Record<string, { added: number; removed: number }> = {}
52  const fields = out.split('\0')
53  for (let i = 0; i < fields.length; i++) {
54    const m = /^(\d+|-)\t(\d+|-)\t([\s\S]*)$/.exec(fields[i] ?? '')
55    if (!m) continue
56    let path = m[3] ?? ''
57    if (path === '') {
58      path = fields[i + 2] ?? ''
59      i += 2
60    }
61    stats[join(top, path)] = { added: Number(m[1]) || 0, removed: Number(m[2]) || 0 }
62  }
63
64  return stats
65}
66
67/**
68 * The snapshot with each file's line counts filled in.
69 */
70export function withNumstat(snap: GitSnapshot, stats: Record<string, { added: number; removed: number }>): GitSnapshot {
71  const files: Record<string, FileGit> = {}
72  for (const [path, git] of Object.entries(snap.files)) files[path] = { ...git, ...(stats[path] ?? {}) }
73
74  return { ...snap, files }
75}
76
77/**
78 * Per folder under `root` (itself included), how many changed files it holds:
79 * modified (renamed and conflicted too), untracked or added, deleted.
80 */
81export function rollUp(files: Record<string, FileGit>, root: string): Record<string, Counts> {
82  const counts: Record<string, Counts> = {}
83  for (const [path, git] of Object.entries(files)) {
84    if (!isInside(root, path) || path === root) continue
85    const bucket: keyof Counts = git.status === 'U' || git.status === 'A' ? 'U' : git.status === 'D' ? 'D' : 'M'
86    for (let dir = parentOf(path); isInside(root, dir); dir = parentOf(dir)) {
87      const c = (counts[dir] ??= { M: 0, U: 0, D: 0 })
88      c[bucket]++
89      if (dir === root) break
90    }
91  }
92
93  return counts
94}
95
hooks/preview.ts 75 lines
1import { parentOf } from './tree'
2
3/** The most text one preview holds (the Code element draws 100000 at most). */
4export const MAX_CHARS = 90_000
5
6/** Files larger than this are not read (`$.fs.read` refuses over 4 MiB). */
7export const MAX_BYTES = 4_000_000
8
9/**
10 * Whether the file is drawn as Markdown rather than as code.
11 */
12export function isMarkdown(path: string): boolean {
13  return /\.(md|mdx|markdown)$/i.test(path)
14}
15
16/**
17 * Whether text holds a NUL byte: a binary file, not shown.
18 */
19export function isBinary(text: string): boolean {
20  return text.includes('\u0000')
21}
22
23/**
24 * Text cut to whole lines under MAX_CHARS, with a note when cut.
25 */
26export function clip(text: string): { text: string; note: string } {
27  if (text.length <= MAX_CHARS) return { text, note: '' }
28  const cut = text.slice(0, MAX_CHARS)
29  const end = cut.lastIndexOf('\n')
30  const kept = end > 0 ? cut.slice(0, end) : cut
31
32  return { text: kept, note: `first ${kept.split('\n').length} lines` }
33}
34
35/**
36 * The highest first line a window of `rows` can start at.
37 */
38export function maxTop(text: string, rows: number): number {
39  return Math.max(0, text.split('\n').length - rows)
40}
41
42/**
43 * The `rows` lines from line `top` (0-based, clamped), and the 1-based number
44 * of the first.
45 */
46export function windowOf(text: string, top: number, rows: number): { source: string; startLine: number } {
47  const lines = text.split('\n')
48  const from = Math.max(0, Math.min(top, lines.length - rows))
49
50  return { source: lines.slice(from, from + rows).join('\n'), startLine: from + 1 }
51}
52
53/**
54 * A command line split on spaces, single or double quotes keeping a word whole.
55 */
56export function splitWords(command: string): string[] {
57  return [...command.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map(m => m[1] ?? m[2] ?? m[3] ?? '')
58}
59
60/**
61 * What the ↗ button runs: the Open with command with `{path}` and `{dir}`
62 * filled (the path last when it holds no `{path}`), or the platform's opener.
63 */
64export function openArgv(setting: string, path: string, os: 'darwin' | 'linux' | 'win32'): string[] {
65  if (!setting.trim()) {
66    if (os === 'darwin') return ['open', '--', path]
67
68    return os === 'win32' ? ['cmd', '/c', 'start', '', path] : ['xdg-open', path]
69  }
70  const words = splitWords(setting)
71  const argv = words.map(w => w.replaceAll('{path}', path).replaceAll('{dir}', parentOf(path)))
72
73  return words.some(w => w.includes('{path}')) ? argv : [...argv, path]
74}
75
hooks/segments.ts 88 lines
1import type { Counts, FileGit, GitSnapshot, GitStatus } from './git'
2import type { Kind, Mark } from './activity'
3import type { TreeRow } from './tree'
4
5/** One run of text in a row: color (a theme key or hex), and how it lights. */
6export type Seg = {
7  t: string
8  c?: string
9  b?: boolean
10  dim?: boolean
11  /** Claude's activity tone; with `sweep` a light band runs across the text. */
12  tone?: Kind
13  sweep?: boolean
14  /** Shown on alternate animation frames. */
15  blink?: boolean
16}
17
18/** One drawn row: `id` is the path a click reports ('' for no click). */
19export type RowSpec = { id: string; left: Seg[]; right: Seg[] }
20
21/** What a style draws a row from. */
22export type RowInput = TreeRow & { git?: FileGit; counts?: Counts; mark?: Mark }
23
24/** What a style draws the header from. */
25export type HeaderInput = { name: string; git: GitSnapshot | null; dirty: boolean; status: string }
26
27export type StyleCtx = { icons: 'nerd' | 'unicode'; now: number }
28
29export type Style = {
30  row: (r: RowInput, ctx: StyleCtx) => RowSpec
31  header: (h: HeaderInput, ctx: StyleCtx) => RowSpec[]
32}
33
34/** Theme keys per git status, so colors follow the person's theme. */
35export const GIT_COLOR: Record<GitStatus, string> = { M: 'warning', A: 'success', U: 'success', D: 'error', R: 'suggestion', '!': 'error' }
36
37/**
38 * The status a folder shows: the first of modified, untracked, deleted it holds.
39 */
40export function folderStatus(counts: Counts | undefined): GitStatus | undefined {
41  if (!counts) return undefined
42  if (counts.M > 0) return 'M'
43  if (counts.U > 0) return 'U'
44
45  return counts.D > 0 ? 'D' : undefined
46}
47
48/**
49 * The row's name segment: dim when ignored, colored by git, lit by a mark
50 * (sweeping while live when `sweep` is asked for, tinted while fading).
51 */
52export function nameSeg(r: RowInput, sweep: boolean, base: Seg = { t: '' }): Seg {
53  const status = r.kind === 'dir' ? folderStatus(r.counts) : r.git?.status
54  const seg: Seg = { ...base, t: r.name, ...(status ? { c: GIT_COLOR[status] } : {}), ...(r.isIgnored ? { dim: true } : {}) }
55  if (!r.mark) return seg
56
57  return { ...seg, tone: r.mark.kind, ...(r.mark.isLive && sweep ? { sweep: true } : {}) }
58}
59
60/**
61 * How long ago `ms` was, in the largest whole unit: `5m`, `3h`, `2d`, `4w`.
62 */
63export function age(ms: number, now: number): string {
64  const minutes = Math.max(0, Math.round((now - ms) / 60000))
65  if (minutes < 60) return `${minutes}m`
66  if (minutes < 60 * 24) return `${Math.round(minutes / 60)}h`
67  if (minutes < 60 * 24 * 14) return `${Math.round(minutes / 60 / 24)}d`
68
69  return `${Math.round(minutes / 60 / 24 / 7)}w`
70}
71
72/**
73 * The header's first line, shared by the styles: the root's name on the
74 * left, branch, ahead/behind and a dot for uncommitted changes on the right.
75 */
76export function titleRow(h: HeaderInput, title: Seg[]): RowSpec {
77  const right: Seg[] = h.git
78    ? [
79        { t: h.git.branch || '(detached)', c: 'suggestion' },
80        ...(h.git.ahead ? [{ t: ` ↑${h.git.ahead}`, dim: true }] : []),
81        ...(h.git.behind ? [{ t: ` ↓${h.git.behind}`, dim: true }] : []),
82        ...(h.dirty ? [{ t: ' ●', c: 'warning' }] : []),
83      ]
84    : []
85
86  return { id: '', left: title, right }
87}
88
hooks/settings.ts 55 lines
1export const STYLE_NAMES = ['ide', 'minimal', 'rich'] as const
2export type StyleName = (typeof STYLE_NAMES)[number]
3
4export type Settings = {
5  style: StyleName
6  layout: 'replace' | 'split'
7  openWith: string
8  follow: boolean
9  icons: 'nerd' | 'unicode'
10  showIgnored: boolean
11}
12
13/**
14 * Whether `value` names one of the styles.
15 */
16export function isStyle(value: unknown): value is StyleName {
17  return typeof value === 'string' && (STYLE_NAMES as readonly string[]).includes(value)
18}
19
20/**
21 * The settings from the plugin's `userConfig` options, defaults filled in.
22 */
23export function settingsOf(options: Record<string, unknown> | undefined): Settings {
24  const o = options ?? {}
25
26  return {
27    style: isStyle(o.style) ? o.style : 'ide',
28    layout: o.previewLayout === 'split' ? 'split' : 'replace',
29    openWith: typeof o.openWith === 'string' ? o.openWith : '',
30    follow: o.followClaude !== 'off',
31    icons: o.icons === 'nerd' ? 'nerd' : 'unicode',
32    showIgnored: o.showIgnored === 'on',
33  }
34}
35
36/**
37 * The style after `style` in the ◐ button's cycle.
38 */
39export function nextStyle(style: StyleName): StyleName {
40  return STYLE_NAMES[(STYLE_NAMES.indexOf(style) + 1) % STYLE_NAMES.length] ?? 'ide'
41}
42
43/**
44 * The style to draw: the ◐ button's stored choice while /config still holds
45 * the value it overrode, else /config's.
46 *
47 * @param config the style /config holds
48 * @param stored what the button stored: `{ style, over }`
49 */
50export function effectiveStyle(config: StyleName, stored: unknown): StyleName {
51  const o = stored && typeof stored === 'object' ? (stored as { style?: unknown; over?: unknown }) : undefined
52
53  return o && o.over === config && isStyle(o.style) ? o.style : config
54}
55
hooks/styles/index.ts 8 lines
1import type { StyleName } from '../settings'
2import type { Style } from '../segments'
3import { ide } from './ide'
4import { minimal } from './minimal'
5import { rich } from './rich'
6
7export const STYLES: Record<StyleName, Style> = { ide, minimal, rich }
8
hooks/tree.ts 153 lines
1import type { Entry } from '../types'
2
3export type { Entry }
4
5/** Every listed folder's sorted entries, by the folder's absolute path. */
6export type Listing = Record<string, Entry[]>
7
8/** One visible row of the tree. */
9export type TreeRow = {
10  path: string
11  name: string
12  depth: number
13  kind: 'file' | 'dir'
14  isOpen: boolean
15  isIgnored: boolean
16  mtimeMs: number
17}
18
19const HIDDEN = new Set(['.git', 'node_modules'])
20const byName = (a: string, b: string) => a.localeCompare(b, undefined, { sensitivity: 'base' })
21
22/**
23 * `dir` and `name` joined with one slash.
24 */
25export function join(dir: string, name: string): string {
26  return dir.endsWith('/') ? `${dir}${name}` : `${dir}/${name}`
27}
28
29/**
30 * The folder holding `path`; `/` for a top-level path.
31 */
32export function parentOf(path: string): string {
33  const at = path.lastIndexOf('/')
34
35  return at <= 0 ? '/' : path.slice(0, at)
36}
37
38/**
39 * Whether `path` is `root` or under it.
40 */
41export function isInside(root: string, path: string): boolean {
42  return path === root || path.startsWith(root.endsWith('/') ? root : `${root}/`)
43}
44
45/**
46 * `path` relative to `root` (no leading slash); `path` itself when outside.
47 */
48export function relative(root: string, path: string): string {
49  if (path === root) return ''
50
51  return isInside(root, path) ? path.slice(root.length).replace(/^\/+/, '') : path
52}
53
54/**
55 * A folder's entries for the tree: files and folders only, `.git` and
56 * `node_modules` left out, folders first, then by name ignoring case.
57 *
58 * @param ignored names git ignores in this folder
59 * @param keepIgnored keep them, flagged, rather than leaving them out
60 */
61export function sortEntries(
62  entries: readonly { name: string; kind: string; mtimeMs: number }[],
63  ignored: ReadonlySet<string>,
64  keepIgnored: boolean,
65): Entry[] {
66  return entries
67    .filter(e => (e.kind === 'file' || e.kind === 'dir') && !HIDDEN.has(e.name))
68    .filter(e => keepIgnored || !ignored.has(e.name))
69    .map(e => ({ name: e.name, kind: e.kind as Entry['kind'], mtimeMs: e.mtimeMs, isIgnored: ignored.has(e.name) }))
70    .sort((a, b) => (a.kind === b.kind ? byName(a.name, b.name) : a.kind === 'dir' ? -1 : 1))
71}
72
73/**
74 * The visible rows: the root's entries, and under each open folder its own.
75 */
76export function flatten(root: string, listing: Listing, open: readonly string[]): TreeRow[] {
77  const isOpen = new Set(open)
78  const rows: TreeRow[] = []
79  const walk = (dir: string, depth: number) => {
80    for (const entry of listing[dir] ?? []) {
81      const path = join(dir, entry.name)
82      const opened = entry.kind === 'dir' && isOpen.has(path)
83      rows.push({ path, name: entry.name, depth, kind: entry.kind, isOpen: opened, isIgnored: entry.isIgnored, mtimeMs: entry.mtimeMs })
84      if (opened) walk(path, depth + 1)
85    }
86  }
87  walk(root, 0)
88
89  return rows
90}
91
92/**
93 * The folders to open, outermost first, so that `path` shows in the tree.
94 */
95export function foldersTo(root: string, path: string): string[] {
96  if (path === root || !isInside(root, path)) return []
97  const out: string[] = []
98  for (let dir = parentOf(path); dir !== root && isInside(root, dir); dir = parentOf(dir)) out.unshift(dir)
99
100  return out
101}
102
103/**
104 * Lowercase text with diacritics removed, so `y tuong` finds `Ý tưởng`.
105 */
106export function fold(text: string): string {
107  return text.normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/đ/gi, 'd').toLowerCase()
108}
109
110/**
111 * Whether every word of `query` is in `path`, ignoring case and diacritics.
112 */
113export function matches(path: string, query: string): boolean {
114  const haystack = fold(path)
115
116  return fold(query).split(/\s+/).filter(Boolean).every(word => haystack.includes(word))
117}
118
119type SearchNode = { dirs: Map<string, SearchNode>; files: string[] }
120
121/**
122 * The rows of a search: the matching files (paths relative to `root`) as a
123 * tree opened down to each, folders first, at most `limit` files.
124 */
125export function searchRows(root: string, files: readonly string[], query: string, limit = 200): TreeRow[] {
126  const top: SearchNode = { dirs: new Map(), files: [] }
127  for (const hit of files.filter(file => matches(file, query)).slice(0, limit)) {
128    const parts = hit.split('/')
129    let node = top
130    for (const part of parts.slice(0, -1)) {
131      const next = node.dirs.get(part) ?? { dirs: new Map(), files: [] }
132      node.dirs.set(part, next)
133      node = next
134    }
135    node.files.push(parts.at(-1) ?? hit)
136  }
137  const rows: TreeRow[] = []
138  const walk = (node: SearchNode, dir: string, depth: number) => {
139    for (const name of [...node.dirs.keys()].sort(byName)) {
140      const path = join(dir, name)
141      rows.push({ path, name, depth, kind: 'dir', isOpen: true, isIgnored: false, mtimeMs: 0 })
142      const child = node.dirs.get(name)
143      if (child) walk(child, path, depth + 1)
144    }
145    for (const name of [...node.files].sort(byName)) {
146      rows.push({ path: join(dir, name), name, depth, kind: 'file', isOpen: false, isIgnored: false, mtimeMs: 0 })
147    }
148  }
149  walk(top, root, 0)
150
151  return rows
152}
153
hooks/rows.client.tsx 119 lines
1import type { ClientModule } from 'claude-code'
2
3import type { Kind } from './activity'
4import type { RowSpec, Seg } from './segments'
5
6export type RowsProps = {
7  rows: RowSpec[]
8  active: string
9  preview?: { text: string; path: string; startLine: number; markdown: boolean }
10}
11
12type Local = { phase: number; hover: number; last: { id: string; at: number }; ref: { stop?: () => void } }
13
14const TICK_MS = 90
15const DOUBLE_MS = 400
16
17/** Dark to bright, per activity tone. */
18export const TONES: Record<Kind, string[]> = {
19  read: ['#5b4a7a', '#8a6fc0', '#b89cf0', '#e6d8ff'],
20  write: ['#7a4a2a', '#c0763a', '#f0a060', '#ffd8b0'],
21  commit: ['#2f6a3a', '#4caf62', '#7fdc8f', '#c8f5d0'],
22}
23
24/**
25 * The color of character `i` of a `len`-long name at animation `phase`: a
26 * band of light that runs left to right and starts again.
27 */
28export function sweepColor(i: number, phase: number, len: number, palette: readonly string[]): string {
29  const center = ((phase * 1.5) % (len + 8)) - 4
30  const d = Math.abs(i - center)
31  const step = d < 0.8 ? 3 : d < 1.8 ? 2 : d < 2.8 ? 1 : 0
32
33  return palette[step] ?? palette[0] ?? '#ffffff'
34}
35
36const Rows: ClientModule<RowsProps, Local> = (props, surface) => {
37  const { Box, Text, Code, Markdown } = surface.elements
38  let state = surface.state
39  if (state === undefined) {
40    state = { phase: 0, hover: -1, last: { id: '', at: 0 }, ref: {} }
41    surface.setState(state)
42  }
43  const animates = props.rows.some(r => [...r.left, ...r.right].some(s => s.sweep || s.blink))
44  if (animates && !state.ref.stop) {
45    state.ref.stop = surface.every(TICK_MS, () => {
46      const cur = surface.state
47      if (cur) surface.setState({ ...cur, phase: cur.phase + 1 })
48    })
49  } else if (!animates && state.ref.stop) {
50    state.ref.stop()
51    state.ref.stop = undefined
52  }
53
54  surface.onKey(e => surface.post({ key: e.key }))
55  surface.onPointer(e => {
56    const cur = surface.state ?? state
57    if (!cur) return
58    if (e.type === 'leave' || e.y < 0 || e.y >= props.rows.length) {
59      if (cur.hover !== -1) surface.setState({ ...cur, hover: -1 })
60
61      return
62    }
63    if (e.type === 'move' && cur.hover !== e.y) surface.setState({ ...cur, hover: e.y })
64    if (e.type !== 'down' || (e.button ?? 'left') !== 'left') return
65    const id = props.rows[e.y]?.id
66    if (!id) return
67    const now = Date.now()
68    const isDouble = cur.last.id === id && now - cur.last.at < DOUBLE_MS
69    surface.setState({ ...cur, last: { id, at: isDouble ? 0 : now } })
70    surface.post(isDouble ? { open: id } : { press: id })
71  })
72
73  if (props.preview) {
74    const p = props.preview
75
76    return p.markdown
77      ? <Markdown text={p.text} />
78      : <Code source={p.text} path={p.path} startLine={p.startLine} wrap="truncate-end" />
79  }
80
81  const phase = state.phase
82  const hover = state.hover
83  const draw = (s: Seg) => {
84    if (s.blink && phase % 6 >= 3) return <Text>{' '.repeat([...s.t].length)}</Text>
85    const palette = s.tone ? TONES[s.tone] : undefined
86    if (palette && s.sweep) {
87      const chars = [...s.t]
88
89      return (
90        <Text bold={s.b}>
91          {chars.map((ch, i) => <Text color={sweepColor(i, phase, chars.length, palette)}>{ch}</Text>)}
92        </Text>
93      )
94    }
95    const color = palette ? palette[2] : s.c
96
97    return <Text color={color} bold={s.b} dimColor={s.dim}>{s.t}</Text>
98  }
99
100  return (
101    <Box flexDirection="column">
102      {props.rows.map((r, i) => (
103        <Box
104          flexDirection="row"
105          height={1}
106          overflow="hidden"
107          backgroundColor={r.id && r.id === props.active ? '#313244' : i === hover ? '#262637' : undefined}
108        >
109          <Box flexShrink={1} overflow="hidden">{r.left.map(draw)}</Box>
110          <Box flexGrow={1} />
111          {r.right.map(draw)}
112        </Box>
113      ))}
114    </Box>
115  )
116}
117
118export default Rows
119
hooks/styles/ide.ts 23 lines
1import { iconOf } from '../icons'
2import { GIT_COLOR, folderStatus, nameSeg, titleRow } from '../segments'
3import type { Seg, Style } from '../segments'
4
5export const ide: Style = {
6  row(r, ctx) {
7    const icon = iconOf(r.name, r.kind, r.isOpen, ctx.icons)
8    const left: Seg[] = [
9      { t: '│ '.repeat(r.depth), c: 'subtle' },
10      { t: r.kind === 'dir' ? (r.isOpen ? '▾ ' : '▸ ') : '  ', c: 'suggestion' },
11      ...(icon ? [{ t: `${icon.glyph} `, ...(icon.color ? { c: icon.color } : { dim: true }) }] : []),
12      nameSeg(r, true),
13    ]
14    const status = r.kind === 'dir' ? folderStatus(r.counts) : r.git?.status
15
16    return { id: r.path, left, right: status ? [{ t: status, c: GIT_COLOR[status] }] : [] }
17  },
18
19  header(h) {
20    return [titleRow(h, [{ t: h.name, b: true, c: 'claude' }])]
21  },
22}
23
hooks/styles/minimal.ts 20 lines
1import { GIT_COLOR, folderStatus, nameSeg, titleRow } from '../segments'
2import type { Seg, Style } from '../segments'
3
4export const minimal: Style = {
5  row(r) {
6    const status = r.kind === 'dir' ? folderStatus(r.counts) : r.git?.status
7    const name = nameSeg({ ...r, name: r.kind === 'dir' ? `${r.name}/` : r.name }, false, { t: '', dim: !status && !r.mark })
8    const right: Seg[] = [
9      ...(r.mark?.isLive ? [{ t: '• ', tone: r.mark.kind, blink: true }] : []),
10      ...(status ? [{ t: '•', c: GIT_COLOR[status] }] : []),
11    ]
12
13    return { id: r.path, left: [{ t: '  '.repeat(r.depth + 1) }, name], right }
14  },
15
16  header(h) {
17    return [titleRow(h, [{ t: h.name, b: true }])]
18  },
19}
20
hooks/styles/rich.ts 44 lines
1import { iconOf } from '../icons'
2import { age, nameSeg, titleRow } from '../segments'
3import type { RowInput, Seg, Style } from '../segments'
4
5/**
6 * The rich style's right column: line counts, `new`, `deleted`, folder
7 * counts, or the entry's age.
8 */
9function detail(r: RowInput, now: number): Seg[] {
10  if (r.kind === 'dir') {
11    const c = r.counts
12    const parts = c ? [c.M ? `M:${c.M}` : '', c.U ? `?:${c.U}` : '', c.D ? `D:${c.D}` : ''].filter(Boolean) : []
13
14    return parts.length ? [{ t: parts.join(' '), dim: true }] : r.mtimeMs ? [{ t: age(r.mtimeMs, now), dim: true }] : []
15  }
16  const git = r.git
17  if (git?.status === 'U' || git?.status === 'A') return [{ t: 'new', c: 'success' }]
18  if (git?.status === 'D') return [{ t: 'deleted', c: 'error' }]
19  if (git && (git.added || git.removed)) return [{ t: `+${git.added ?? 0}`, c: 'success' }, { t: ' ' }, { t: `-${git.removed ?? 0}`, c: 'error' }]
20
21  return [{ t: age(r.mtimeMs, now), dim: true }]
22}
23
24export const rich: Style = {
25  row(r, ctx) {
26    const icon = iconOf(r.name, r.kind, r.isOpen, ctx.icons)
27    const left: Seg[] = [
28      { t: '  '.repeat(r.depth) },
29      { t: r.kind === 'dir' ? (r.isOpen ? '▾ ' : '▸ ') : '  ', c: 'suggestion' },
30      ...(icon ? [{ t: `${icon.glyph} `, ...(icon.color ? { c: icon.color } : { dim: true }) }] : []),
31      nameSeg(r, true),
32    ]
33
34    return { id: r.path, left, right: detail(r, ctx.now) }
35  },
36
37  header(h) {
38    const title = titleRow(h, [{ t: h.name, b: true, c: 'claude' }])
39    const status: Seg[] = h.status ? [{ t: '● ', c: 'claude' }, { t: h.status, dim: true }] : [{ t: 'Claude is idle', dim: true }]
40
41    return [title, { id: '', left: status, right: [] }]
42  },
43}
44