An IDE-style file tree for Claude Code that shows what Claude is doing and where in files

<h1 align="center">teckTree Mod</h1>
An IDE-style file tree for Claude Code that shows what Claude is doing and where in files
<img src="https://img.shields.io/badge/Claude_Code-%E2%89%A5_2.1.287-D97757?logo=claude&logoColor=fff" alt="Claude Code 2.1.287 or newer"> <img src="https://img.shields.io/badge/version-0.4.0-blue" alt="Version"> <img src="https://img.shields.io/badge/type-mod-6f42c1" alt="Claude Code mod"> <img src="https://img.shields.io/badge/license-MIT-green" alt="License">
<img src="media/tecktree-shimmer.gif" alt="teckTree shimmering the file Claude is editing" width="900">
[!NOTE] teckTree is a Claude Code mod and needs Claude Code 2.1.287+. It shows in the right sidebar, which in the terminal needs the fullscreen layout (
/tui fullscreen) and a terminal at least 110 columns wide; the Desktop app docks it on its own, and/tecktreereopens it there after you close it. Tested on Linux, macOS and Windows; mods don't load in WSL sessions of the Desktop app.
It installs from the claudemods marketplace. Run this in the terminal:
claude plugin marketplace add laynem/claudemods
claude plugin install tecktree@claudemods
Or inside a Claude Code session:
/plugin marketplace add laynem/claudemods
/plugin install tecktree@claudemods
Install fails with Unrecognized key: "options"? Your Claude Code is older than 2.1.287: run claude update, then install again.
/tecktree <path> pins another folder+N -N) on modified files and ?:N M:N D:N file counts on folders<img src="media/tecktree-read.gif" alt="Files shimmer purple while Claude reads and searches them" width="800">
git and gh (commit, push, pull, checkout, merge, PR and more) shown as a status at the bottom of the pane<img src="media/tecktree-git.gif" alt="A committed file shimmers green and the footer shows the commit" width="800">
prompt.submit hook, and @path mentions in a prompt reveal that file in the tree<img src="media/tecktree-ask.gif" alt="Selecting config.yaml in the tree and asking Claude what it changed there" width="800">
Σ header button swaps the date column for sizes; folders show their disk usage (du, or a summed listing on Windows), worked out in the background for the rows on screen and refreshed after Claude writesj/k/h/l to move through the tree/config theme in the terminal gets its own palette, with every text color at WCAG AAA contrast (7:1 or more), also on the selected and hovered rowsd on a changed file shows its diff, in the pane or in hunk, lazygit or any diff tool you name<img src="media/tecktree-diff.gif" alt="Pressing d on a file and a folder shows their diffs in the pane, and j/k jump between changed files" width="800">
With the teckTree pane focused, use j/k to move down/up, l to expand a folder, and h to collapse it or move to its parent. o or Enter opens a file or toggles a folder. Press s to focus search, type a query, then Enter to jump to the first match and return focus to the tree controls.
You can resize the pane with the mouse, or by setting custom pane:grow or pane:shrink keybindings in keybindings.json
<img src="media/tecktree-resize.gif" alt="Dragging the teckTree pane edge to resize it" width="900">
All settings are in /config under tecktree.
reads and writes (default), writes, reads or none. Git status, line counts and the git status at the bottom always show.on (default) scrolls the tree to what Claude reads, writes or commits; off keeps the view where you put it, and highlights still show.on (default) opens the pane when a session starts; off keeps it closed until you run /tecktree.date (default), age or size; what the right column shows when a session starts. age shows how long ago a file changed in a few characters: now, 12m, 3h, 5d, then the day (Oct 9) past a week and the year past a year. The Σ button in the header toggles between sizes and the dates or ages.on (default) shows a line under the header outside a git repo saying git status starts after git init; off hides it.j/k/h/l/open/search row under the search bar; off hides it and the keys still work with the tree focused.editor opts into $VISUAL, then $EDITOR, then vi in tmux or herdr on Linux/macOS. In tmux, nvim reuses a running nvim in the same tmux server and session; otherwise the editor opens in a new window in the file's folder. In herdr without tmux, the editor opens in a new pane to the right of Claude's pane, in the file's folder, and that pane closes when the editor exits; the file name reaches the editor through the pane's environment, not through its command line, so names with spaces or shell characters are safe. Outside tmux and herdr it shows a notice instead of opening the file: a mod cannot hand Claude Code's terminal to another program, so a terminal editor needs a tmux window or a herdr pane to run in. For a GUI editor, or outside both, use a command instead, such as code -g {path} or one that opens your terminal's new window. Editor commands can include arguments; quote an executable path or argument that contains spaces, for example EDITOR='"/opt/My Editor/nvim" -u NONE'. Arguments apply when starting a new editor; reusing nvim keeps that instance's configuration. preview shows the file in the pane instead: Markdown rendered, code highlighted, j/k or the arrow keys, f/b, Page Up/Down or Ctrl+F/B for a page, and the wheel to scroll, ←/h or ← files back to the tree, ↗ always the default app, e the editor from Edit with at the top line shown, which the button names (edit :42). Otherwise a command that double-click and Enter run instead: {path} is the file and {dir} its folder, and without {path} the path goes last. It runs without a shell, so quote a word that holds spaces, and it should start the viewer and return rather than wait for it. Examples: zed {path}, code -g {path}, or in herdr a tab running herdr-file-viewer on the file: herdr plugin pane open --plugin herdr-file-viewer --entrypoint file-viewer --placement tab --focus --env HERDR_FILE_VIEWER_OPEN={path}.e does in the preview: it opens the file at the top line shown. Empty (default) uses $VISUAL, then $EDITOR, then vi in tmux or herdr on Linux/macOS, the same way as Open with: editor, passing the line as +N; elsewhere the e button is hidden. Otherwise a command like Open with, where {line} is that line: code -g {path}:{line} or zed {path}:{line}.d does on a changed file, in the tree or the preview. Empty (default) shows its diff against HEAD in the pane, drawn by Claude Code's own diff view; an untracked file shows as all added, and d again goes back to the file. On a folder it shows every changed file under it, each under its name; j/k jump to the next or previous file (or hunk, for one file), the arrows scroll by line and f/b by page. Otherwise a terminal command run on the file in a tmux window, a herdr pane or, on Linux, a new terminal window through xdg-terminal-exec, with the file path last: hunk diff --, lazygit -f, git difftool -y -- or nvim -c Gvdiffsplit..cache .tmp. Names match exactly, without wildcards, at any depth, like the built-in .git and node_modules, and a matching path is never listed, found or flashed.key=#hex pairs separated by spaces, laid over the Omarchy, light or default theme. A key you leave out keeps the theme's color, so accent=#83a598 alone recolors only folders. Keys: fg, bg, accent (folders), muted (dates and sizes), urgent (deleted files and conflicts), selection (the selected row), hover (the hovered row), added, modified and renamed (git status), add and del (line counts). An entry it cannot read is skipped and named in a notice at session start. Gruvbox Dark Hard, from #13 by @Mourey: fg=#ebdbb2 bg=#1d2021 accent=#83a598 muted=#928374 urgent=#fb4934 selection=#3c3836 added=#b8bb26 modified=#fabd2f renamed=#83a598 add=#b8bb26 del=#fb4934auto (default) uses Nerd Font icons when a Nerd Font is installed and your terminal started after it was installed, plain Unicode in the desktop app, and Nerd Font over SSH. nerd or plain forces one.Clicking rows needs herdr 0.9.1 or later. herdr 0.9.0 and older accept pixel mouse reporting but still send cell positions, which would put every click in the session in the wrong place, so on those versions the rows ignore the mouse and the rest of the pane and session keep working. Run herdr update to get row clicks.
With Open with: editor, or e in the preview, the editor opens in a new pane to the right of Claude's pane and that pane closes when you quit the editor; see Settings.
Turn on the pre-commit hook once per clone; it runs claude plugin validate and the plugin tests before each commit that touches the plugin:
git config core.hooksPath .githooks
The same checks run on macOS, Windows and Linux in CI on every push that touches the plugin.
This project is inspired by my Omarchy app FileBlade
hooks/register.tsx 1779 lines1import { type BuiltinToolResults, type EngineInterface, type Register, type Timer, update } from 'claude-code'
2import { herdrError, herdrOpenCommand, herdrPane, LINUX_LAUNCH, openCommand, openWithCommand, tmuxOpenCommand, words } from './open'
3import type { ButtonProps } from './button'
4
5import type { Activity, FileNode, FileTree, Theme } from '../types'
6import { BRANCH_ICON, chainOf, type GitAction, gitActions, LIGHT_TONES, readOnly, readTargets, resolve, TONES } from './git'
7import type { RowSpec, RowsProps, Seg } from './rows'
8import { type DiffLine, diffWindow, splitPatch } from './diff'
9import { ADD_COLOR, DEL_COLOR, LIGHT_ADD_COLOR, LIGHT_DEL_COLOR, LIGHT_HOVER, type Palette, parsePalette } from './palette'
10import { CHEVRON_CLOSED, CHEVRON_OPEN, fileIcon, GIT_COLOR, LIGHT_GIT_COLOR } from './icons'
11import {
12 age,
13 ancestorsOf,
14 type Change,
15 DEFAULT_THEME,
16 dirname,
17 emptyTree,
18 formatSize,
19 inside,
20 isAbsolute,
21 join,
22 LIGHT_THEME,
23 mapper,
24 parseGit,
25 parseNumstat,
26 parseTheme,
27 posix,
28 relative,
29 dropBelow,
30 useDrives,
31 rollCounts,
32 replaceChildren,
33 rollUp,
34 stamp,
35 toNodes,
36 underAny,
37 visibleRows,
38} from './tree'
39
40const TREE = { plugin: 'tecktree', key: 'tree' } as const
41const THEME = { plugin: 'tecktree', key: 'theme' } as const
42const ACTIVITY = { plugin: 'tecktree', key: 'activity' } as const
43const PANE = 'tecktree'
44const SHIMMER = Object.fromEntries(Object.entries(TONES).map(([k, v]) => [k, { bright: v.bright, dim: v.dim }]))
45const LIGHT_SHIMMER = Object.fromEntries(Object.entries(LIGHT_TONES).map(([k, v]) => [k, { bright: v.bright, dim: v.dim }]))
46const BRANCH_ROW = '#branch'
47const FLASH_MS = 2700
48const RUNNING_MAX_MS = 600_000
49const NO_REPO_DEPTH = 4
50const DOUBLE_MS = 450
51const FIND_LIMIT = 200
52const ROWS_BUDGET = 80_000
53const READ_REVEAL_LIMIT = 12
54const SEARCH_REVEAL_LIMIT = 60
55const ACTIVITY_TTL_MS = 45_000
56const THEME_FILE = '.local/state/omarchy/current/theme/colors.toml'
57const THEME_POLL_MS = 2000
58const GIT_POLL_MS = 2000
59const FONT_SCRIPT =
60 'if command -v fc-list >/dev/null 2>&1; then f=$(fc-list ":charset=$1" file | grep -vi lastresort | head -n1 | cut -d: -f1); fi; ' +
61 '[ -n "$f" ] || f=$(ls "$HOME"/Library/Fonts/*Nerd* /Library/Fonts/*Nerd* 2>/dev/null | head -n1); ' +
62 '[ -n "$f" ] || { echo missing; exit 0; }; m=$(stat -c %Z "$f" 2>/dev/null || stat -f %c "$f"); p=$PPID; ' +
63 'while [ -n "$p" ] && [ "$p" -gt 1 ]; do c=$(ps -o comm= -p "$p" 2>/dev/null); c=$(basename "$c" 2>/dev/null | tr -d " "); case "$c" in ' +
64 'ghostty|kitty|alacritty|Alacritty|foot|footclient|wezterm-gui|konsole|gnome-terminal-|xterm|urxvt|st|Terminal|iTerm2) ' +
65 'e=$(ps -o etime= -p "$p" 2>/dev/null | awk -F\'[-:]\' \'{n=NF; s=$n+60*$(n-1); if (n>2) s+=3600*$(n-2); if (n>3) s+=86400*$(n-3); print s}\'); [ -n "$e" ] && [ $(( $(date +%s) - e )) -lt "$m" ] && echo stale || echo ok; exit 0;; esac; ' +
66 'p=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d " "); done; echo ok'
67const PRUNE = ['.git', 'node_modules', 'target', '.venv', '__pycache__', 'dist', '.next']
68const HOME_PRUNE = ['Library', 'AppData', '.Trash']
69const SIZE_TIMEOUT_MS = 15_000
70const SIZE_JOBS = 2
71const SIZE_WALK_LIMIT = 50_000
72const AGE_TICK_MS = 60_000
73
74let blink: Timer | null = null
75let themePoll: Timer | null = null
76let gitPoll: Timer | null = null
77let gitStamp: string | null = null
78let gitDirOf = { root: '', dir: '' }
79let ageTick: Timer | null = null
80let themeMtime: number | null = null
81let lightTerminal = false
82let generation = 0
83let lastPress = { key: '', at: 0 }
84let noNerd = false
85let glyphSetting = 'auto'
86let openWithSetting = ''
87let editWithSetting = ''
88let diffWithSetting = ''
89let palette: Palette = parsePalette('')
90let inTmux = false
91let inHerdr = false
92let previewLine = 1
93let previewed: { path: string; text: string; note: string; diff?: DiffLine[] } | null = null
94let previewRows = 20
95let follow = true
96let followClaude = true
97let scanning: Promise<void> | null = null
98let scanJobs: Job[] = []
99let gitRun: Promise<void> | null = null
100let gitNext: Promise<void> | null = null
101let queuedReads: string[] = []
102let showReads = true
103let showWrites = true
104let searchIndex: { root: string; paths: Promise<string[]> } | null = null
105let activityId = 0
106let pointer = true
107let hovered = ''
108let view = { from: 0, max: 0 }
109let nativeScroll = false
110let lastSync = 0
111let noDock = false
112let home = ''
113let platform: Promise<'linux' | 'darwin' | 'win32'> | null = null
114let dirty: { root: string; files: Record<string, Change> } = { root: '', files: {} }
115let markId = 0
116let listSeq = 0
117let lastRoot = ''
118let sizeDefault = false
119let showAge = false
120let showHints = true
121let ignoreNames: string[] = []
122let searchGap = true
123let showShortcuts = true
124let sizeEpoch = 0
125let sizeActive = 0
126let sizeQueue: string[] = []
127const sizing = new Set<string>()
128const listLatest = new Map<string, number>()
129const unreadable = new Set<string>()
130const background = new Map<string, Background>()
131
132function osName($: EngineInterface): Promise<'linux' | 'darwin' | 'win32'> {
133 platform ??= (async () => {
134 if ((await $.env.get('OS')) === 'Windows_NT') return 'win32'
135 try {
136 return (await $.process.run(['uname', '-s'], { timeoutMs: 3_000 })).stdout.trim() === 'Darwin' ? 'darwin' : 'linux'
137 } catch {
138 return 'linux'
139 }
140 })()
141 return platform
142}
143
144async function cwdOf($: EngineInterface): Promise<string> {
145 return posix(await $.session.cwd())
146}
147
148function clean(segs: Seg[]): Seg[] {
149 for (const seg of segs) for (const k of Object.keys(seg) as (keyof Seg)[]) if (seg[k] === undefined) delete seg[k]
150 return segs
151}
152
153function faint(hex: string): string {
154 const v = parseInt(hex.slice(1), 16)
155 const ch = (shift: number) => Math.round(0x26 + (((v >> shift) & 255) - 0x26) * 0.3)
156 return `#${[16, 8, 0].map(x => ch(x).toString(16).padStart(2, '0')).join('')}`
157}
158
159async function readThemeSetting($: EngineInterface): Promise<void> {
160 const row = (await $.config.list()).find(r => r.key === 'theme')
161 const light = typeof row?.value === 'string' && row.value.startsWith('light')
162 if (light === lightTerminal) return
163 lightTerminal = light
164 void $.ui.invalidate('ui.render')
165}
166
167async function get($: EngineInterface): Promise<FileTree> {
168 return { ...emptyTree(''), ...(await $.state.get(TREE)).value }
169}
170
171async function put($: EngineInterface, fn: (t: FileTree) => FileTree): Promise<void> {
172 await update($, TREE, cur => fn({ ...emptyTree(''), ...cur }))
173}
174
175function patch($: EngineInterface, fn: (t: FileTree) => Partial<FileTree>) {
176 return put($, t => ({ ...t, ...fn(t) }))
177}
178
179async function activities($: EngineInterface): Promise<Activity[]> {
180 return (await $.state.get(ACTIVITY)).value ?? []
181}
182
183async function setActivities($: EngineInterface, fn: (list: Activity[]) => Activity[]): Promise<void> {
184 await update($, ACTIVITY, cur => fn(cur ?? []).slice(-6))
185}
186
187async function list($: EngineInterface, dir: string): Promise<FileNode[] | null> {
188 try {
189 const entries = await $.fs.list(dir)
190 const resolved = await Promise.all(
191 entries.map(async e => {
192 if (!e.isLink) return { name: e.name, kind: e.kind, mtimeMs: e.mtimeMs, size: e.size, isLink: false }
193 try {
194 const target = await $.fs.stat(join(dir, e.name))
195 return target.kind === 'dir'
196 ? { name: e.name, kind: 'dir' as const, mtimeMs: target.mtimeMs, size: 0, isLink: false }
197 : { name: e.name, kind: 'file' as const, mtimeMs: target.mtimeMs, size: target.size, isLink: true }
198 } catch {
199 return { name: e.name, kind: 'other' as const, mtimeMs: 0, size: 0, isLink: true }
200 }
201 }),
202 )
203 unreadable.delete(dir)
204 return toNodes(dir, resolved.filter(e => !ignoreNames.includes(e.name)))
205 } catch (err) {
206 if (!(await exists($, dir))) return []
207 if (!unreadable.has(dir)) {
208 unreadable.add(dir)
209 $.ui.toast(`could not list ${shortPath(dir)}: ${err instanceof Error ? err.message : String(err)}`)
210 }
211 return null
212 }
213}
214
215async function loadDirs($: EngineInterface, dirs: string[]): Promise<Map<string, FileNode[]>> {
216 const root = (await get($)).root
217 const seq = ++listSeq
218 for (const dir of dirs) listLatest.set(dir, seq)
219 const results = await Promise.all(dirs.map(async dir => [dir, await list($, dir)] as const))
220 const listed = new Map<string, FileNode[]>()
221 for (const [dir, kids] of results) {
222 if (listLatest.get(dir) !== seq) continue
223 listLatest.delete(dir)
224 if (kids) listed.set(dir, kids)
225 }
226 await patch($, t => (t.root === root ? { nodes: replaceChildren(t.nodes, listed) } : {}))
227 return listed
228}
229
230async function git($: EngineInterface, cwd: string, args: string[], timeoutMs = 20_000, stdin?: string) {
231 return $.process.run(['git', '--no-optional-locks', '-C', cwd, ...args], { timeoutMs, env: { GIT_OPTIONAL_LOCKS: '0' }, ...(stdin === undefined ? {} : { stdin }) })
232}
233
234async function detectRepo($: EngineInterface): Promise<void> {
235 const t = await get($)
236 if (!t.root) return
237 let top = ''
238 let prefix = ''
239 try {
240 const run = await git($, t.root, ['rev-parse', '--show-prefix', '--show-toplevel'], 5_000)
241 const [p = '', tl = ''] = run.exitCode === 0 ? run.stdout.split('\n') : []
242 prefix = p.trim()
243 top = tl.trim()
244 } catch {
245 top = ''
246 }
247 await patch($, cur => (cur.root === t.root ? { top, prefix } : {}))
248}
249
250async function readGit($: EngineInterface): Promise<void> {
251 const t = await get($)
252 if (!t.root || !t.top) return
253 const root = t.root
254 try {
255 const status = await git($, root, ['status', '--porcelain=v1', '-b', '-z', '--ignored=traditional', '--untracked-files=normal', '--', '.'])
256 if (status.exitCode !== 0) return
257 const parsed = parseGit(status.stdout, root, t.prefix)
258 const diff: Record<string, [number, number]> = {}
259 const head = await git($, root, ['diff', 'HEAD', '--numstat', '-z', '--', '.'])
260 if (head.exitCode === 0) parseNumstat(head.stdout, root, t.prefix, diff)
261 const files = { ...parsed.files }
262 if (parsed.untrackedDirs.length) {
263 const others = await git($, root, ['ls-files', '-o', '--exclude-standard', '-z', '--', ...parsed.untrackedDirs.map(d => d.slice(root.length + 1) || '.')])
264 if (others.exitCode === 0) for (const rel of others.stdout.split('\0').filter(Boolean)) files[join(root, rel)] = 'new'
265 }
266 for (const path of Object.keys(diff)) if (files[path] === 'new') delete diff[path]
267 dirty = { root, files }
268 await patch($, cur =>
269 cur.root === root
270 ? {
271 git: parsed.git,
272 ignored: parsed.ignored,
273 untrackedDirs: parsed.untrackedDirs,
274 branch: parsed.branch,
275 counts: rollCounts(files, root),
276 diff: rollUp(diff, root),
277 }
278 : {},
279 )
280 } catch {
281 return
282 }
283}
284
285function refreshGit($: EngineInterface): Promise<void> {
286 if (!gitRun) {
287 gitRun = readGit($).finally(() => {
288 gitRun = null
289 })
290 return gitRun
291 }
292 const rerun = () => {
293 gitNext = null
294 return refreshGit($)
295 }
296 gitNext ??= gitRun.then(rerun, rerun)
297 return gitNext
298}
299
300async function reset($: EngineInterface, root: string, focus = false): Promise<void> {
301 const prev = await get($)
302 generation += 1
303 blink?.cancel()
304 blink = null
305 lastRoot = root
306 searchIndex = null
307 sizeEpoch += 1
308 sizeQueue = []
309 await put($, () => ({ ...emptyTree(root), showHidden: prev.showHidden, showSize: prev.root ? prev.showSize : sizeDefault }))
310 const title = `Files: ${root.split('/').pop() || root}`
311 if (focus) await $.ui.open({ id: PANE, title, focus: true })
312 else if (!noDock) await $.ui.open({ id: PANE, title })
313 await loadDirs($, [root])
314 await detectRepo($)
315 await refreshGit($)
316}
317
318async function revealPaths($: EngineInterface, paths: string[]): Promise<void> {
319 const tried = new Set<string>()
320 for (let pass = 0; pass < 32; pass++) {
321 const t = await get($)
322 const loaded = new Set(t.nodes.filter(n => n.loaded).map(n => n.id))
323 if (t.nodes.some(n => n.parent === t.root)) loaded.add(t.root)
324 const need = new Set<string>()
325 for (const p of paths) {
326 for (const dir of [t.root, ...ancestorsOf(p, t.root).reverse()]) {
327 if (!loaded.has(dir)) {
328 if (!tried.has(dir)) need.add(dir)
329 break
330 }
331 }
332 }
333 if (need.size === 0) return
334 for (const dir of need) tried.add(dir)
335 await loadDirs($, [...need])
336 }
337}
338
339function isIgnored(root: string, path: string): boolean {
340 return ignoreNames.length > 0 && relative(root, path).split('/').some(name => ignoreNames.includes(name))
341}
342
343function pruneArgs(root: string, ignored: string[] = []): string[] {
344 const names = [...PRUNE, ...(broad(root) ? HOME_PRUNE : []), ...ignoreNames.map(n => n.replace(/[*?[\]\\]/g, '\\$&'))]
345 return ['(', ...names.flatMap((name, i) => (i === 0 ? ['-name', name] : ['-o', '-name', name])), ...ignored.flatMap(p => ['-o', '-path', p]), ')', '-prune', '-o']
346}
347
348async function newer($: EngineInterface, paths: string[], sinceMs: number): Promise<string[]> {
349 const stats = await Promise.all(
350 paths.map(async p => {
351 try {
352 return (await $.fs.stat(p)).mtimeMs > sinceMs ? p : ''
353 } catch {
354 return ''
355 }
356 }),
357 )
358 return stats.filter(Boolean)
359}
360
361function broad(root: string): boolean {
362 return root === home || root === '/' || /^[A-Za-z]:\/$/.test(root)
363}
364
365function rootOfDisk(root: string): boolean {
366 return root === '/' || /^[A-Za-z]:\/$/.test(root)
367}
368
369async function changedSince($: EngineInterface, root: string, since: Since, depth: number, ignored: string[] = []): Promise<string[]> {
370 if (rootOfDisk(root)) return []
371 const test = since.mark ? ['-newer', since.mark] : ['-newermt', `@${(since.ms / 1000).toFixed(3)}`]
372 try {
373 const run = await $.process.run(['find', '-H', root, '-xdev', ...(depth ? ['-maxdepth', String(depth)] : []), ...pruneArgs(root, ignored.filter(p => inside(root, p)).slice(0, 40)), ...test, '-print0'], { timeoutMs: 8_000 })
374 return run.stdout.split('\0').filter(p => p && p !== root)
375 } catch {
376 return []
377 }
378}
379
380async function changedInRepo($: EngineInterface, root: string, since: Since, before: Record<string, Change>, ignored: string[]): Promise<{ hits: string[]; gone: string[] }> {
381 if (since.os !== 'win32') return { hits: await changedSince($, root, since, 0, ignored), gone: [] }
382 if (dirty.root !== root) return { hits: [], gone: [] }
383 const entries = Object.entries(dirty.files)
384 const gone = entries.filter(([p, c]) => c === 'del' && before[p] !== 'del').map(([p]) => p)
385 const live = entries.filter(([, c]) => c !== 'del').map(([p]) => p)
386 return { hits: await newer($, live.filter(p => !isIgnored(root, p)).slice(0, 4_000), since.ms), gone }
387}
388
389type Since = { ms: number; mark: string; os: 'linux' | 'darwin' | 'win32' }
390
391async function sinceNow($: EngineInterface, writes: boolean): Promise<Since> {
392 const ms = await $.clock.now()
393 const os = await osName($)
394 if (os !== 'darwin' || !writes) return { ms, mark: '', os }
395 const mark = `${((await $.env.get('TMPDIR')) || '/tmp').replace(/\/+$/, '')}/tecktree-${await $.session.id()}-${++markId}.mark`
396 try {
397 await $.process.run(['touch', mark], { timeoutMs: 3_000 })
398 return { ms, mark, os }
399 } catch {
400 return { ms, mark: '', os }
401 }
402}
403
404async function walkSize($: EngineInterface, dir: string): Promise<number> {
405 let total = 0
406 let seen = 0
407 let level = [dir]
408 while (level.length) {
409 const next: string[] = []
410 for (const d of level) {
411 let entries
412 try {
413 entries = await $.fs.list(d)
414 } catch {
415 continue
416 }
417 for (const e of entries) {
418 if (++seen > SIZE_WALK_LIMIT) return -1
419 if (e.kind === 'dir' && !e.isLink) next.push(join(d, e.name))
420 else if (e.kind === 'file') total += e.size
421 }
422 }
423 level = next
424 }
425 return total
426}
427
428async function dirSize($: EngineInterface, dir: string): Promise<number> {
429 if (rootOfDisk(dir)) return -1
430 if ((await osName($)) === 'win32') return walkSize($, dir)
431 try {
432 const run = await $.process.run(['du', '-skxH', dir], { timeoutMs: SIZE_TIMEOUT_MS })
433 const kb = Number(run.stdout.trim().split(/\s/)[0] || NaN)
434 return Number.isFinite(kb) ? kb * 1024 : -1
435 } catch {
436 return -1
437 }
438}
439
440function pumpSizes($: EngineInterface): void {
441 while (sizeActive < SIZE_JOBS && sizeQueue.length) {
442 const dir = sizeQueue.shift() ?? ''
443 const epoch = sizeEpoch
444 sizing.add(dir)
445 sizeActive += 1
446 void dirSize($, dir)
447 .then(async bytes => {
448 if (epoch === sizeEpoch) return patch($, cur => (inside(cur.root, dir) ? { dirSizes: { ...cur.dirSizes, [dir]: bytes } } : {}))
449 const cur = await get($)
450 if (cur.showSize && inside(cur.root, dir) && !(dir in cur.dirSizes) && !sizeQueue.includes(dir)) sizeQueue.push(dir)
451 })
452 .catch(() => undefined)
453 .finally(() => {
454 sizing.delete(dir)
455 sizeActive -= 1
456 pumpSizes($)
457 })
458 }
459}
460
461function wantSizes($: EngineInterface, dirs: string[]): void {
462 for (const dir of dirs) if (!sizing.has(dir) && !sizeQueue.includes(dir)) sizeQueue.push(dir)
463 pumpSizes($)
464}
465
466async function staleSizes($: EngineInterface, paths?: string[]): Promise<void> {
467 sizeEpoch += 1
468 sizeQueue = []
469 await patch($, cur => {
470 if (!paths) return { dirSizes: {} }
471 const keep: Record<string, number> = {}
472 for (const [dir, bytes] of Object.entries(cur.dirSizes)) if (!paths.some(p => inside(dir, p))) keep[dir] = bytes
473 return { dirSizes: keep }
474 })
475}
476
477function openDirs(t: FileTree): string[] {
478 const open = new Set(t.expanded)
479 return t.nodes.filter(n => n.kind === 'dir' && n.loaded && open.has(n.id)).map(n => n.id)
480}
481
482function keepTop(cur: FileTree, expanded: string[]): Partial<FileTree> {
483 if (followClaude) return { scroll: null }
484 const top = visibleRows(cur)[cur.scroll ?? view.from]?.node.id
485 const at = top ? visibleRows({ ...cur, expanded }).findIndex(r => r.node.id === top) : -1
486 return at < 0 ? {} : { scroll: at }
487}
488
489async function flash($: EngineInterface, tones: Record<string, string>): Promise<void> {
490 const unique = Object.keys(tones)
491 if (unique.length === 0) return
492 const mine = ++generation
493 const root = (await get($)).root
494 blink?.cancel()
495 blink = null
496 await patch($, cur => {
497 if (cur.root !== root) return {}
498 const open = new Set(cur.expanded)
499 const bright = new Set([...(cur.flashOn ? cur.flash.filter(id => !unique.includes(id)) : []), ...unique])
500 const dim = new Set<string>()
501 const all: Record<string, string> = cur.flashOn ? { ...cur.flashTones } : {}
502 if (cur.flashOn) {
503 for (const id of cur.flashDim) dim.add(id)
504 }
505 for (const id of unique) {
506 const tone = tones[id] ?? 'orange'
507 all[id] = tone
508 if (id === BRANCH_ROW) continue
509 const chain = ancestorsOf(id, cur.root)
510 if (!chain.some(a => !open.has(a))) continue
511 for (const a of chain) {
512 if (!open.has(a)) {
513 dim.add(a)
514 all[a] = all[a] ?? tone
515 }
516 open.add(a)
517 }
518 }
519 for (const id of bright) dim.delete(id)
520 return {
521 flash: [...bright],
522 flashDim: [...dim],
523 flashOn: true,
524 flashTones: all,
525 expanded: [...open],
526 ...keepTop(cur, [...open]),
527 }
528 })
529 if (generation !== mine) return
530 blink = $.clock.after(FLASH_MS, () => {
531 if (generation !== mine) return
532 blink = null
533 void patch($, cur => (generation === mine ? { flash: [], flashDim: [], flashOn: false, flashTones: {} } : {}))
534 })
535}
536
537async function followCwd($: EngineInterface): Promise<boolean> {
538 if (!follow) return false
539 const cwd = await cwdOf($)
540 const t = await get($)
541 if (t.root === cwd) return false
542 await reset($, cwd)
543 return true
544}
545
546type Pending = { actions: GitAction[]; ids: number[]; command: string; head: string }
547type Job = { actions: GitAction[]; since: Since; initRepo: boolean; readOnly: boolean }
548type Background = { p: Pending | null; job: Job; reads: string[] }
549type GitOperation = NonNullable<BuiltinToolResults['Bash']['gitOperation']>
550type Outcome = { state: 'done' | 'failed'; label: string; detail: string }
551
552const PROOF: Record<string, (op: GitOperation) => boolean> = {
553 push: op => Boolean(op.push),
554 merge: op => op.branch?.action === 'merged',
555 rebase: op => op.branch?.action === 'rebased',
556 'pr create': op => op.pr?.action === 'created',
557 'pr merge': op => op.pr?.action === 'merged',
558 'pr comment': op => op.pr?.action === 'commented',
559}
560
561async function startGit($: EngineInterface, actions: GitAction[]): Promise<number[]> {
562 const now = await $.clock.now()
563 const ids = actions.map(() => ++activityId)
564 await setActivities($, cur => [
565 ...cur.filter(a => a.state === 'running' || now - a.at < ACTIVITY_TTL_MS),
566 ...actions.map((a, i) => ({
567 id: ids[i] ?? 0,
568 kind: a.kind,
569 label: a.running,
570 state: 'running' as const,
571 detail: '',
572 at: now,
573 tone: a.tone,
574 nerd: a.icon.nerd,
575 plain: a.icon.plain,
576 })),
577 ])
578 return ids
579}
580
581async function headOf($: EngineInterface, cwd: string): Promise<string> {
582 try {
583 const run = await git($, cwd, ['rev-parse', 'HEAD'], 5_000)
584 return run.exitCode === 0 ? run.stdout.trim() : ''
585 } catch {
586 return ''
587 }
588}
589
590async function outcomes($: EngineInterface, p: Pending, ok: boolean, op: GitOperation | undefined): Promise<Outcome[]> {
591 const chain = chainOf(p.command)
592 const after = p.actions.some(a => a.verb === 'commit') && !op?.commit ? await headOf($, (await get($)).root || (await cwdOf($))) : ''
593 return p.actions.map(a => {
594 if (a.verb === 'commit') {
595 const sha = op?.commit?.sha || (after && after !== p.head ? after : '')
596 return sha ? { state: 'done', label: a.done, detail: sha.slice(0, 7) } : { state: 'failed', label: `${a.verb} failed`, detail: '' }
597 }
598 if ((op && PROOF[a.verb]?.(op)) || (ok && chain.and)) return { state: 'done', label: a.done, detail: '' }
599 if (!ok && chain.and && chain.size === 1) return { state: 'failed', label: `${a.verb} failed`, detail: '' }
600 return { state: 'done', label: `ran ${a.kind}`, detail: '' }
601 })
602}
603
604async function finishGit($: EngineInterface, p: Pending, results: Outcome[]): Promise<void> {
605 const now = await $.clock.now()
606 await setActivities($, cur =>
607 cur.map(a => {
608 const i = p.ids.indexOf(a.id)
609 const result = i < 0 ? undefined : results[i]
610 return result ? { ...a, ...result, at: now } : a
611 }),
612 )
613 $.clock.after(ACTIVITY_TTL_MS + 500, () => void setActivities($, cur => cur.filter(a => a.state === 'running' || a.at > now)))
614}
615
616function failAll(p: Pending): Outcome[] {
617 return p.actions.map(a => ({ state: 'failed', label: `${a.verb} failed`, detail: '' }))
618}
619
620function proven(p: Pending | null, results: Outcome[]): GitAction[] {
621 return p ? p.actions.filter((_, i) => results[i]?.state !== 'failed') : []
622}
623
624function taskEnds(text: string): [string, string][] {
625 return [...text.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)].flatMap(m => {
626 const id = /<task-id>([^<]*)<\/task-id>/.exec(m[1] ?? '')?.[1]?.trim()
627 const status = /<status>([^<]*)<\/status>/.exec(m[1] ?? '')?.[1]?.trim()
628 return id && status ? [[id, status] as [string, string]] : []
629 })
630}
631
632async function settleBackground($: EngineInterface, text: string): Promise<void> {
633 if (background.size === 0 || !text.includes('<task-notification>')) return
634 for (const [id, status] of taskEnds(text)) {
635 const task = background.get(id)
636 if (!task) continue
637 background.delete(id)
638 const ok = status === 'completed'
639 const results = task.p ? await outcomes($, task.p, ok, undefined) : []
640 if (task.p) await finishGit($, task.p, results)
641 if (showReads) queuedReads.push(...task.reads)
642 scheduleScan($, { ...task.job, actions: proven(task.p, results) })
643 }
644}
645
646async function gitPaths($: EngineInterface, root: string, prefix: string, committed: boolean): Promise<string[]> {
647 const map = mapper(root, prefix)
648 try {
649 const run = committed
650 ? await git($, root, ['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', '-z', 'HEAD'], 5_000)
651 : await git($, root, ['diff', '--cached', '--name-only', '-z'], 5_000)
652 return run.exitCode === 0 ? run.stdout.split('\0').filter(Boolean).flatMap(rel => map(rel) ?? []) : []
653 } catch {
654 return []
655 }
656}
657
658async function gitChanged($: EngineInterface): Promise<void> {
659 const root = (await get($)).root
660 if (gitDirOf.root !== root || !gitDirOf.dir) gitDirOf = { root, dir: await gitDir($, root) }
661 const gd = gitDirOf.dir
662 const stamps = gd ? await Promise.all(['index', 'HEAD'].map(f => $.fs.stat(join(gd, f)).then(st => st.mtimeMs, () => 0))) : []
663 const stamp = gd ? `${gd}:${stamps.join(':')}` : ''
664 const before = gitStamp
665 gitStamp = stamp
666 if (before === '' && stamp) {
667 await detectRepo($)
668 void sync($, true)
669 } else if (before && stamp && before !== stamp) void sync($, true)
670}
671
672async function settleRoot($: EngineInterface): Promise<void> {
673 await loadTheme($)
674 const root = follow || !lastRoot ? await cwdOf($) : lastRoot
675 const t = await get($)
676 if (t.root !== root || t.nodes.length === 0) await reset($, root)
677}
678
679async function gitDir($: EngineInterface, cwd: string): Promise<string> {
680 for (let dir = cwd, i = 0; i < 64; i++) {
681 try {
682 const dot = join(dir, '.git')
683 const st = await $.fs.stat(dot)
684 if (st.kind === 'dir') return dot
685 const ref = /^gitdir:\s*(.+)$/m.exec(String(await $.fs.read(dot)))?.[1]?.trim()
686 if (ref) return resolve(dir, ref, home)
687 } catch {
688 const up = dirname(dir)
689 if (up === dir) return ''
690 dir = up
691 continue
692 }
693 return ''
694 }
695 return ''
696}
697
698async function sync($: EngineInterface, force = false): Promise<void> {
699 const now = await $.clock.now()
700 if (!force && now - lastSync < 2_000) return
701 lastSync = now
702 searchIndex = null
703 const t = await get($)
704 if (!t.root) return
705 await loadDirs($, [t.root, ...openDirs(t)])
706 if (t.top) await refreshGit($)
707}
708
709async function copyPath($: EngineInterface, id: string, absolute: boolean, surface?: string): Promise<void> {
710 const t = await get($)
711 const text = !absolute && id !== t.root && inside(t.root, id) ? id.slice(t.root.endsWith('/') ? t.root.length : t.root.length + 1) : id
712 const done = await $.ui.copy({ text, ...(surface ? { surface: surface as 'terminal' } : {}) })
713 $.ui.toast(done.isCopied ? `Copied ${text}` : 'Could not copy the path')
714}
715
716async function exists($: EngineInterface, path: string): Promise<boolean> {
717 try {
718 await $.fs.stat(path)
719 return true
720 } catch {
721 return false
722 }
723}
724
725async function afterBash($: EngineInterface, jobs: Job[]): Promise<void> {
726 const reads = queuedReads
727 queuedReads = []
728 if (await followCwd($)) return
729 const t = await get($)
730 if (!t.root) return
731 const actions = jobs.flatMap(j => j.actions)
732 const writers = jobs.filter(j => !j.readOnly)
733 const since = (writers.length ? writers : jobs).reduce((a, j) => (j.since.ms < a.ms ? j.since : a), (writers[0] ?? jobs[0])?.since ?? { ms: 0, mark: '', os: 'linux' as const })
734 const writes = !jobs.every(j => j.readOnly)
735 const before = dirty.root === t.root ? dirty.files : {}
736 if (jobs.some(j => j.initRepo)) await detectRepo($)
737 else if (!t.top && (await exists($, join(t.root, '.git')))) await detectRepo($)
738 const probed = await get($)
739 if (probed.top && (writes || probed.top !== t.top)) await refreshGit($)
740 if (writes) {
741 searchIndex = null
742 await staleSizes($)
743 }
744 const fresh = await get($)
745 const ignored = new Set(fresh.ignored)
746 const tones: Record<string, string> = {}
747 if (writes) {
748 const found = fresh.top
749 ? await changedInRepo($, t.root, since, before, fresh.ignored)
750 : { hits: since.os === 'win32' ? [] : await changedSince($, t.root, since, NO_REPO_DEPTH), gone: [] }
751 const hits = found.hits.filter(x => inside(t.root, x) && !underAny(x, ignored, t.root) && !isIgnored(t.root, x)).slice(0, FIND_LIMIT)
752 await revealPaths($, hits)
753 const loaded = await get($)
754 const dirs = [...new Set([loaded.root, ...openDirs(loaded), ...hits.map(dirname), ...found.gone.map(dirname)])].filter(d => inside(loaded.root, d))
755 const listed = await loadDirs($, dirs)
756 if (!fresh.top && since.os === 'win32')
757 for (const kids of listed.values()) for (const n of kids) if (n.mtime > since.ms && hits.length < FIND_LIMIT) hits.push(n.id)
758 await patch($, cur => {
759 const ids = new Set(cur.nodes.map(n => n.id))
760 return { expanded: cur.expanded.filter(id => ids.has(id)) }
761 })
762 const present = new Set((await get($)).nodes.map(n => n.id))
763 const touchTone = actions.find(a => !['commit', 'push', 'add'].includes(a.verb))?.tone ?? 'orange'
764 if (showWrites) for (const id of hits) if (present.has(id)) tones[id] = touchTone
765 }
766 if (showReads) {
767 const found: string[] = []
768 for (const r of reads) {
769 if (found.length >= READ_REVEAL_LIMIT) break
770 if (r !== t.root && inside(t.root, r) && !underAny(r, ignored, t.root) && !isIgnored(t.root, r) && (await exists($, r))) found.push(r)
771 }
772 if (found.length) {
773 await revealPaths($, found)
774 const shown = new Set((await get($)).nodes.map(n => n.id))
775 for (const r of found) if (shown.has(r) && !tones[r]) tones[r] = 'purple'
776 }
777 }
778 if (showWrites && actions.length) {
779 const final = await get($)
780 const committed = actions.some(a => a.verb === 'commit')
781 if ((committed || actions.some(a => a.verb === 'add')) && final.top) {
782 const paths = (await gitPaths($, final.root, final.prefix, committed)).filter(x => !underAny(x, ignored, final.root) && !isIgnored(final.root, x)).slice(0, READ_REVEAL_LIMIT)
783 await revealPaths($, paths)
784 const shown = new Set((await get($)).nodes.map(n => n.id))
785 for (const x of paths) if (shown.has(x)) tones[x] = 'green'
786 }
787 const pushLike = actions.find(a => ['push', 'pull', 'fetch', 'checkout', 'switch', 'branch', 'merge', 'rebase', 'tag'].includes(a.verb) || a.kind.startsWith('gh '))
788 if (pushLike) tones[BRANCH_ROW] = pushLike.tone
789 else if (committed) tones[BRANCH_ROW] = 'green'
790 }
791 await flash($, tones)
792}
793
794function scheduleScan($: EngineInterface, job: Job): void {
795 scanJobs.push(job)
796 if (scanning) return
797 scanning = (async () => {
798 while (scanJobs.length) {
799 const jobs = scanJobs
800 scanJobs = []
801 try {
802 await afterBash($, jobs)
803 } finally {
804 const marks = jobs.map(j => j.since.mark).filter(Boolean)
805 if (marks.length) await $.process.run(['rm', '-f', ...marks], { timeoutMs: 3_000 }).catch(() => undefined)
806 }
807 }
808 })().finally(() => {
809 scanning = null
810 })
811}
812
813async function touched($: EngineInterface, paths: string[], tone: string, show: boolean): Promise<void> {
814 if (tone !== 'purple' && previewed && paths.map(posix).includes(previewed.path)) previewed = null
815 if (await followCwd($)) return
816 const t = await get($)
817 if (!t.root) return
818 const within = paths.map(posix).filter(p => inside(t.root, p) && !isIgnored(t.root, p)).slice(0, SEARCH_REVEAL_LIMIT)
819 if (within.length === 0) return
820 if (tone !== 'purple') {
821 searchIndex = null
822 await staleSizes($, within)
823 await revealPaths($, within.map(dirname))
824 await loadDirs($, [...new Set(within.map(dirname))].filter(d => inside(t.root, d)))
825 await refreshGit($)
826 } else await revealPaths($, within)
827 if (!show) return
828 const tones: Record<string, string> = {}
829 for (const p of within) tones[p] = tone
830 await flash($, tones)
831}
832
833async function reveal($: EngineInterface, paths: string[]): Promise<void> {
834 if (await followCwd($)) return
835 await revealPaths($, paths)
836 await patch($, cur => {
837 const open = new Set(cur.expanded)
838 for (const p of paths) for (const a of ancestorsOf(p, cur.root)) open.add(a)
839 return { expanded: [...open], cursor: paths[paths.length - 1] ?? cur.cursor, scroll: null }
840 })
841}
842
843async function walk($: EngineInterface, root: string, depth: number, limit: number): Promise<string[]> {
844 const out: string[] = []
845 let level = [root]
846 for (let d = 0; d < depth && level.length && out.length < limit; d++) {
847 const next: string[] = []
848 for (const dir of level) {
849 for (const n of (await list($, dir)) ?? []) {
850 if (n.kind === 'dir') {
851 if (!PRUNE.includes(n.name)) next.push(n.id)
852 } else out.push(n.id)
853 }
854 if (out.length >= limit) break
855 }
856 level = next
857 }
858 return out
859}
860
861async function listAll($: EngineInterface, t: FileTree): Promise<string[] | null> {
862 try {
863 if (!t.top && (await osName($)) === 'win32') return await walk($, t.root, rootOfDisk(t.root) ? 3 : 6, 20_000)
864 const run = t.top
865 ? await git($, t.root, ['ls-files', '-co', '--exclude-standard', '-z'], 10_000)
866 : await $.process.run(['find', '-H', t.root, '-xdev', '-maxdepth', rootOfDisk(t.root) ? '3' : '6', ...pruneArgs(t.root), '-type', 'f', '-print0'], { timeoutMs: 10_000 })
867 if (t.top && run.exitCode !== 0) return null
868 const paths = run.stdout.split('\0').filter(Boolean)
869 if (run.isStdoutTruncated) paths.pop()
870 return paths.map(p => (isAbsolute(p) ? p : join(t.root, p))).filter(p => !isIgnored(t.root, p))
871 } catch {
872 return null
873 }
874}
875
876function indexPaths($: EngineInterface, t: FileTree): Promise<string[]> {
877 if (searchIndex?.root !== t.root) {
878 const index: { root: string; paths: Promise<string[]> } = {
879 root: t.root,
880 paths: listAll($, t).then(paths => {
881 if (paths === null && searchIndex === index) searchIndex = null
882 return paths ?? []
883 }),
884 }
885 searchIndex = index
886 }
887 return searchIndex.paths
888}
889
890async function search($: EngineInterface, query: string): Promise<string[]> {
891 if (!(await get($)).query.trim()) searchIndex = null
892 await patch($, () => ({ query }))
893 const q = query.trim().toLowerCase()
894 if (!q) return []
895 const t = await get($)
896 const hits = (await indexPaths($, t)).filter(p => relative(t.root, p).toLowerCase().includes(q)).slice(0, SEARCH_REVEAL_LIMIT)
897 if ((await get($)).query !== query) return []
898 await revealPaths($, hits)
899 const shown = await get($)
900 const ids = new Set(shown.nodes.map(n => n.id))
901 const stale = [...new Set(hits.filter(p => !ids.has(p)).map(dirname))].filter(d => inside(shown.root, d))
902 if (stale.length) await loadDirs($, stale)
903 return hits
904}
905
906async function jump($: EngineInterface, query: string): Promise<void> {
907 const hits = (await search($, query)).slice(0, 10)
908 await patch($, cur => {
909 const open = new Set(cur.expanded)
910 for (const p of hits) for (const a of ancestorsOf(p, cur.root)) open.add(a)
911 return { query: '', expanded: [...open], cursor: hits[0] ?? cur.cursor, scroll: null }
912 })
913}
914
915async function fontState($: EngineInterface, charset: string): Promise<'ok' | 'stale' | 'missing'> {
916 try {
917 const out = (await $.process.run(['sh', '-c', FONT_SCRIPT, 'sh', charset], { timeoutMs: 5_000 })).stdout.trim()
918 return out === 'stale' || out === 'missing' ? out : 'ok'
919 } catch {
920 return 'missing'
921 }
922}
923
924async function finePointerOk($: EngineInterface): Promise<boolean> {
925 if (!(await $.env.get('HERDR_ENV'))) return true
926 try {
927 const out = (await $.process.run([(await $.env.get('HERDR_BIN_PATH')) || 'herdr', '--version'], { timeoutMs: 3_000 })).stdout
928 const [major = 0, minor = 0, fix = 0] = (/(\d+)\.(\d+)\.(\d+)/.exec(out) ?? []).slice(1).map(Number)
929 return major * 1e6 + minor * 1e3 + fix >= 9_001
930 } catch {
931 return false
932 }
933}
934
935const PREVIEW_CHARS = 90_000
936const PREVIEW_LINE = 2_000
937
938function fitSerialized(lines: string[], max: number): string[] {
939 let size = 0
940 for (let i = 0; i < lines.length; i++) {
941 size += JSON.stringify(lines[i]).length
942 if (size > max) return lines.slice(0, i)
943 }
944 return lines
945}
946
947async function loadPreview($: EngineInterface, path: string): Promise<void> {
948 let text = ''
949 let note = ''
950 try {
951 const stat = await $.fs.stat(path)
952 if (stat.size > 4_000_000) note = `${formatSize(stat.size)}, too large to preview`
953 else {
954 text = await $.fs.read(path)
955 if (text.slice(0, 8000).includes('\0')) {
956 text = ''
957 note = `binary file, ${formatSize(stat.size)}`
958 }
959 }
960 } catch (err) {
961 note = `could not read: ${String((err as Error)?.message ?? err).split('\n')[0]}`
962 }
963 const total = text.length
964 let lines = text.slice(0, PREVIEW_CHARS).replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '').split('\n')
965 if (total > PREVIEW_CHARS && lines.length > 1) lines.pop()
966 lines = lines.map(l => (l.length > PREVIEW_LINE ? `${l.slice(0, PREVIEW_LINE)}…` : l))
967 const fit = fitSerialized(lines, PREVIEW_CHARS)
968 if (total > PREVIEW_CHARS || fit.length < lines.length) note = `first ${fit.length.toLocaleString('en')} lines of ${total.toLocaleString('en')} characters`
969 previewed = { path, text: fit.join('\n'), note }
970}
971
972async function loadDiff($: EngineInterface, path: string, folder: boolean): Promise<boolean> {
973 const cwd = folder ? path : dirname(path)
974 const born = await git($, cwd, ['rev-parse', '--verify', '--quiet', 'HEAD']).then(r => r.exitCode === 0, () => false)
975 const base = born ? 'HEAD' : await git($, cwd, ['hash-object', '-t', 'tree', '--stdin'], 10_000, '').then(r => r.stdout.trim(), () => '')
976 if (!base) return false
977 const tracked = await git($, cwd, ['diff', base, '--no-color', '--no-ext-diff', '--src-prefix=a/', '--dst-prefix=b/', '--', path]).then(r => r.stdout, () => '')
978 const others = await git($, cwd, ['ls-files', '--others', '--exclude-standard', '--full-name', '-z', '--', path]).then(r => r.stdout.split('\0').filter(Boolean), () => [] as string[])
979 const top = (await get($)).top || cwd
980 const files = splitPatch(tracked.slice(0, PREVIEW_CHARS))
981 for (const rel of others.slice(0, 50)) {
982 const run = await git($, top, ['diff', '--no-index', '--no-color', '--no-ext-diff', '--src-prefix=a/', '--dst-prefix=b/', '--', '/dev/null', rel]).catch(() => null)
983 const file = run ? splitPatch(run.stdout.slice(0, PREVIEW_CHARS))[0] : undefined
984 if (file) files.push({ path: rel, lines: file.lines })
985 }
986 if (!files.length) return false
987 const many = folder || files.length > 1
988 const lines = files.flatMap(f => (many ? [{ kind: '@' as const, text: f.path, old: 0, new: 0 }, ...f.lines] : f.lines))
989 const note = many ? `${files.length} changed file${files.length === 1 ? '' : 's'} against HEAD` : others.length ? 'untracked file' : 'changes against HEAD'
990 previewed = { path, text: '', note, diff: lines }
991 return true
992}
993
994async function diffFile($: EngineInterface, path: string): Promise<void> {
995 if (!diffWithSetting) {
996 const t = await get($)
997 const folder = t.nodes.find(n => n.id === path)?.kind === 'dir'
998 if (t.preview === path && previewed?.path === path && previewed.diff) return folder ? closePreview($) : showPreview($, path)
999 if (!(await loadDiff($, path, folder))) {
1000 $.ui.toast(`no changes in ${shortPath(path)}`)
1001 return
1002 }
1003 await patch($, () => ({ preview: path, previewTop: 0, selected: path }))
1004 $.clock.after(100, () => parkPreview($))
1005 return
1006 }
1007 const os = await osName($)
1008 const host = await editorHost($, os)
1009 try {
1010 if (host === 'herdr') return await openInHerdr($, diffWithSetting, path)
1011 if (host === 'tmux') {
1012 const run = await $.process.run(['tmux', 'new-window', '-c', dirname(path), '--', ...words(diffWithSetting), path], { timeoutMs: 10_000 })
1013 if (run.exitCode !== 0) $.ui.toast(`could not diff ${shortPath(path)} with ${diffWithSetting}: ${run.stderr.trim().split('\n')[0] || `exit ${run.exitCode}`}`)
1014 return
1015 }
1016 if (os === 'linux') {
1017 const argv = ['sh', '-c', LINUX_LAUNCH, 'sh', 'xdg-terminal-exec', 'sh', '-c', 'cd "$1" && shift && exec "$@"', 'sh', dirname(path), ...words(diffWithSetting), path]
1018 const run = await $.process.run(argv, { timeoutMs: 10_000 })
1019 if (run.exitCode !== 0) $.ui.toast(`could not diff ${shortPath(path)} with ${diffWithSetting}: ${run.stderr.trim().split('\n')[0] || `exit ${run.exitCode}`}`)
1020 return
1021 }
1022 $.ui.toast('Diff with: a terminal command needs tmux, herdr or Linux. Clear Diff with to see the diff in the pane.')
1023 } catch {
1024 $.ui.toast(`could not diff ${shortPath(path)} with ${diffWithSetting}`)
1025 }
1026}
1027
1028async function showPreview($: EngineInterface, path: string): Promise<void> {
1029 await loadPreview($, path)
1030 await patch($, () => ({ preview: path, previewTop: 0, selected: path }))
1031 $.clock.after(100, () => parkPreview($))
1032}
1033
1034function parkPreview($: EngineInterface): void {
1035 void $.ui.focus({ requestId: PANE, key: editWithSetting || inTmux || inHerdr ? 'edit' : 'back' }).catch(() => {})
1036}
1037
1038async function closePreview($: EngineInterface): Promise<void> {
1039 await patch($, () => ({ preview: '', previewTop: 0 }))
1040 $.clock.after(100, () => void $.ui.focus({ requestId: PANE, key: showShortcuts ? 'nav-o' : 'rows' }).catch(() => {}))
1041}
1042
1043async function jumpDiff($: EngineInterface, dir: 1 | -1): Promise<void> {
1044 const lines = previewed?.diff
1045 if (!lines) return
1046 const files = lines.some(l => l.kind === '@')
1047 const marks = lines.flatMap((l, i) => {
1048 if (files) return l.kind === '@' ? [i] : []
1049 const prev = lines[i - 1]
1050 const gap = !prev || l.old !== prev.old + (prev.kind === '+' ? 0 : 1) || l.new !== prev.new + (prev.kind === '-' ? 0 : 1)
1051 return gap ? [i] : []
1052 })
1053 const t = await get($)
1054 const to = dir > 0 ? marks.find(i => i > t.previewTop) : [...marks].reverse().find(i => i < t.previewTop)
1055 if (to !== undefined) await patch($, () => ({ previewTop: to }))
1056}
1057
1058async function scrollPreview($: EngineInterface, by: number): Promise<void> {
1059 const t = await get($)
1060 const last = Math.max(0, (previewed?.diff?.length ?? previewed?.text.split('\n').length ?? 1) - previewRows)
1061 const top = Math.max(0, Math.min(last, t.previewTop + by))
1062 if (top !== t.previewTop) await patch($, () => ({ previewTop: top }))
1063}
1064
1065function previewLines(text: string, top: number, rows: number, markdown: boolean): { text: string; top: number } {
1066 const lines = text.split('\n')
1067 const from = Math.max(0, Math.min(top, lines.length - 1))
1068 let shown = lines.slice(from, from + rows)
1069 if (markdown) {
1070 // Reopen a code fence the window starts inside, so the rest is not read as prose.
1071 let fence = ''
1072 let opener = ''
1073 for (const line of lines.slice(0, from)) {
1074 const m = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line)
1075 if (!m) continue
1076 const mark = m[1] ?? ''
1077 if (!fence) {
1078 fence = mark
1079 opener = line.trimStart()
1080 } else if (mark[0] === fence[0] && mark.length >= fence.length && !(m[2] ?? '').trim()) fence = ''
1081 }
1082 if (fence) shown = [opener, ...shown]
1083 }
1084 return { text: shown.join('\n'), top: from }
1085}
1086
1087async function openFile($: EngineInterface, path: string, how: { mode?: string; line?: number } = {}): Promise<void> {
1088 const mode = (how.mode ?? openWithSetting).trim()
1089 if (mode === 'preview') return showPreview($, path)
1090 const os = await osName($)
1091 const host = mode === 'editor' ? await editorHost($, os) : ''
1092 if (mode === 'editor' && !host) {
1093 $.ui.toast('Open with: editor requires tmux or herdr on Linux or macOS. Clear Open with to use the default app, or set a custom command.')
1094 return
1095 }
1096 const native = (p: string) => (os === 'win32' ? p.replace(/\//g, '\\') : p)
1097 const custom = mode === 'editor' ? null : openWithCommand(mode, native(path), native(dirname(path)), how.line)
1098 const editor = mode === 'editor' ? (await $.env.get('VISUAL')) || (await $.env.get('EDITOR')) || 'vi' : undefined
1099 try {
1100 if (editor && host === 'herdr') return await openInHerdr($, editor, path, how.line)
1101 const { argv, init } = editor ? tmuxOpenCommand(editor, path, how.line) : custom ? { argv: os === 'linux' ? ['sh', '-c', LINUX_LAUNCH, 'sh', ...custom] : custom, init: { timeoutMs: 10_000 } } : openCommand(os, path)
1102 const run = await $.process.run(argv, init)
1103 if (run.exitCode !== 0) $.ui.toast(`could not open ${path} with ${editor ?? custom?.[0] ?? argv[0]}: ${run.stderr.trim().split('\n')[0] || `exit ${run.exitCode}`}`)
1104 } catch {
1105 $.ui.toast(`could not open ${path} with ${editor ?? custom?.[0] ?? 'the default app'}`)
1106 }
1107}
1108
1109async function editorHost($: EngineInterface, os: 'linux' | 'darwin' | 'win32'): Promise<'tmux' | 'herdr' | ''> {
1110 if (os === 'win32') return ''
1111 if (await $.env.get('TMUX')) return 'tmux'
1112 return (await $.env.get('HERDR_ENV')) ? 'herdr' : ''
1113}
1114
1115async function openInHerdr($: EngineInterface, editor: string, path: string, line?: number): Promise<void> {
1116 const { split, run, init } = herdrOpenCommand((await $.env.get('HERDR_BIN_PATH')) || 'herdr', editor, path, line)
1117 const made = await $.process.run(split, init)
1118 const sent = made.exitCode === 0 ? await $.process.run(run(herdrPane(made.stdout)), init) : made
1119 if (sent.exitCode !== 0) $.ui.toast(`could not open ${path} with ${editor}: ${herdrError(sent.stderr) || `exit ${sent.exitCode}`}`)
1120}
1121
1122async function editPreview($: EngineInterface, path: string): Promise<void> {
1123 if (editWithSetting || inTmux || inHerdr) await openFile($, path, { mode: editWithSetting || 'editor', line: previewLine })
1124}
1125
1126async function quickLook($: EngineInterface, path: string): Promise<void> {
1127 try {
1128 await $.process.run(['sh', '-c', '(nohup qlmanage -p "$1" >/dev/null 2>&1 &); sleep 0.4; osascript -e \'tell application "System Events" to set frontmost of first process whose name is "qlmanage" to true\' >/dev/null 2>&1 || true', 'sh', path], { timeoutMs: 10_000 })
1129 } catch {
1130 $.ui.toast(`could not preview ${path} with Quick Look`)
1131 }
1132}
1133
1134async function toggle($: EngineInterface, n: FileNode): Promise<void> {
1135 if (n.kind === 'dir' && !(await get($)).expanded.includes(n.id)) await loadDirs($, [n.id])
1136 await patch($, t => {
1137 const open = new Set(t.expanded)
1138 if (n.kind === 'dir') open.has(n.id) ? open.delete(n.id) : open.add(n.id)
1139 return { expanded: [...open], cursor: n.id, selected: n.kind === 'dir' ? t.selected : n.id }
1140 })
1141}
1142
1143async function press($: EngineInterface, n: FileNode): Promise<void> {
1144 const now = await $.clock.now()
1145 const isDouble = lastPress.key === n.id && now - lastPress.at < DOUBLE_MS
1146 lastPress = { key: isDouble ? '' : n.id, at: now }
1147 if (!isDouble) {
1148 await toggle($, n)
1149 return
1150 }
1151 await openNode($, n)
1152}
1153
1154async function openNode($: EngineInterface, n: FileNode): Promise<void> {
1155 if (n.kind === 'dir') {
1156 follow = false
1157 await reset($, n.id)
1158 } else await openFile($, n.id)
1159}
1160
1161async function navigate($: EngineInterface, key: string, shift = false, surface?: string): Promise<void> {
1162 const t = await get($)
1163 const rows = visibleRows(t)
1164 const at = rows.findIndex(r => r.node.id === t.cursor)
1165 const cur = rows[at]?.node
1166 const move = (d: number) => {
1167 const target = rows[Math.max(0, Math.min(rows.length - 1, (at < 0 ? 0 : at) + d))]
1168 return target ? patch($, () => ({ cursor: target.node.id, scroll: null })) : Promise.resolve()
1169 }
1170 if (key === 'up' || key === 'k') await move(-1)
1171 else if (key === 'down' || key === 'j') await move(1)
1172 else if (key === 'pageup') await move(-10)
1173 else if (key === 'pagedown') await move(10)
1174 else if (key === 'home') await move(-rows.length)
1175 else if (key === 'end') await move(rows.length)
1176 else if (cur && key === 'd') await diffFile($, cur.id)
1177 else if (cur && (key === 'y' || key === 'Y')) await copyPath($, cur.id, key === 'Y' || shift, surface)
1178 else if (cur && (key === 'right' || key === 'l') && cur.kind === 'dir' && !t.expanded.includes(cur.id)) await toggle($, cur)
1179 else if (cur && (key === 'left' || key === 'h')) {
1180 if (cur.kind === 'dir' && t.expanded.includes(cur.id)) await toggle($, cur)
1181 else if (cur.parent !== t.root) await patch($, () => ({ cursor: cur.parent }))
1182 } else if (cur && key === 'return') await (cur.kind !== 'dir' ? openNode($, cur) : toggle($, cur))
1183 else if (cur && (key === ' ' || key === 'space')) await (cur.kind !== 'dir' && (await osName($)) === 'darwin' ? quickLook($, cur.id) : toggle($, cur))
1184}
1185
1186async function loadTheme($: EngineInterface): Promise<void> {
1187 const path = `${(await $.env.get('HOME')) ?? ''}/${THEME_FILE}`
1188 try {
1189 const stat = await $.fs.stat(path)
1190 if (stat.kind !== 'file') throw new Error('no theme file')
1191 if (stat.mtimeMs === themeMtime) return
1192 await $.state.set(THEME, parseTheme(String(await $.fs.read(path))))
1193 themeMtime = stat.mtimeMs
1194 } catch {
1195 if (themeMtime === 0) return
1196 await $.state.set(THEME, DEFAULT_THEME)
1197 themeMtime = 0
1198 }
1199}
1200hooks/open.ts 137 lines1export const LINUX_LAUNCH = 'setsid -f -w "$@" </dev/null >/dev/null 2>&1 & p=$!; sleep 1; kill -0 "$p" 2>/dev/null && exit 0; wait "$p"'
2
3export const LINUX_OPEN = [
4 'set -f',
5 'f=$1',
6 'if command -v gio >/dev/null; then',
7 ' if [ -f "$f" ] && command -v file >/dev/null && encoding=$(file -b --mime-encoding -- "$f") && [ -n "$encoding" ] && [ "$encoding" != binary ]; then',
8 ' case $(gio info -a standard::content-type -- "$f" | sed -n \'s/.*standard::content-type: //p\') in',
9 ' video/* | audio/*)',
10 ' app=$(xdg-mime query default text/plain)',
11 ' IFS=:',
12 ' for dir in "${XDG_DATA_HOME:-$HOME/.local/share}" ${XDG_DATA_DIRS:-/usr/local/share:/usr/share}; do',
13 ' [ -n "$app" ] && [ -f "$dir/applications/$app" ] && exec gio launch "$dir/applications/$app" "$f"',
14 ' done',
15 ' echo "No text/plain desktop application found for $f" >&2',
16 ' exit 1',
17 ' ;;',
18 ' esac',
19 ' fi',
20 ' if (exec gio open "$f"); then exit 0; fi',
21 'fi',
22 'exec xdg-open "$f"',
23].join('\n')
24
25export const TMUX_OPEN = [
26 'f=$1 d=$2 n=$3; shift 3',
27 'bin=$1',
28 'case ${bin##*/} in nvim) ;; *) exec tmux new-window -c "$d" -- "$@" ${n:+"+$n"} "$f" ;; esac',
29 'probe() {',
30 ' "$bin" --server "$s" --remote-expr \'$TMUX . " " . $TMUX_PANE\' 2>/dev/null &',
31 ' request=$!',
32 ' (sleep 1; kill -KILL "$request") >/dev/null 2>&1 &',
33 ' timer=$!',
34 ' wait "$request" 2>/dev/null; result=$?',
35 ' kill "$timer" 2>/dev/null',
36 ' wait "$timer" 2>/dev/null',
37 ' return "$result"',
38 '}',
39 'here=$(tmux display-message -p \'#{socket_path} #{session_id}\')',
40 'best= act=-1',
41 'for s in "${XDG_RUNTIME_DIR:-/nonexistent}"/nvim.*.0 "${TMPDIR:-${TMP:-${TEMP:-/tmp}}}"/nvim."$(id -un)"/*/nvim.*.0; do',
42 ' [ -S "$s" ] && [ -O "$s" ] || continue',
43 ' t=$(probe) || continue',
44 ' p=${t##* } sock=${t%%,*}',
45 ' [ -n "$p" ] && [ "$sock" != "$t" ] || continue',
46 ' info=$(tmux display-message -p -t "$p" \'#{socket_path} #{session_id} #{window_activity}\' 2>/dev/null) || continue',
47 ' location=${info% *} activity=${info##* }',
48 ' [ "$location" = "$here" ] && [ "${location% *}" = "$sock" ] && [ "$activity" -gt "$act" ] || continue',
49 ' best=$s pane=$p act=$activity',
50 'done',
51 'if [ -n "$best" ] && "$bin" --server "$best" --remote "$f"; then',
52 ' [ -z "$n" ] || "$bin" --server "$best" --remote-send "<C-\\><C-N>:$n<CR>"',
53 ' exec tmux select-window -t "$pane" \\; select-pane -t "$pane"',
54 'fi',
55 'exec tmux new-window -c "$d" -- "$@" ${n:+"+$n"} "$f"',
56].join('\n')
57
58const WIN_OPEN = "$ErrorActionPreference = 'Stop'; [Diagnostics.Process]::Start([Diagnostics.ProcessStartInfo]@{ FileName = $env:PANE_OPEN_TARGET; UseShellExecute = $true }) | Out-Null"
59
60export function tmuxOpenCommand(editor: string, target: string, line?: number) {
61 const argv = words(editor)
62 if (!argv[0]) throw new Error('Editor command is empty')
63 const dir = target.slice(0, target.lastIndexOf('/')) || '/'
64 return {
65 argv: ['sh', '-c', TMUX_OPEN, 'sh', target, dir, line ? String(line) : '', ...argv],
66 init: { timeoutMs: 10_000 },
67 }
68}
69
70export function herdrOpenCommand(herdr: string, editor: string, target: string, line?: number) {
71 const argv = words(editor)
72 if (!argv[0]) throw new Error('Editor command is empty')
73 const dir = target.slice(0, target.lastIndexOf('/')) || '/'
74 const command = [...argv, ...(line ? [`+${line}`] : [])].map(w => `'${w.replace(/'/g, "'\\''")}'`).join(' ')
75 return {
76 split: [herdr, 'pane', 'split', '--current', '--direction', 'right', '--focus', '--cwd', dir, '--env', `PANE_OPEN_TARGET=${target}`],
77 run: (pane: string) => [herdr, 'pane', 'run', pane, `exec ${command} "$PANE_OPEN_TARGET"`],
78 init: { timeoutMs: 10_000 },
79 }
80}
81
82export function herdrPane(stdout: string): string {
83 const id: unknown = JSON.parse(stdout)?.result?.pane?.pane_id
84 if (typeof id !== 'string' || !id) throw new Error('herdr returned no pane id')
85 return id
86}
87
88export function herdrError(stderr: string): string {
89 let message: unknown
90 try {
91 message = JSON.parse(stderr)?.error?.message
92 } catch {}
93 return typeof message === 'string' && message ? message : (stderr.trim().split('\n')[0] ?? '')
94}
95
96export function openCommand(os: 'linux' | 'darwin' | 'win32', target: string) {
97 const argv = os === 'linux'
98 ? ['sh', '-c', LINUX_LAUNCH, 'sh', 'sh', '-c', LINUX_OPEN, 'sh', target]
99 : os === 'darwin'
100 ? ['open', '--', target]
101 : ['powershell', '-NoProfile', '-NonInteractive', '-Command', WIN_OPEN]
102 return {
103 argv,
104 init: {
105 timeoutMs: 10_000,
106 ...(os === 'win32' ? { env: { PANE_OPEN_TARGET: /^[a-z][a-z0-9+.-]*:\/\//i.test(target) ? target : target.replace(/\//g, '\\') } } : {}),
107 },
108 }
109}
110
111export function words(command: string): string[] {
112 const out: string[] = []
113 let word: string | null = null
114 let quote = ''
115 for (const ch of command) {
116 if (quote) {
117 if (ch === quote) quote = ''
118 else word = (word ?? '') + ch
119 } else if (ch === '"' || ch === "'") {
120 quote = ch
121 word ??= ''
122 } else if (/\s/.test(ch)) {
123 if (word !== null) out.push(word)
124 word = null
125 } else word = (word ?? '') + ch
126 }
127 if (word !== null) out.push(word)
128 return out
129}
130
131export function openWithCommand(command: string, path: string, dir: string, line = 1): string[] | null {
132 const argv = words(command)
133 if (!argv.length) return null
134 const filled = argv.map(w => w.replace(/\{(path|dir|line)\}/g, (_, key) => (key === 'path' ? path : key === 'dir' ? dir : String(line))))
135 return argv.some(w => w.includes('{path}')) ? filled : [...filled, path]
136}
137hooks/button.tsx 19 lines1import type { ClientModule } from 'claude-code'
2
3export type ButtonProps = { id: string; label: string; dim: boolean }
4
5// A header button that also tells the hooks module when the pointer is over it, which a Button
6// alone doesn't, so the header can say what the button does.
7const HeaderButton: ClientModule<ButtonProps, { over: boolean }> = (props, surface) => {
8 const { Button } = surface.elements
9 surface.onPointer(e => {
10 const over = e.type !== 'leave'
11 if (over === Boolean(surface.state?.over)) return
12 surface.setState({ over })
13 surface.post({ over })
14 })
15 return <Button key={props.id} plain dimColor={props.dim} label={props.label} onPress={() => surface.post({ press: true })} />
16}
17
18export default HeaderButton
19hooks/git.ts 272 lines1import { drivesOn, isAbsolute, posix } from './tree'
2
3export type GitAction = {
4 kind: string
5 verb: string
6 running: string
7 done: string
8 tone: string
9 icon: { nerd: string; plain: string }
10 init?: boolean
11}
12
13const ICON = {
14 commit: { nerd: '\u{f417}', plain: '●' },
15 push: { nerd: '\u{f0552}', plain: '↑' },
16 pull: { nerd: '\u{f0553}', plain: '↓' },
17 branch: { nerd: '\u{f418}', plain: '⑂' },
18 merge: { nerd: '\u{f419}', plain: '⑃' },
19 pr: { nerd: '\u{f407}', plain: '⇄' },
20 github: { nerd: '\u{f408}', plain: '◎' },
21 stash: { nerd: '\u{f01bc}', plain: '≡' },
22 undo: { nerd: '\u{f054c}', plain: '↶' },
23 tag: { nerd: '\u{f412}', plain: '⌖' },
24 git: { nerd: '\u{e702}', plain: '±' },
25}
26
27export const TONES: Record<string, { bright: string[]; dim: string[]; solid: string }> = {
28 orange: { bright: ['#f97316', '#fb923c', '#fdba74', '#ffedd5'], dim: ['#8e491d', '#a3562a', '#bd7444', '#d29267'], solid: '#f97316' },
29 green: { bright: ['#22c55e', '#4ade80', '#86efac', '#dcfce7'], dim: ['#2e6644', '#1b6838', '#2f7a47', '#4f9a66'], solid: '#4ade80' },
30 teal: { bright: ['#14b8a6', '#2dd4bf', '#5eead4', '#ccfbf1'], dim: ['#1b665f', '#16786f', '#2a9488', '#4fb3a8'], solid: '#2dd4bf' },
31 blue: { bright: ['#3b82f6', '#60a5fa', '#93c5fd', '#dbeafe'], dim: ['#40589c', '#1d4ed8', '#3b6fd1', '#6b93dc'], solid: '#60a5fa' },
32 purple: { bright: ['#a855f7', '#c084fc', '#d8b4fe', '#f3e8ff'], dim: ['#76459d', '#7d3cb2', '#8b47c4', '#a874d6'], solid: '#c084fc' },
33 cyan: { bright: ['#06b6d4', '#22d3ee', '#67e8f9', '#cffafe'], dim: ['#1c6379', '#0e7490', '#2b8ea3', '#5aa9b8'], solid: '#22d3ee' },
34 red: { bright: ['#ef4444', '#f87171', '#fca5a5', '#fee2e2'], dim: ['#954343', '#a43434', '#b54040', '#c96a6a'], solid: '#f87171' },
35}
36
37export const LIGHT_TONES: Record<string, { bright: string[]; dim: string[]; solid: string }> = {
38 orange: { bright: ['#7c2d12', '#69250e', '#561c0b', '#431407'], dim: ['#5d362e', '#52322c', '#482d2a', '#3e2828'], solid: '#7c2d12' },
39 green: { bright: ['#14532d', '#0f4725', '#0a3a1e', '#052e16'], dim: ['#244b3d', '#214439', '#1e3d35', '#1b3731'], solid: '#14532d' },
40 teal: { bright: ['#134e4a', '#0e4441', '#093937', '#042f2e'], dim: ['#23484d', '#204348', '#1e3d43', '#1b373e'], solid: '#134e4a' },
41 blue: { bright: ['#1e3a8a', '#1c3378', '#192c66', '#172554'], dim: ['#293d70', '#283966', '#26355d', '#253253'], solid: '#1e3a8a' },
42 purple: { bright: ['#581c87', '#4e157b', '#450e70', '#3b0764'], dim: ['#492d6f', '#442968', '#3f2562', '#39215b'], solid: '#581c87' },
43 cyan: { bright: ['#164e63', '#114559', '#0d3c4e', '#083344'], dim: ['#25485b', '#224355', '#203e4f', '#1d394a'], solid: '#164e63' },
44 red: { bright: ['#7f1d1d', '#6c1717', '#581010', '#450a0a'], dim: ['#5f2d34', '#542a31', '#49262d', '#3f232a'], solid: '#7f1d1d' },
45}
46
47const GIT_VERBS: Record<string, Omit<GitAction, 'kind'>> = {
48 commit: { verb: 'commit', running: 'committing', done: 'committed', tone: 'green', icon: ICON.commit },
49 push: { verb: 'push', running: 'pushing', done: 'pushed', tone: 'teal', icon: ICON.push },
50 pull: { verb: 'pull', running: 'pulling', done: 'pulled', tone: 'blue', icon: ICON.pull },
51 fetch: { verb: 'fetch', running: 'fetching', done: 'fetched', tone: 'blue', icon: ICON.pull },
52 checkout: { verb: 'checkout', running: 'checking out', done: 'checked out', tone: 'blue', icon: ICON.branch },
53 switch: { verb: 'switch', running: 'switching', done: 'switched', tone: 'blue', icon: ICON.branch },
54 branch: { verb: 'branch', running: 'branching', done: 'branched', tone: 'blue', icon: ICON.branch },
55 merge: { verb: 'merge', running: 'merging', done: 'merged', tone: 'purple', icon: ICON.merge },
56 rebase: { verb: 'rebase', running: 'rebasing', done: 'rebased', tone: 'purple', icon: ICON.merge },
57 'cherry-pick': { verb: 'cherry-pick', running: 'cherry-picking', done: 'cherry-picked', tone: 'purple', icon: ICON.merge },
58 stash: { verb: 'stash', running: 'stashing', done: 'stashed', tone: 'orange', icon: ICON.stash },
59 reset: { verb: 'reset', running: 'resetting', done: 'reset', tone: 'red', icon: ICON.undo },
60 restore: { verb: 'restore', running: 'restoring', done: 'restored', tone: 'red', icon: ICON.undo },
61 revert: { verb: 'revert', running: 'reverting', done: 'reverted', tone: 'red', icon: ICON.undo },
62 add: { verb: 'add', running: 'staging', done: 'staged', tone: 'green', icon: ICON.git },
63 rm: { verb: 'rm', running: 'removing', done: 'removed', tone: 'red', icon: ICON.git },
64 mv: { verb: 'mv', running: 'moving', done: 'moved', tone: 'orange', icon: ICON.git },
65 tag: { verb: 'tag', running: 'tagging', done: 'tagged', tone: 'purple', icon: ICON.tag },
66 init: { verb: 'init', running: 'initialising', done: 'initialised', tone: 'green', icon: ICON.git, init: true },
67 clone: { verb: 'clone', running: 'cloning', done: 'cloned', tone: 'blue', icon: ICON.pull, init: true },
68}
69
70const GH_VERBS: Record<string, Omit<GitAction, 'kind'>> = {
71 'pr create': { verb: 'pr create', running: 'opening PR', done: 'opened PR', tone: 'purple', icon: ICON.pr },
72 'pr merge': { verb: 'pr merge', running: 'merging PR', done: 'merged PR', tone: 'purple', icon: ICON.merge },
73 'pr checkout': { verb: 'pr checkout', running: 'checking out PR', done: 'checked out PR', tone: 'blue', icon: ICON.pr },
74 'pr comment': { verb: 'pr comment', running: 'commenting on PR', done: 'commented on PR', tone: 'purple', icon: ICON.pr },
75 'pr review': { verb: 'pr review', running: 'reviewing PR', done: 'reviewed PR', tone: 'purple', icon: ICON.pr },
76 'repo clone': { verb: 'repo clone', running: 'cloning', done: 'cloned', tone: 'blue', icon: ICON.github, init: true },
77 'release create': { verb: 'release create', running: 'releasing', done: 'released', tone: 'purple', icon: ICON.tag },
78 'issue create': { verb: 'issue create', running: 'opening issue', done: 'opened issue', tone: 'purple', icon: ICON.github },
79}
80
81const READ_ONLY_GIT = new Set(['status', 'log', 'diff', 'show', 'blame', 'rev-parse', 'ls-files', 'grep', 'describe', 'config', 'remote', 'reflog', 'shortlog', 'help', 'version', 'check-ignore'])
82
83function segments(command: string, seps: string[] = []): string[][] {
84 const out: string[][] = []
85 let cur: string[] = []
86 let tok = ''
87 let quote = ''
88 let has = false
89 const endTok = () => {
90 if (has) cur.push(tok)
91 tok = ''
92 has = false
93 }
94 const endSeg = () => {
95 endTok()
96 if (cur.length) out.push(cur)
97 cur = []
98 }
99 for (let i = 0; i < command.length; i++) {
100 const ch = command[i] ?? ''
101 if (quote) {
102 if (ch === quote) quote = ''
103 else if (ch === '\\' && quote === '"' && i + 1 < command.length) tok += command[++i]
104 else tok += ch
105 } else if (ch === '"' || ch === "'") {
106 quote = ch
107 has = true
108 } else if (ch === '\\' && i + 1 < command.length) {
109 tok += command[++i]
110 has = true
111 } else if (ch === '#' && !has) {
112 while (i + 1 < command.length && command[i + 1] !== '\n') i++
113 } else if (ch === ' ' || ch === '\t') endTok()
114 else if (ch === '\n' || ch === ';') {
115 seps.push(';')
116 endSeg()
117 } else if (ch === '&' && (command[i - 1] === '>' || command[i + 1] === '>')) {
118 tok += ch
119 has = true
120 } else if (ch === '&' || ch === '|') {
121 const twice = command[i + 1] === ch
122 if (twice) i++
123 seps.push(twice ? ch + ch : ch)
124 endSeg()
125 } else {
126 tok += ch
127 has = true
128 }
129 }
130 endSeg()
131 if (!quote) return out
132 seps.push(';')
133 return command
134 .split(/&&|\|\||;|\||\n/)
135 .map(s => s.trim().split(/\s+/).filter(Boolean))
136 .filter(s => s.length > 0)
137}
138
139function stripGlobals(tokens: string[]): string[] {
140 const out = [...tokens]
141 if (out[0] === 'env') out.shift()
142 while (out.length && /^[A-Z_][A-Z0-9_]*=/.test(out[0] ?? '')) out.shift()
143 return out
144}
145
146function gitVerb(tokens: string[]): number {
147 let i = 1
148 while (i < tokens.length && (tokens[i] ?? '').startsWith('-')) {
149 if (tokens[i] === '-C' || tokens[i] === '-c') i++
150 i++
151 }
152 return i
153}
154
155function gitReadOnly(verb: string, rest: string[]): boolean {
156 if (!verb || READ_ONLY_GIT.has(verb)) return true
157 if (verb === 'stash') return ['list', 'show'].includes(rest[0] ?? '')
158 if (verb === 'branch') return rest.every(t => t.startsWith('-'))
159 if (verb === 'tag') return rest.length === 0 || rest.some(t => /^(-l|--list|-n\d*|--contains|--points-at|--merged|--no-merged|-v|--verify)$/.test(t))
160 return false
161}
162
163export function chainOf(command: string): { size: number; and: boolean } {
164 const seps: string[] = []
165 const size = segments(command, seps).length
166 return { size, and: seps.every(s => s === '&&') }
167}
168
169export function gitActions(command: string): GitAction[] {
170 const out: GitAction[] = []
171 for (const raw of segments(command)) {
172 const tokens = stripGlobals(raw)
173 const head = tokens[0]?.split('/').pop()
174 if (head === 'git') {
175 const i = gitVerb(tokens)
176 const verb = tokens[i] ?? ''
177 const rest = tokens.slice(i + 1)
178 if (gitReadOnly(verb, rest)) continue
179 const spec = verb === 'checkout' && rest.includes('--') ? GIT_VERBS.restore : GIT_VERBS[verb]
180 if (spec) out.push({ kind: `git ${verb}`, ...spec })
181 } else if (head === 'gh') {
182 const pair = `${tokens[1] ?? ''} ${tokens[2] ?? ''}`
183 const spec = GH_VERBS[pair]
184 if (spec) out.push({ kind: `gh ${pair}`, ...spec })
185 }
186 }
187 return out
188}
189
190export const BRANCH_ICON = ICON.branch
191
192const READERS = new Set(['rg', 'grep', 'egrep', 'fgrep', 'find', 'fd', 'fdfind', 'cat', 'head', 'tail', 'bat', 'less', 'more', 'ls', 'eza', 'tree', 'wc', 'sed', 'awk', 'jq'])
193const PATTERN_FIRST = new Set(['rg', 'grep', 'egrep', 'fgrep', 'sed', 'awk', 'jq'])
194const OUTPUT_PATHS = new Set(['rg', 'grep', 'egrep', 'fgrep', 'find', 'fd', 'fdfind'])
195
196function tidy(path: string): string {
197 const s = path.replace(/\/+$/, '')
198 return s === '' ? '/' : /^[A-Za-z]:$/.test(s) && isAbsolute(`${s}/`) ? `${s}/` : s
199}
200
201export function resolve(cwd: string, p: string, home = ''): string {
202 let path = posix(p)
203 if (home && (path === '~' || path.startsWith('~/'))) path = home + path.slice(1)
204 const drive = isAbsolute(path) && !path.startsWith('/') ? path.slice(0, 2) : ''
205 const parts = isAbsolute(path) ? [drive] : posix(cwd).replace(/\/+$/, '').split('/')
206 for (const seg of path.slice(drive.length).split('/')) {
207 if (!seg || seg === '.') continue
208 if (seg === '..') {
209 if (parts.length > 1) parts.pop()
210 } else parts.push(seg)
211 }
212 return tidy(parts.join('/'))
213}
214
215const WRITE_FLAGS = /^-(delete|exec|execdir|ok|okdir|fprint\w*|fls|x|X|-exec|-exec-batch)$/
216
217export function readOnly(command: string): boolean {
218 const bare = ` ${command}`.replace(/\d*>&\d/g, ' ').replace(/(\d*|&)>>?\s*\/dev\/null/g, ' ')
219 if (/[^>]>[^>&]|>>/.test(bare)) return false
220 return segments(command).every(raw => {
221 const tokens = stripGlobals(raw)
222 const head = tokens[0]?.split('/').pop() ?? ''
223 if (head === 'cd' || head === 'echo' || head === 'printf' || head === 'true' || head === 'pwd') return true
224 if (head === 'git') {
225 const i = gitVerb(tokens)
226 return gitReadOnly(tokens[i] ?? '', tokens.slice(i + 1))
227 }
228 if (head === 'sed') return !tokens.some(t => /^-i/.test(t))
229 if (head === 'find' || head === 'fd' || head === 'fdfind') return !tokens.some(t => WRITE_FLAGS.test(t))
230 return READERS.has(head)
231 })
232}
233
234export function readTargets(command: string, sessionCwd: string, stdout: string, home = ''): string[] {
235 let cwd = sessionCwd
236 const out = new Set<string>()
237 let listsPaths = false
238 for (const raw of segments(command)) {
239 const tokens = stripGlobals(raw).map(t => posix(t.replace(/^["']|["']$/g, '')))
240 const head = tokens[0]?.split('/').pop() ?? ''
241 if (head === 'cd' && tokens[1]) {
242 cwd = resolve(cwd, tokens[1], home)
243 continue
244 }
245 if (!READERS.has(head)) continue
246 if (head === 'sed' && tokens.some(t => /^-i/.test(t))) continue
247 if (OUTPUT_PATHS.has(head)) listsPaths = true
248 const args = tokens.slice(1)
249 let skipPattern = PATTERN_FIRST.has(head) && !args.some(t => t === '-e' || t === '-f' || t === '--files')
250 for (let i = 0; i < args.length; i++) {
251 const a = args[i] ?? ''
252 if (a.startsWith('-')) {
253 if (/^-(e|f|g|t|T|m|A|B|C|-glob|-type|-max-count|name|iname|maxdepth|mindepth)$/.test(a)) i++
254 continue
255 }
256 if (skipPattern) {
257 skipPattern = false
258 continue
259 }
260 if (/[*?<>|]/.test(a)) continue
261 out.add(resolve(cwd, a, home))
262 }
263 }
264 if (listsPaths) {
265 for (const line of stdout.split('\n').slice(0, 400)) {
266 const path = posix(line).match(drivesOn() ? /^((?:[A-Za-z]:)?[^:\0]+?)(?::\d+[:-]|$|:)/ : /^([^:\0]+?)(?::\d+[:-]|$|:)/)?.[1]?.trim()
267 if (path && !path.includes(' ')) out.add(resolve(cwd, path, home))
268 }
269 }
270 return [...out].slice(0, 60)
271}
272hooks/rows.tsx 178 lines1import type { ClientModule, RenderElement } from 'claude-code'
2
3export type Seg = { t: string; c?: string; b?: boolean; s?: boolean; i?: boolean; sh?: string; dim?: boolean; one?: boolean; spin?: boolean; bg?: string; tab?: string }
4export type RowSpec = { id: string; left: Seg[]; right: Seg[] }
5export type RowsProps = {
6 rows: RowSpec[]
7 active: string
8 activeBg: string
9 hoverBg: string
10 tones: Record<string, { bright: string[]; dim: string[] }>
11 spinner?: string[]
12 pointer?: boolean
13 bar?: { pos: number; size: number; thumb: string; track: string }
14 preview?: { text: string; path: string; startLine: number; markdown: boolean; segments?: { title: string; source: string }[]; titleColor?: string }
15}
16type Local = { hover: number; phase: number; drag: boolean; ref: { stop?: () => void; unpoint?: () => void } }
17
18const TICK_MS = 90
19const TREE_BUDGET = 70_000
20const MAX_ROWS = 1_500
21const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
22
23function shimmer(i: number, phase: number, len: number, palette: string[]): string {
24 const band = ((phase * 1.6) % (len + 8)) - 4
25 const d = Math.abs(i - band)
26 return palette[d < 0.8 ? 3 : d < 1.8 ? 2 : d < 2.8 ? 1 : 0] ?? palette[0] ?? '#f97316'
27}
28
29const Rows: ClientModule<RowsProps, Local> = (props, surface) => {
30 const { Box, Text } = surface.elements
31 let state = surface.state
32 if (state === undefined) {
33 state = { hover: -1, phase: 0, drag: false, ref: {} }
34 surface.setState(state)
35 }
36 let visible = props.rows.length
37 const lit = props.rows.some(r => r.left.some(s => s.sh || s.spin) || r.right.some(s => s.sh || s.spin))
38 if (lit && !state.ref.stop) {
39 state.ref.stop = surface.every(TICK_MS, () => {
40 const cur = surface.state
41 if (cur) surface.setState({ ...cur, phase: cur.phase + 1 })
42 })
43 } else if (!lit && state.ref.stop) {
44 state.ref.stop()
45 state.ref.stop = undefined
46 }
47 if (props.pointer === false) {
48 state.ref.unpoint?.()
49 state.ref.unpoint = undefined
50 } else state.ref.unpoint = surface.onPointer(e => {
51 const cur = surface.state ?? state
52 const span = Math.max(1, props.rows.length - 1)
53 const onBar = Boolean(props.bar) && e.x >= surface.columns - 1
54 if (props.bar && (cur.drag || (onBar && e.type === 'down' && (e.button ?? 'left') === 'left'))) {
55 if (e.type === 'up' || e.type === 'leave') {
56 surface.setState({ ...cur, drag: false })
57 return
58 }
59 if (e.type === 'down' || e.type === 'move') {
60 if (!cur.drag) surface.setState({ ...cur, drag: true, hover: -1 })
61 surface.post({ scrollTo: Math.max(0, Math.min(1, e.y / span)) })
62 return
63 }
64 }
65 if (e.type === 'leave' || e.y < 0 || e.y >= visible) {
66 if (cur.hover !== -1) surface.setState({ ...cur, hover: -1 })
67 return
68 }
69 if (e.type === 'move' && !e.button && cur.hover !== e.y) surface.setState({ ...cur, hover: e.y })
70 const row = props.rows[e.y]
71 if (e.type === 'down' && e.button === 'right' && row?.id) {
72 surface.post({ copy: row.id, shift: Boolean(e.shift) })
73 return
74 }
75 if (e.type !== 'down' || (e.button ?? 'left') !== 'left' || !row) return
76 let x = 0
77 for (const seg of row.left) {
78 const w = [...seg.t].length
79 if (seg.tab && e.x >= x && e.x < x + w) {
80 surface.post({ tab: seg.tab })
81 return
82 }
83 x += w
84 }
85 if (row.id) surface.post({ press: row.id, ctrl: Boolean(e.ctrl), shift: Boolean(e.shift) })
86 })
87 surface.onKey(e => surface.post({ key: e.key, ctrl: Boolean(e.ctrl), shift: Boolean(e.shift) }))
88 // The preview draws in this same element, so the focus a click gave the tree stays put.
89 if (props.preview) {
90 const { Markdown, Code, Box: Column, Text: Title } = surface.elements
91 const p = props.preview
92 if (p.segments) {
93 return (
94 <Column flexDirection="column">
95 {p.segments.map((g, i) => (
96 <Column key={`seg-${i}`} flexDirection="column">
97 {g.title ? <Title bold color={p.titleColor || undefined}>{g.title}</Title> : null}
98 {g.source ? <Code source={g.source} path={g.title || p.path} format="diff" wrap="truncate-end" /> : null}
99 </Column>
100 ))}
101 </Column>
102 )
103 }
104 return p.markdown ? <Markdown text={p.text} /> : <Code source={p.text} path={p.path} startLine={p.startLine} wrap="truncate-end" />
105 }
106 const frames = props.spinner?.length ? props.spinner : FRAMES
107 const draw = (s: Seg) => {
108 if (s.spin) {
109 return (
110 <Text color={s.c} bold={s.b}>
111 {s.t + (frames[state.phase % frames.length] ?? '')}
112 </Text>
113 )
114 }
115 const palette = s.sh ? (s.dim ? props.tones[s.sh]?.dim : props.tones[s.sh]?.bright) : undefined
116 if (!palette) {
117 return (
118 <Text color={s.c} backgroundColor={s.bg} bold={s.b} strikethrough={s.s} italic={s.i}>
119 {s.t}
120 </Text>
121 )
122 }
123 if (s.one) {
124 return (
125 <Text color={shimmer(-1, state.phase, s.t.length, palette)}>
126 {s.t}
127 </Text>
128 )
129 }
130 const chars = [...s.t]
131 return (
132 <Text bold={s.b}>
133 {chars.map((ch, i) => (
134 <Text color={shimmer(i, state.phase, chars.length, palette)}>{ch}</Text>
135 ))}
136 </Text>
137 )
138 }
139 const rowEl = (r: RowSpec, i: number) => (
140 <Box
141 flexDirection="row"
142 height={1}
143 overflow="hidden"
144 backgroundColor={r.id && r.id === props.active ? props.activeBg : r.id && i === state.hover ? props.hoverBg : undefined}
145 >
146 <Box flexShrink={1} overflow="hidden">
147 {r.left.map(draw)}
148 </Box>
149 <Box flexGrow={1} />
150 {r.right.map(draw)}
151 {props.bar && (
152 <Text color={i >= props.bar.pos && i < props.bar.pos + props.bar.size ? props.bar.thumb : props.bar.track}>
153 {i >= props.bar.pos && i < props.bar.pos + props.bar.size ? '┃' : '│'}
154 </Text>
155 )}
156 </Box>
157 )
158 const drawn: RenderElement[] = []
159 let size = 0
160 for (let i = 0; i < props.rows.length; i++) {
161 const el = rowEl(props.rows[i] as RowSpec, i)
162 let cost = 1_000
163 try {
164 cost = JSON.stringify(el).length
165 } catch {}
166 if (size + cost > TREE_BUDGET || drawn.length >= MAX_ROWS) {
167 visible = i
168 drawn.push(<Text dimColor>{` … ${props.rows.length - i} more rows, collapse folders or use search`}</Text>)
169 break
170 }
171 size += cost
172 drawn.push(el)
173 }
174 return <Box flexDirection="column">{drawn}</Box>
175}
176
177export default Rows
178hooks/diff.ts 83 lines1export type DiffLine = { kind: ' ' | '+' | '-' | '@'; text: string; old: number; new: number }
2export type DiffSegment = { title: string; source: string }
3
4export function parseDiff(patch: string): DiffLine[] {
5 const out: DiffLine[] = []
6 let old = 0
7 let cur = 0
8 let inHunk = false
9 for (const line of patch.replace(/\r\n?/g, '\n').split('\n')) {
10 const head = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line)
11 if (head) {
12 old = Number(head[1])
13 cur = Number(head[2])
14 inHunk = true
15 continue
16 }
17 if (!inHunk || line.startsWith('\\')) continue
18 const kind = line[0]
19 if (kind === '+') out.push({ kind, text: line.slice(1), old, new: cur++ })
20 else if (kind === '-') out.push({ kind, text: line.slice(1), old: old++, new: cur })
21 else if (kind === ' ') out.push({ kind, text: line.slice(1), old: old++, new: cur++ })
22 else inHunk = false
23 }
24 return out
25}
26
27export function splitPatch(patch: string): { path: string; lines: DiffLine[] }[] {
28 return patch
29 .replace(/\r\n?/g, '\n')
30 .split(/^(?=diff --git )/m)
31 .map(part => {
32 const plus = /^\+\+\+ (?:b\/)?(.+)$/m.exec(part)?.[1]
33 const head = /^diff --git a\/.+ b\/(.+)$/m.exec(part)?.[1]
34 const lines = parseDiff(part)
35 const note = /^Binary files /m.test(part) ? 'binary file changed' : /^rename from /m.test(part) ? 'renamed, no text change' : /^copy from /m.test(part) ? 'copied, no text change' : /^(old|new) mode /m.test(part) ? 'file mode changed' : 'changed, no text diff'
36 return { path: (plus && plus !== '/dev/null' ? plus : head) ?? '', lines: lines.length || !note ? lines : [{ kind: ' ' as const, text: `(${note})`, old: 1, new: 1 }] }
37 })
38 .filter(f => f.path && f.lines.length)
39}
40
41export function diffWindow(lines: DiffLine[], top: number, rows: number): { segments: DiffSegment[]; top: number } {
42 const from = Math.max(0, Math.min(top, lines.length - 1))
43 const shown = lines.slice(from, from + rows)
44 const segments: DiffSegment[] = []
45 let title = ''
46 for (let i = from; i >= 0; i--) {
47 const l = lines[i]
48 if (l?.kind === '@') {
49 title = i === from ? '' : l.text
50 break
51 }
52 }
53 let out: string[] = []
54 let run: DiffLine[] = []
55 const flush = () => {
56 const first = run[0]
57 if (!first) return
58 const olds = run.filter(l => l.kind !== '+').length
59 const news = run.filter(l => l.kind !== '-').length
60 out.push(`@@ -${first.old},${olds} +${first.new},${news} @@`, ...run.map(l => `${l.kind}${l.text}`))
61 run = []
62 }
63 const close = () => {
64 flush()
65 if (out.length || title) segments.push({ title, source: out.join('\n') })
66 out = []
67 }
68 for (const line of shown) {
69 if (line.kind === '@') {
70 close()
71 title = line.text
72 continue
73 }
74 const prev = run[run.length - 1]
75 const nextOld = prev ? prev.old + (prev.kind === '+' ? 0 : 1) : 0
76 const nextNew = prev ? prev.new + (prev.kind === '-' ? 0 : 1) : 0
77 if (prev && (line.old !== nextOld || line.new !== nextNew)) flush()
78 run.push(line)
79 }
80 close()
81 return { segments, top: from }
82}
83hooks/palette.ts 40 lines1import type { Theme } from '../types'
2
3export type Palette = {
4 theme: Partial<Theme>
5 git: Record<string, string>
6 add?: string
7 del?: string
8 hover?: string
9 unknown: string[]
10}
11
12export const ADD_COLOR = '#98c379'
13export const DEL_COLOR = '#e06c75'
14export const LIGHT_ADD_COLOR = '#14532d'
15export const LIGHT_DEL_COLOR = '#7f1d1d'
16export const LIGHT_HOVER = '#f0f2f5'
17
18const THEME_KEYS = ['fg', 'bg', 'accent', 'muted', 'urgent', 'selection'] as const
19const GIT_KEYS: Record<string, string[]> = { added: ['A', '?'], modified: ['M', 'T'], renamed: ['R', 'C'] }
20const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i
21
22export function parsePalette(text: unknown): Palette {
23 const out: Palette = { theme: {}, git: {}, unknown: [] }
24 if (typeof text !== 'string') return out
25 for (const pair of text.split(/[\s,;]+/).filter(Boolean)) {
26 const [rawKey, value = ''] = pair.split('=')
27 const key = (rawKey ?? '').trim().toLowerCase()
28 if (!HEX.test(value.trim())) {
29 out.unknown.push(pair)
30 continue
31 }
32 const color = value.trim().toLowerCase().replace(/^#(.)(.)(.)$/, '#$1$1$2$2$3$3')
33 if ((THEME_KEYS as readonly string[]).includes(key)) out.theme[key as (typeof THEME_KEYS)[number]] = color
34 else if (key in GIT_KEYS) for (const status of GIT_KEYS[key] ?? []) out.git[status] = color
35 else if (key === 'add' || key === 'del' || key === 'hover') out[key] = color
36 else out.unknown.push(pair)
37 }
38 return out
39}
40hooks/icons.ts 141 lines1import type { FileNode } from '../types'
2
3const cp = (n: number) => String.fromCodePoint(n)
4
5export const CHEVRON_OPEN = cp(0xf47c)
6export const CHEVRON_CLOSED = cp(0xf460)
7
8const NAMES: Record<string, number> = {
9 'agents.md': 0xf0354,
10 'changelog.md': 0xf0354,
11 'contributing.md': 0xf0354,
12 'todo.md': 0xf0354,
13 'readme.md': 0xf0354,
14 license: 0xe60a,
15 dockerfile: 0xf0868,
16 makefile: 0xf1064,
17 'cmakelists.txt': 0xf1064,
18 justfile: 0xf05b7,
19 gemfile: 0xf0d2d,
20 rakefile: 0xf0d2d,
21 'cargo.toml': 0xe6b2,
22 'cargo.lock': 0xe6b2,
23 'pyproject.toml': 0xe6b2,
24 'requirements.txt': 0xf160e,
25 'go.mod': 0xf0afa,
26 'go.sum': 0xf07d3,
27 'package.json': 0xf0626,
28 'package-lock.json': 0xf0626,
29 'tsconfig.json': 0xf0626,
30 '.prettierrc': 0xf0626,
31 '.eslintrc': 0xf0626,
32 'pnpm-lock.yaml': 0xe6a8,
33 'yarn.lock': 0xe6a8,
34 '.gitignore': 0xf02a2,
35 '.gitmodules': 0xf0493,
36 '.editorconfig': 0xe652,
37}
38
39const EXTS: Record<string, number> = {
40 lua: 0xe620,
41 py: 0xe606,
42 pyi: 0xe606,
43 js: 0xe60c,
44 mjs: 0xe60c,
45 cjs: 0xe60c,
46 ts: 0xe628,
47 jsx: 0xe625,
48 tsx: 0xe7ba,
49 json: 0xe60b,
50 jsonc: 0xe60b,
51 yaml: 0xe6a8,
52 yml: 0xe6a8,
53 sh: 0xe795,
54 bash: 0xe795,
55 zsh: 0xe795,
56 fish: 0xe795,
57 md: 0xf0354,
58 markdown: 0xf0354,
59 css: 0xe6b8,
60 scss: 0xf031c,
61 html: 0xe736,
62 htm: 0xe736,
63 go: 0xe627,
64 rs: 0xe68b,
65 rb: 0xe791,
66 php: 0xe608,
67 java: 0xe738,
68 cs: 0xf031b,
69 sql: 0xe706,
70 graphql: 0xf20e,
71 gql: 0xf20e,
72 xml: 0xf05c0,
73 toml: 0xe6b2,
74 ini: 0xf0bc2,
75 conf: 0xf0493,
76 pdf: 0xeaeb,
77 svg: 0xf0721,
78 jpg: 0xf0225,
79 jpeg: 0xf0225,
80 png: 0xe60d,
81 gif: 0xf0d78,
82 webp: 0xf021f,
83 mp4: 0xf022b,
84 mkv: 0xf022b,
85 mov: 0xf022b,
86 mp3: 0xf0223,
87 wav: 0xf0223,
88 flac: 0xf0223,
89 zip: 0xf05c4,
90 gz: 0xf05c4,
91 xz: 0xf05c4,
92 tar: 0xf05c4,
93 qml: 0xf375,
94 cpp: 0xe61d,
95 cc: 0xe61d,
96 cxx: 0xe61d,
97 c: 0xe61e,
98 h: 0xf0af5,
99 hpp: 0xf0af5,
100 vue: 0xe6a0,
101 svelte: 0xe697,
102 csv: 0xe64a,
103 txt: 0xf09aa,
104}
105
106export function fileIcon(n: FileNode, open: boolean, isRepo: boolean): string {
107 if (n.kind === 'link') return cp(0xf481)
108 if (n.kind === 'dir') return cp(isRepo ? 0xf02a2 : open ? 0xe5fe : 0xe5ff)
109 const lower = n.name.toLowerCase()
110 const named = NAMES[lower]
111 if (named) return cp(named)
112 const dot = lower.lastIndexOf('.')
113 const ext = dot >= 0 ? lower.slice(dot + 1) : ''
114 return cp(EXTS[ext] ?? 0xf0214)
115}
116
117export const GIT_COLOR: Record<string, string> = {
118 A: '#98c379',
119 '?': '#98c379',
120 R: '#61afef',
121 C: '#61afef',
122 M: '#e5c07b',
123 T: '#e5c07b',
124}
125
126export const LIGHT_GIT_COLOR: Record<string, string> = {
127 A: '#14532d',
128 '?': '#14532d',
129 R: '#1e3a8a',
130 C: '#1e3a8a',
131 M: '#78350f',
132 T: '#78350f',
133}
134
135const PRIORITY = ['U', 'D', 'M', 'T', 'R', 'C', 'A', '?']
136
137export function stronger(a: string | undefined, b: string): string {
138 if (!a) return b
139 return PRIORITY.indexOf(b) < PRIORITY.indexOf(a) ? b : a
140}
141hooks/tree.ts 355 lines1import type { Branch, FileNode, FileTree, Theme } from '../types'
2import { stronger } from './icons'
3
4export type Entry = { name: string; kind: 'file' | 'dir' | 'other'; mtimeMs: number; size: number; isLink: boolean }
5export type Row = { node: FileNode; depth: number; open: boolean }
6
7const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
8
9export const DEFAULT_THEME: Theme = {
10 fg: '',
11 accent: '#5b9bd5',
12 muted: '#808a96',
13 urgent: '#d0605e',
14 selection: '#6b7280',
15 bg: '',
16}
17
18export const LIGHT_THEME: Theme = {
19 fg: '',
20 accent: '#1e3a8a',
21 muted: '#374151',
22 urgent: '#7f1d1d',
23 selection: '#e2e6ec',
24 bg: '',
25}
26
27export function emptyTree(root: string): FileTree {
28 return {
29 root,
30 nodes: [],
31 expanded: [],
32 cursor: '',
33 selected: '',
34 query: '',
35 showHidden: true,
36 showSize: false,
37 dirSizes: {},
38 git: {},
39 diff: {},
40 ignored: [],
41 untrackedDirs: [],
42 top: '',
43 prefix: '',
44 branch: null,
45 counts: {},
46 flash: [],
47 flashDim: [],
48 flashOn: false,
49 flashTones: {},
50 scroll: null,
51 preview: '',
52 previewTop: 0,
53 }
54}
55
56export function parseTheme(toml: string): Theme {
57 const get = (k: string) => toml.match(new RegExp(`^${k}\\s*=\\s*"(#[0-9a-fA-F]{6})"`, 'm'))?.[1]
58 return {
59 fg: get('foreground') ?? get('color7') ?? DEFAULT_THEME.fg,
60 accent: get('accent') ?? get('color4') ?? DEFAULT_THEME.accent,
61 muted: get('muted') ?? get('color8') ?? DEFAULT_THEME.muted,
62 urgent: get('red') ?? get('color1') ?? DEFAULT_THEME.urgent,
63 selection: get('selection') ?? DEFAULT_THEME.selection,
64 bg: get('dark_background') ?? get('background') ?? DEFAULT_THEME.bg,
65 }
66}
67
68export function join(dir: string, name: string): string {
69 return dir.endsWith('/') ? dir + name : `${dir}/${name}`
70}
71
72let drives = false
73
74export function dirname(path: string): string {
75 if (drives && /^[A-Za-z]:\/[^/]*$/.test(path)) return path.slice(0, 3)
76 const i = path.lastIndexOf('/')
77 return i <= 0 ? '/' : path.slice(0, i)
78}
79
80export function useDrives(on: boolean): void {
81 drives = on
82}
83
84export function drivesOn(): boolean {
85 return drives
86}
87
88export function posix(path: string): string {
89 if (!drives) return path
90 return path.replace(/\\/g, '/').replace(/^\/([A-Za-z])(\/|$)/, (_, d: string) => `${d.toUpperCase()}:/`)
91}
92
93export function dropBelow(nodes: FileNode[], dirs: string[]): FileNode[] {
94 if (dirs.length === 0) return nodes
95 const under = (id: string) => dirs.some(d => id !== d && inside(d, id))
96 const gone = new Set(dirs)
97 return nodes.filter(n => !under(n.id)).map(n => (gone.has(n.id) && n.loaded ? { ...n, loaded: false } : n))
98}
99
100export function isAbsolute(path: string): boolean {
101 return path.startsWith('/') || (drives && /^[A-Za-z]:\//.test(path))
102}
103
104export function inside(root: string, path: string): boolean {
105 return path === root || path.startsWith(root.endsWith('/') ? root : `${root}/`)
106}
107
108export function toNodes(dir: string, list: Entry[]): FileNode[] {
109 return list
110 .map(e => ({
111 id: join(dir, e.name),
112 parent: dir,
113 name: e.name,
114 kind: e.isLink ? ('link' as const) : e.kind === 'dir' ? ('dir' as const) : ('file' as const),
115 hidden: e.name.startsWith('.'),
116 mtime: e.mtimeMs,
117 size: e.size,
118 loaded: false,
119 }))
120 .sort((a, b) => (a.kind === 'dir' ? 0 : 1) - (b.kind === 'dir' ? 0 : 1) || collator.compare(a.name, b.name))
121}
122
123export function replaceChildren(nodes: FileNode[], listed: Map<string, FileNode[]>): FileNode[] {
124 if (listed.size === 0) return nodes
125 const keep = new Map<string, FileNode>()
126 for (const n of nodes) if (listed.has(n.parent)) keep.set(n.id, n)
127 const freshIds = new Set<string>()
128 for (const kids of listed.values()) for (const k of kids) freshIds.add(k.id)
129 const gone = new Set([...keep.keys()].filter(id => !freshIds.has(id)))
130 const under = (id: string) => {
131 if (gone.size === 0) return false
132 for (let dir = dirname(id), prev = id; dir !== prev; prev = dir, dir = dirname(dir)) if (gone.has(dir)) return true
133 return false
134 }
135 const fresh: FileNode[] = []
136 for (const kids of listed.values()) {
137 for (const k of kids) {
138 if (under(k.id)) continue
139 const old = keep.get(k.id)
140 fresh.push(old && old.kind === 'dir' && k.kind === 'dir' ? { ...k, loaded: old.loaded } : k)
141 }
142 }
143 const out = nodes.filter(n => !listed.has(n.parent) && !under(n.id))
144 return [...out.map(n => (listed.has(n.id) ? { ...n, loaded: true } : n)), ...fresh]
145}
146
147export type GitStatus = {
148 git: Record<string, string>
149 ignored: string[]
150 untrackedDirs: string[]
151 branch: Branch | null
152 files: Record<string, Change>
153}
154
155export type Change = 'new' | 'mod' | 'del'
156
157export function parseBranch(record: string): Branch {
158 const body = record.replace(/^## /, '')
159 if (body.startsWith('HEAD (no branch)')) return { head: 'detached', upstream: '', ahead: 0, behind: 0 }
160 const head = body.match(/^(?:No commits yet on |Initial commit on )?(.+?)(?:\.\.\.|\s|$)/)?.[1] ?? body
161 const upstream = body.match(/\.\.\.(\S+)/)?.[1] ?? ''
162 const ahead = Number(body.match(/ahead (\d+)/)?.[1] ?? 0)
163 const behind = Number(body.match(/behind (\d+)/)?.[1] ?? 0)
164 return { head, upstream, ahead, behind }
165}
166
167export function mapper(root: string, prefix: string): (rel: string) => string | null {
168 return rel => (rel.startsWith(prefix) ? join(root, rel.slice(prefix.length).replace(/\/$/, '')).replace(/\/$/, '') : null)
169}
170
171export function parseGit(stdout: string, root: string, prefix: string): GitStatus {
172 const map = mapper(root, prefix)
173 const git: Record<string, string> = {}
174 const ignored: string[] = []
175 const untrackedDirs: string[] = []
176 const files: Record<string, Change> = {}
177 let branch: Branch | null = null
178 const parts = stdout.split('\0')
179 for (let i = 0; i < parts.length; i++) {
180 const rec = parts[i] ?? ''
181 if (rec.startsWith('## ')) {
182 branch = parseBranch(rec)
183 continue
184 }
185 if (rec.length < 4) continue
186 const xy = rec.slice(0, 2)
187 const raw = rec.slice(3)
188 if (xy[0] === 'R' || xy[0] === 'C') i++
189 const path = map(raw)
190 if (!path) continue
191 if (xy === '!!') {
192 ignored.push(path)
193 continue
194 }
195 if (xy === '??' && raw.endsWith('/')) untrackedDirs.push(path)
196 else files[path] = xy === '??' || (xy.includes('A') && !xy.includes('D')) ? 'new' : xy.includes('D') && !/U/.test(xy) ? 'del' : 'mod'
197 const letter =
198 xy === '??'
199 ? '?'
200 : /U/.test(xy) || xy === 'AA' || xy === 'DD'
201 ? 'U'
202 : (['D', 'M', 'R', 'C', 'A', 'T'].find(c => xy.includes(c)) ?? '')
203 if (!letter) continue
204 git[path] = stronger(git[path], letter)
205 let dir = dirname(path)
206 while (dir.length >= root.length && dir !== '/') {
207 git[dir] = stronger(git[dir], letter)
208 if (dir === root) break
209 dir = dirname(dir)
210 }
211 }
212 return { git, ignored, untrackedDirs, branch, files }
213}
214
215export function parseNumstat(stdout: string, root: string, prefix: string, into: Record<string, [number, number]>): void {
216 const map = mapper(root, prefix)
217 const parts = stdout.split('\0')
218 for (let i = 0; i < parts.length; i++) {
219 const rec = parts[i] ?? ''
220 if (!rec) continue
221 const m = rec.match(/^(-|\d+)\t(-|\d+)\t(.*)$/s)
222 if (!m) continue
223 let path = m[3] ?? ''
224 if (path === '') {
225 i += 2
226 path = parts[i] ?? ''
227 }
228 if (!path) continue
229 const add = m[1] === '-' ? 0 : Number(m[1])
230 const del = m[2] === '-' ? 0 : Number(m[2])
231 const abs = map(path)
232 if (!abs) continue
233 const prev = into[abs] ?? [0, 0]
234 into[abs] = [prev[0] + add, prev[1] + del]
235 }
236}
237
238export function rollUp(diff: Record<string, [number, number]>, root: string): Record<string, [number, number]> {
239 const out: Record<string, [number, number]> = { ...diff }
240 for (const [path, [add, del]] of Object.entries(diff)) {
241 let dir = dirname(path)
242 while (dir.length >= root.length && dir !== '/') {
243 const prev = out[dir] ?? [0, 0]
244 out[dir] = [prev[0] + add, prev[1] + del]
245 if (dir === root) break
246 dir = dirname(dir)
247 }
248 }
249 return out
250}
251
252export function rollCounts(files: Record<string, Change>, root: string): Record<string, [number, number, number]> {
253 const out: Record<string, [number, number, number]> = {}
254 const slot = { new: 0, mod: 1, del: 2 } as const
255 for (const [path, change] of Object.entries(files)) {
256 let dir = dirname(path)
257 while (dir.length >= root.length && dir !== '/') {
258 const cur = out[dir] ?? [0, 0, 0]
259 cur[slot[change]] += 1
260 out[dir] = cur
261 if (dir === root) break
262 dir = dirname(dir)
263 }
264 }
265 return out
266}
267
268export function underAny(id: string, set: Set<string>, root: string): boolean {
269 if (set.size === 0) return false
270 let cur = id
271 while (cur.length >= root.length) {
272 if (set.has(cur)) return true
273 const up = dirname(cur)
274 if (up === cur) break
275 cur = up
276 }
277 return false
278}
279
280export function ancestorsOf(id: string, root: string): string[] {
281 const out: string[] = []
282 if (!root) return out
283 for (let dir = dirname(id), prev = id; dir !== prev && dir !== root && inside(root, dir); prev = dir, dir = dirname(dir)) out.push(dir)
284 return out
285}
286
287export function relative(root: string, path: string): string {
288 return path === root ? '' : path.slice(root.endsWith('/') ? root.length : root.length + 1)
289}
290
291export function visibleRows(t: FileTree): Row[] {
292 const kids = new Map<string, FileNode[]>()
293 for (const n of t.nodes) {
294 if (!t.showHidden && n.hidden) continue
295 const list = kids.get(n.parent)
296 if (list) list.push(n)
297 else kids.set(n.parent, [n])
298 }
299 const q = t.query.trim().toLowerCase()
300 let keep: Set<string> | null = null
301 if (q) {
302 keep = new Set()
303 for (const n of t.nodes) {
304 if (!relative(t.root, n.id).toLowerCase().includes(q)) continue
305 keep.add(n.id)
306 for (const a of ancestorsOf(n.id, t.root)) keep.add(a)
307 }
308 }
309 const open = new Set(t.expanded)
310 const rows: Row[] = []
311 const walk = (dir: string, depth: number) => {
312 for (const n of kids.get(dir) ?? []) {
313 if (keep && !keep.has(n.id)) continue
314 const isOpen = n.kind === 'dir' && (keep ? kids.has(n.id) : open.has(n.id))
315 rows.push({ node: n, depth, open: isOpen })
316 if (isOpen) walk(n.id, depth + 1)
317 }
318 }
319 walk(t.root, 0)
320 return rows
321}
322
323export function stamp(ms: number): string {
324 if (!ms) return ''
325 const d = new Date(ms)
326 const p = (v: number) => String(v).padStart(2, '0')
327 return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
328}
329
330const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
331
332export function age(ms: number, now: number): string {
333 if (!ms) return ''
334 const s = (now - ms) / 1000
335 if (s < 60 && s > -60) return 'now'
336 if (s > 0 && s < 3600) return `${Math.floor(s / 60)}m`
337 if (s > 0 && s < 86_400) return `${Math.floor(s / 3600)}h`
338 if (s > 0 && s < 7 * 86_400) return `${Math.floor(s / 86_400)}d`
339 const d = new Date(ms)
340 return Math.abs(s) < 365 * 86_400 ? `${MONTHS[d.getMonth()]} ${d.getDate()}` : String(d.getFullYear())
341}
342
343export function formatSize(bytes: number): string {
344 if (bytes < 0) return '?'
345 if (bytes < 1024) return `${bytes} B`
346 const units = ['K', 'M', 'G', 'T']
347 let v = bytes / 1024
348 let i = 0
349 while (v >= 1024 && i < units.length - 1) {
350 v /= 1024
351 i++
352 }
353 return `${v < 100 ? v.toFixed(1) : Math.round(v)} ${units[i]}`
354}
355types/index.d.ts 76 lines1export type FileNode = {
2 id: string
3 parent: string
4 name: string
5 kind: 'dir' | 'file' | 'link'
6 hidden: boolean
7 mtime: number
8 size: number
9 loaded: boolean
10}
11
12export type Branch = {
13 head: string
14 upstream: string
15 ahead: number
16 behind: number
17}
18
19export type Activity = {
20 id: number
21 kind: string
22 label: string
23 state: 'running' | 'done' | 'failed'
24 detail: string
25 at: number
26 tone: string
27 nerd: string
28 plain: string
29}
30
31export type FileTree = {
32 root: string
33 nodes: FileNode[]
34 expanded: string[]
35 cursor: string
36 selected: string
37 query: string
38 showHidden: boolean
39 showSize: boolean
40 dirSizes: Record<string, number>
41 git: Record<string, string>
42 diff: Record<string, [number, number]>
43 ignored: string[]
44 untrackedDirs: string[]
45 top: string
46 prefix: string
47 branch: Branch | null
48 counts: Record<string, [number, number, number]>
49 flash: string[]
50 flashDim: string[]
51 flashOn: boolean
52 flashTones: Record<string, string>
53 scroll: number | null
54 preview: string
55 previewTop: number
56}
57
58export type Theme = {
59 fg: string
60 accent: string
61 muted: string
62 urgent: string
63 selection: string
64 bg: string
65}
66
67declare module 'claude-code' {
68 interface PluginState {
69 tecktree: {
70 tree: FileTree
71 theme: Theme
72 activity: Activity[]
73 }
74 }
75}
76