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

Small Claude Code mods: function-hook plugins that add panes, bands and commands inside Claude Code.
A sidebar listing the current project's sessions. Pick one to resume it.
☰ Sessions (12) above the prompt, or /sessions, opens the pane with the keyboard on it3 / 12)1–9 or Enter resumes a session, r refreshes, x closes, Esc goes back to the prompts 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[feat-search] 12m ago, lined up at the right edge)▸ (this session)An IDE-style file tree beside the conversation, showing what Claude reads, edits and commits.
▤ 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↑/↓ move, → opens a folder, ← closes it, Enter or a double-click previews a file+N -N line counts, branch and ahead/behind in the headers searches every file, opened folders or not (case and diacritics ignored)t cycles three styles: ide, minimal, rich↗ opens the file with your Open with command (code -g {path}, phpstorm {path}) or the default app/config → file-tree: Style, Preview layout (replace or split), Open with, Follow Claude, Icons (nerd or unicode), Show ignored files/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.
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-treehooks/register.tsx 584 lines1import { 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}
584hooks/activity.ts 75 lines1import 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}
75hooks/git.ts 95 lines1import 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}
95hooks/preview.ts 75 lines1import { 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}
75hooks/segments.ts 88 lines1import 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}
88hooks/settings.ts 55 lines1export 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}
55hooks/styles/index.ts 8 lines1import 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 }
8hooks/tree.ts 153 lines1import 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}
153hooks/rows.client.tsx 119 lines1import 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
119hooks/styles/ide.ts 23 lines1import { 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}
23hooks/styles/minimal.ts 20 lines1import { 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}
20hooks/styles/rich.ts 44 lines1import { 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