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

[!IMPORTANT] This is a fork. filetree is by Kurt Buhler (@data-goblin): the original is data-goblin/claude-code-filetree, MIT licensed (LICENSE, kept as it came). This copy was taken at upstream
bf8de32(0.2.24) and is changed here to fit the other mods in awesome-claude. Changes since then are listed under "Changes in this fork" at the end. For the original, report issues and send PRs upstream.
<h1 align="center">Claude Code Filetree</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.3.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="https://raw.githubusercontent.com/data-goblin/claude-code-filetree/bf8de321fcd03a9edb893b9ef3e3cc0e4af5bee5/media/filetree-shimmer.gif" alt="filetree shimmering the file Claude is editing" width="900">
[!NOTE] filetree 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/filetreereopens it there after you close it. Tested on Linux, macOS and Windows; mods don't load in WSL sessions of the Desktop app.
This fork installs from the awesome-claude marketplace. Run this in the terminal:
claude plugin marketplace add satoramoto/awesome-claude
claude plugin install filetree@awesome-claude
Or inside a Claude Code session:
/plugin marketplace add satoramoto/awesome-claude
/plugin install filetree@awesome-claude
Don't install it beside the original (filetree@claude-code-filetree), or both panes load. For the original, follow its README.
/filetree <path> pins another folder+N -N) on modified files and ?:N M:N D:N file counts on folders<img src="https://raw.githubusercontent.com/data-goblin/claude-code-filetree/bf8de321fcd03a9edb893b9ef3e3cc0e4af5bee5/media/filetree-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="https://raw.githubusercontent.com/data-goblin/claude-code-filetree/bf8de321fcd03a9edb893b9ef3e3cc0e4af5bee5/media/filetree-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="https://raw.githubusercontent.com/data-goblin/claude-code-filetree/bf8de321fcd03a9edb893b9ef3e3cc0e4af5bee5/media/filetree-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 writes▌ in the gutter, bold while the pane has the keyboard; key hints show at the foot thenYou can resize the pane with the mouse, or by setting custom pane:grow or pane:shrink keybindings in keybindings.json
<img src="https://raw.githubusercontent.com/data-goblin/claude-code-filetree/bf8de321fcd03a9edb893b9ef3e3cc0e4af5bee5/media/filetree-resize.gif" alt="Dragging the filetree pane edge to resize it" width="900">
All settings are in /config under filetree.
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.date (default) or size; what the right column shows when a session starts. The Σ button in the header toggles it.preview shows it in the pane instead: Markdown rendered, code highlighted, arrow keys, Page Up/Down and the wheel to scroll, ←/h or ← files back to the tree, ↗ the default app. 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}.auto (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.
This fork is built and checked like the other mods in awesome-claude: see its AGENTS.md. Upstream's pre-commit hook and its macOS, Windows and Linux CI weren't brought over; this repository's CI runs on Linux only.
MIT, copyright Kurt Buhler. The license is kept unchanged with the code.
This project is inspired by my Omarchy app FileBlade
From upstream bf8de32:
mods/filetree/ in awesome-claude.hooks/desktop.tsx). A header with the folder and its actions as icons, the branch line with ahead/behind and lines changed, the search field, then rows with GitHub's file and folder icons, git status colours, lines changed and dates or sizes in greys, the picked row highlighted, and a footer with the selection (and an open button) and git's latest step. The rows are drawn by the render hook rather than the terminal's Client, which can't draw icons; Claude's reads and writes light their rows (blue for reads, amber for writes, green for commits, with a rail beside them) for as long as the terminal's shimmer runs, and the pane scrolls to them. A press on a row selects it and opens a folder, a second press opens a file; arrow keys stay the terminal's. The terminal draws exactly as before. Changed files say so in their first line.attach(on, options, pane) in hooks/register.tsx registers the tree's hooks for that pane, the tree reading nothing until it first draws there. syns' dashboard copies hooks/ for its Files tab.media/ (this README links upstream's), the repository's own marketplace, its pre-commit hook and its platforms CI.hooks/register.tsx 1848 lines1// Changed in this fork (awesome-claude) for the desktop version: the desktop surface draws hooks/desktop.tsx.
2// The terminal draws in the kit's terminal design system: a section header, TREE
3// connectors, the kit's status colours as theme keys, and its note lines.
4//
5// Another mod can draw the tree in a tab of its own pane: it calls `attach` (see
6// there). syns' dashboard does, copying this folder. Every function that takes `$`
7// stays in this file: the engine follows `$` within one file only.
8import { type BuiltinToolResults, type Elements, type EngineInterface, type Hook, type Register, type RenderElement, type RenderInput, type Timer, update } from 'claude-code'
9import { LINUX_LAUNCH, openCommand, openWithCommand } from './open'
10
11import { ago, middle } from '../kit/format'
12import { C, MUTED, termInk, treePrefix } from '../kit/tokens'
13import { type Hint, holdButtons, kit, MARK } from '../kit/ui'
14
15import type { Activity, FileNode, FileTree } from '../types'
16import { renderDesktop, statusColor, toneColor } from './desktop'
17import type { DesktopElements } from './desktop'
18import { BRANCH_ICON, chainOf, type GitAction, gitActions, readOnly, readTargets, resolve, TONES } from './git'
19import type { RowSpec, RowsProps, Seg } from './rows'
20import { fileIcon } from './icons'
21import {
22 ancestorsOf,
23 type Change,
24 dirname,
25 emptyTree,
26 formatSize,
27 inside,
28 isAbsolute,
29 join,
30 mapper,
31 parseGit,
32 parseNumstat,
33 posix,
34 relative,
35 remoteHost,
36 remoteName,
37 HOST_MARK,
38 hostKind,
39 dropBelow,
40 useDrives,
41 rollCounts,
42 replaceChildren,
43 rollUp,
44 toNodes,
45 underAny,
46 visibleRows,
47} from './tree'
48
49const TREE = { plugin: 'filetree', key: 'tree' } as const
50const ACTIVITY = { plugin: 'filetree', key: 'activity' } as const
51/** The pane the tree draws in: its own, or a host's (see `attach`). */
52let PANE = 'filetree'
53/** The tab's name. The folder is the section header's title, so the tab doesn't repeat it. */
54const TAB = 'Files'
55/** Each activity tone's terminal colour: the kit's meaning for it (desktop's toneColor), as a theme key. */
56const SHIMMER: Record<string, string> = Object.fromEntries(TONES.map(k => [k, termInk(toneColor(k)).color ?? '']))
57/** The terminal's key hints while the pane has the keyboard, the most used first. */
58const TREE_HINTS: Hint[] = [
59 { key: '↑↓', label: 'move' },
60 { key: '⏎', label: 'open' },
61 { key: '←→', label: 'fold' },
62 { key: 'y', label: 'copy path' },
63]
64const PREVIEW_HINTS: Hint[] = [
65 { key: '↑↓', label: 'scroll' },
66 { key: '←', label: 'back' },
67]
68const BRANCH_ROW = '#branch'
69const FLASH_MS = 2700
70const RUNNING_MAX_MS = 600_000
71const NO_REPO_DEPTH = 4
72const DOUBLE_MS = 450
73const FIND_LIMIT = 200
74const READ_REVEAL_LIMIT = 12
75const SEARCH_REVEAL_LIMIT = 60
76const ACTIVITY_TTL_MS = 45_000
77const FONT_SCRIPT =
78 'if command -v fc-list >/dev/null 2>&1; then f=$(fc-list ":charset=$1" file | head -n1 | cut -d: -f1); ' +
79 'else f=$(ls "$HOME"/Library/Fonts/*Nerd* /Library/Fonts/*Nerd* 2>/dev/null | head -n1); fi; ' +
80 '[ -n "$f" ] || { echo missing; exit 0; }; m=$(stat -c %Z "$f" 2>/dev/null || stat -f %c "$f"); p=$PPID; ' +
81 '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 ' +
82 'ghostty|kitty|alacritty|Alacritty|foot|footclient|wezterm-gui|konsole|gnome-terminal-|xterm|urxvt|st|Terminal|iTerm2) ' +
83 '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; ' +
84 'p=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d " "); done; echo ok'
85const PRUNE = ['.git', 'node_modules', 'target', '.venv', '__pycache__', 'dist', '.next']
86const HOME_PRUNE = ['Library', 'AppData', '.Trash']
87const SIZE_TIMEOUT_MS = 15_000
88const SIZE_JOBS = 2
89const SIZE_WALK_LIMIT = 50_000
90const ROW_SCROLL_MS = 100
91
92let blink: Timer | null = null
93let generation = 0
94let lastPress = { key: '', at: 0 }
95let noNerd = false
96let glyphSetting = 'auto'
97let openWithSetting = ''
98let previewed: { path: string; text: string; note: string } | null = null
99let previewRows = 20
100let follow = true
101let followClaude = true
102let scanning: Promise<void> | null = null
103let scanJobs: Job[] = []
104let gitRun: Promise<void> | null = null
105let gitNext: Promise<void> | null = null
106let queuedReads: string[] = []
107let showReads = true
108let showWrites = true
109let searchIndex: { root: string; paths: Promise<string[]> } | null = null
110let activityId = 0
111let pointer = true
112let view = { from: 0, max: 0 }
113let nativeScroll = false
114let lastSync = 0
115// The pane opens only when asked for (/filetree), never by itself at session start.
116let noDock = true
117let home = ''
118let platform: Promise<'linux' | 'darwin' | 'win32'> | null = null
119let dirty: { root: string; files: Record<string, Change> } = { root: '', files: {} }
120let markId = 0
121let listSeq = 0
122let lastRoot = ''
123let sizeDefault = false
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
153async function get($: EngineInterface): Promise<FileTree> {
154 return { ...emptyTree(''), ...(await $.state.get(TREE)).value }
155}
156
157async function put($: EngineInterface, fn: (t: FileTree) => FileTree): Promise<void> {
158 await update($, TREE, cur => fn({ ...emptyTree(''), ...cur }))
159}
160
161function patch($: EngineInterface, fn: (t: FileTree) => Partial<FileTree>) {
162 return put($, t => ({ ...t, ...fn(t) }))
163}
164
165async function activities($: EngineInterface): Promise<Activity[]> {
166 return (await $.state.get(ACTIVITY)).value ?? []
167}
168
169async function setActivities($: EngineInterface, fn: (list: Activity[]) => Activity[]): Promise<void> {
170 await update($, ACTIVITY, cur => fn(cur ?? []).slice(-6))
171}
172
173async function list($: EngineInterface, dir: string): Promise<FileNode[] | null> {
174 try {
175 const entries = await $.fs.list(dir)
176 const resolved = await Promise.all(
177 entries.map(async e => {
178 if (!e.isLink) return { name: e.name, kind: e.kind, mtimeMs: e.mtimeMs, size: e.size, isLink: false }
179 try {
180 const target = await $.fs.stat(join(dir, e.name))
181 return target.kind === 'dir'
182 ? { name: e.name, kind: 'dir' as const, mtimeMs: target.mtimeMs, size: 0, isLink: false }
183 : { name: e.name, kind: 'file' as const, mtimeMs: target.mtimeMs, size: target.size, isLink: true }
184 } catch {
185 return { name: e.name, kind: 'other' as const, mtimeMs: 0, size: 0, isLink: true }
186 }
187 }),
188 )
189 unreadable.delete(dir)
190 return toNodes(dir, resolved)
191 } catch (err) {
192 if (!(await exists($, dir))) return []
193 if (!unreadable.has(dir)) {
194 unreadable.add(dir)
195 $.ui.toast(`could not list ${shortPath(dir)}: ${err instanceof Error ? err.message : String(err)}`)
196 }
197 return null
198 }
199}
200
201async function loadDirs($: EngineInterface, dirs: string[]): Promise<Map<string, FileNode[]>> {
202 const root = (await get($)).root
203 const seq = ++listSeq
204 for (const dir of dirs) listLatest.set(dir, seq)
205 const results = await Promise.all(dirs.map(async dir => [dir, await list($, dir)] as const))
206 const listed = new Map<string, FileNode[]>()
207 for (const [dir, kids] of results) {
208 if (listLatest.get(dir) !== seq) continue
209 listLatest.delete(dir)
210 if (kids) listed.set(dir, kids)
211 }
212 await patch($, t => (t.root === root ? { nodes: replaceChildren(t.nodes, listed) } : {}))
213 return listed
214}
215
216async function git($: EngineInterface, cwd: string, args: string[], timeoutMs = 20_000) {
217 return $.process.run(['git', '--no-optional-locks', '-C', cwd, ...args], { timeoutMs, env: { GIT_OPTIONAL_LOCKS: '0' } })
218}
219
220async function detectRepo($: EngineInterface): Promise<void> {
221 const t = await get($)
222 if (!t.root) return
223 let top = ''
224 let prefix = ''
225 try {
226 const run = await git($, t.root, ['rev-parse', '--show-prefix', '--show-toplevel'], 5_000)
227 const [p = '', tl = ''] = run.exitCode === 0 ? run.stdout.split('\n') : []
228 prefix = p.trim()
229 top = tl.trim()
230 } catch {
231 top = ''
232 }
233 await patch($, cur => (cur.root === t.root ? { top, prefix } : {}))
234}
235
236async function readGit($: EngineInterface): Promise<void> {
237 const t = await get($)
238 if (!t.root || !t.top) return
239 const root = t.root
240 try {
241 const status = await git($, root, ['status', '--porcelain=v1', '-b', '-z', '--ignored=traditional', '--untracked-files=normal', '--', '.'])
242 if (status.exitCode !== 0) return
243 const parsed = parseGit(status.stdout, root, t.prefix)
244 const diff: Record<string, [number, number]> = {}
245 const head = await git($, root, ['diff', 'HEAD', '--numstat', '-z', '--', '.'])
246 if (head.exitCode === 0) parseNumstat(head.stdout, root, t.prefix, diff)
247 const files = { ...parsed.files }
248 if (parsed.untrackedDirs.length) {
249 const others = await git($, root, ['ls-files', '-o', '--exclude-standard', '-z', '--', ...parsed.untrackedDirs.map(d => d.slice(root.length + 1) || '.')])
250 if (others.exitCode === 0) for (const rel of others.stdout.split('\0').filter(Boolean)) files[join(root, rel)] = 'new'
251 }
252 for (const path of Object.keys(diff)) if (files[path] === 'new') delete diff[path]
253 const branch = parsed.branch ? { ...parsed.branch, host: await hostOf($, t.top, remoteName(parsed.branch.upstream)) } : null
254 dirty = { root, files }
255 await patch($, cur =>
256 cur.root === root
257 ? {
258 git: parsed.git,
259 ignored: parsed.ignored,
260 untrackedDirs: parsed.untrackedDirs,
261 branch,
262 counts: rollCounts(files, root),
263 diff: rollUp(diff, root),
264 }
265 : {},
266 )
267 } catch {
268 return
269 }
270}
271
272/** Each repo's remotes' hosts, by `top` and remote name: a remote's URL rarely changes, so it's read once a load. */
273const hosts = new Map<string, string>()
274
275/** The host of `remote`'s URL in the repo at `top` (`github.com`), or '' for none. */
276async function hostOf($: EngineInterface, top: string, remote: string): Promise<string> {
277 if (!remote) return ''
278 const id = `${top}\u0000${remote}`
279 const known = hosts.get(id)
280 if (known !== undefined) return known
281 try {
282 const run = await git($, top, ['remote', 'get-url', remote], 5_000)
283 if (run.exitCode !== 0) return ''
284 // Only a successful lookup is cached: a timeout or failure is retried on a later refresh.
285 const host = remoteHost(run.stdout)
286 hosts.set(id, host)
287 return host
288 } catch {
289 return ''
290 }
291}
292
293function refreshGit($: EngineInterface): Promise<void> {
294 if (!gitRun) {
295 gitRun = readGit($).finally(() => {
296 gitRun = null
297 })
298 return gitRun
299 }
300 const rerun = () => {
301 gitNext = null
302 return refreshGit($)
303 }
304 gitNext ??= gitRun.then(rerun, rerun)
305 return gitNext
306}
307
308async function reset($: EngineInterface, root: string, focus = false): Promise<void> {
309 const prev = await get($)
310 generation += 1
311 blink?.cancel()
312 blink = null
313 lastRoot = root
314 searchIndex = null
315 sizeEpoch += 1
316 sizeQueue = []
317 await put($, () => ({ ...emptyTree(root), showHidden: prev.showHidden, showSize: prev.root ? prev.showSize : sizeDefault }))
318 if (focus) await $.ui.open({ id: PANE, title: TAB, focus: true })
319 else if (!noDock) await $.ui.open({ id: PANE, title: TAB })
320 await loadDirs($, [root])
321 await detectRepo($)
322 await refreshGit($)
323}
324
325async function revealPaths($: EngineInterface, paths: string[]): Promise<void> {
326 const tried = new Set<string>()
327 for (let pass = 0; pass < 32; pass++) {
328 const t = await get($)
329 const loaded = new Set(t.nodes.filter(n => n.loaded).map(n => n.id))
330 if (t.nodes.some(n => n.parent === t.root)) loaded.add(t.root)
331 const need = new Set<string>()
332 for (const p of paths) {
333 for (const dir of [t.root, ...ancestorsOf(p, t.root).reverse()]) {
334 if (!loaded.has(dir)) {
335 if (!tried.has(dir)) need.add(dir)
336 break
337 }
338 }
339 }
340 if (need.size === 0) return
341 for (const dir of need) tried.add(dir)
342 await loadDirs($, [...need])
343 }
344}
345
346function pruneArgs(root: string, ignored: string[] = []): string[] {
347 const names = broad(root) ? [...PRUNE, ...HOME_PRUNE] : PRUNE
348 return ['(', ...names.flatMap((name, i) => (i === 0 ? ['-name', name] : ['-o', '-name', name])), ...ignored.flatMap(p => ['-o', '-path', p]), ')', '-prune', '-o']
349}
350
351async function newer($: EngineInterface, paths: string[], sinceMs: number): Promise<string[]> {
352 const stats = await Promise.all(
353 paths.map(async p => {
354 try {
355 return (await $.fs.stat(p)).mtimeMs > sinceMs ? p : ''
356 } catch {
357 return ''
358 }
359 }),
360 )
361 return stats.filter(Boolean)
362}
363
364function broad(root: string): boolean {
365 return root === home || root === '/' || /^[A-Za-z]:\/$/.test(root)
366}
367
368function rootOfDisk(root: string): boolean {
369 return root === '/' || /^[A-Za-z]:\/$/.test(root)
370}
371
372async function changedSince($: EngineInterface, root: string, since: Since, depth: number, ignored: string[] = []): Promise<string[]> {
373 if (rootOfDisk(root)) return []
374 const test = since.mark ? ['-newer', since.mark] : ['-newermt', `@${(since.ms / 1000).toFixed(3)}`]
375 try {
376 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 })
377 return run.stdout.split('\0').filter(p => p && p !== root)
378 } catch {
379 return []
380 }
381}
382
383async function changedInRepo($: EngineInterface, root: string, since: Since, before: Record<string, Change>, ignored: string[]): Promise<{ hits: string[]; gone: string[] }> {
384 if (since.os !== 'win32') return { hits: await changedSince($, root, since, 0, ignored), gone: [] }
385 if (dirty.root !== root) return { hits: [], gone: [] }
386 const entries = Object.entries(dirty.files)
387 const gone = entries.filter(([p, c]) => c === 'del' && before[p] !== 'del').map(([p]) => p)
388 const live = entries.filter(([, c]) => c !== 'del').map(([p]) => p)
389 return { hits: await newer($, live.slice(0, 4_000), since.ms), gone }
390}
391
392type Since = { ms: number; mark: string; os: 'linux' | 'darwin' | 'win32' }
393
394async function sinceNow($: EngineInterface, writes: boolean): Promise<Since> {
395 const ms = await $.clock.now()
396 const os = await osName($)
397 if (os !== 'darwin' || !writes) return { ms, mark: '', os }
398 const mark = `${((await $.env.get('TMPDIR')) || '/tmp').replace(/\/+$/, '')}/filetree-${await $.session.id()}-${++markId}.mark`
399 try {
400 await $.process.run(['touch', mark], { timeoutMs: 3_000 })
401 return { ms, mark, os }
402 } catch {
403 return { ms, mark: '', os }
404 }
405}
406
407async function walkSize($: EngineInterface, dir: string): Promise<number> {
408 let total = 0
409 let seen = 0
410 let level = [dir]
411 while (level.length) {
412 const next: string[] = []
413 for (const d of level) {
414 let entries
415 try {
416 entries = await $.fs.list(d)
417 } catch {
418 continue
419 }
420 for (const e of entries) {
421 if (++seen > SIZE_WALK_LIMIT) return -1
422 if (e.kind === 'dir' && !e.isLink) next.push(join(d, e.name))
423 else if (e.kind === 'file') total += e.size
424 }
425 }
426 level = next
427 }
428 return total
429}
430
431async function dirSize($: EngineInterface, dir: string): Promise<number> {
432 if (rootOfDisk(dir)) return -1
433 if ((await osName($)) === 'win32') return walkSize($, dir)
434 try {
435 const run = await $.process.run(['du', '-skxH', dir], { timeoutMs: SIZE_TIMEOUT_MS })
436 const kb = Number(run.stdout.trim().split(/\s/)[0] || NaN)
437 return Number.isFinite(kb) ? kb * 1024 : -1
438 } catch {
439 return -1
440 }
441}
442
443function pumpSizes($: EngineInterface): void {
444 while (sizeActive < SIZE_JOBS && sizeQueue.length) {
445 const dir = sizeQueue.shift() ?? ''
446 const epoch = sizeEpoch
447 sizing.add(dir)
448 sizeActive += 1
449 void dirSize($, dir)
450 .then(async bytes => {
451 if (epoch === sizeEpoch) return patch($, cur => (inside(cur.root, dir) ? { dirSizes: { ...cur.dirSizes, [dir]: bytes } } : {}))
452 const cur = await get($)
453 if (cur.showSize && inside(cur.root, dir) && !(dir in cur.dirSizes) && !sizeQueue.includes(dir)) sizeQueue.push(dir)
454 })
455 .catch(() => undefined)
456 .finally(() => {
457 sizing.delete(dir)
458 sizeActive -= 1
459 pumpSizes($)
460 })
461 }
462}
463
464function wantSizes($: EngineInterface, dirs: string[]): void {
465 for (const dir of dirs) if (!sizing.has(dir) && !sizeQueue.includes(dir)) sizeQueue.push(dir)
466 pumpSizes($)
467}
468
469async function staleSizes($: EngineInterface, paths?: string[]): Promise<void> {
470 sizeEpoch += 1
471 sizeQueue = []
472 await patch($, cur => {
473 if (!paths) return { dirSizes: {} }
474 const keep: Record<string, number> = {}
475 for (const [dir, bytes] of Object.entries(cur.dirSizes)) if (!paths.some(p => inside(dir, p))) keep[dir] = bytes
476 return { dirSizes: keep }
477 })
478}
479
480function openDirs(t: FileTree): string[] {
481 const open = new Set(t.expanded)
482 return t.nodes.filter(n => n.kind === 'dir' && n.loaded && open.has(n.id)).map(n => n.id)
483}
484
485function keepTop(cur: FileTree, expanded: string[]): Partial<FileTree> {
486 if (followClaude) return { scroll: null }
487 const top = visibleRows(cur)[cur.scroll ?? view.from]?.node.id
488 const at = top ? visibleRows({ ...cur, expanded }).findIndex(r => r.node.id === top) : -1
489 return at < 0 ? {} : { scroll: at }
490}
491
492async function flash($: EngineInterface, tones: Record<string, string>): Promise<void> {
493 const unique = Object.keys(tones)
494 if (unique.length === 0) return
495 const mine = ++generation
496 const root = (await get($)).root
497 blink?.cancel()
498 blink = null
499 await patch($, cur => {
500 if (cur.root !== root) return {}
501 const open = new Set(cur.expanded)
502 const bright = new Set([...(cur.flashOn ? cur.flash.filter(id => !unique.includes(id)) : []), ...unique])
503 const dim = new Set<string>()
504 const all: Record<string, string> = cur.flashOn ? { ...cur.flashTones } : {}
505 if (cur.flashOn) {
506 for (const id of cur.flashDim) dim.add(id)
507 }
508 for (const id of unique) {
509 const tone = tones[id] ?? 'orange'
510 all[id] = tone
511 if (id === BRANCH_ROW) continue
512 const chain = ancestorsOf(id, cur.root)
513 if (!chain.some(a => !open.has(a))) continue
514 for (const a of chain) {
515 if (!open.has(a)) {
516 dim.add(a)
517 all[a] = all[a] ?? tone
518 }
519 open.add(a)
520 }
521 }
522 for (const id of bright) dim.delete(id)
523 return {
524 flash: [...bright],
525 flashDim: [...dim],
526 flashOn: true,
527 flashTones: all,
528 expanded: [...open],
529 ...keepTop(cur, [...open]),
530 }
531 })
532 if (generation !== mine) return
533 // The desktop scrolls the pane itself, so following Claude there means asking it to show the row.
534 if (followClaude) scrollToRow($, [...unique].reverse().find(id => id !== BRANCH_ROW) ?? '')
535 blink = $.clock.after(FLASH_MS, () => {
536 if (generation !== mine) return
537 blink = null
538 void patch($, cur => (generation === mine ? { flash: [], flashDim: [], flashOn: false, flashTones: {} } : {}))
539 })
540}
541
542/** On the desktop, brings a row into the pane's view once it's drawn; the terminal scrolls by `scroll`. */
543function scrollToRow($: EngineInterface, id: string): void {
544 if (!nativeScroll || !id) return
545 $.clock.after(ROW_SCROLL_MS, () => void $.ui.scroll({ to: { key: id }, in: PANE }).catch(() => undefined))
546}
547
548async function goUp($: EngineInterface): Promise<void> {
549 follow = false
550 await reset($, dirname((await get($)).root))
551}
552
553async function goHome($: EngineInterface): Promise<void> {
554 follow = true
555 await reset($, await cwdOf($))
556}
557
558async function refreshAll($: EngineInterface): Promise<void> {
559 const cur = await get($)
560 searchIndex = null
561 await staleSizes($)
562 await loadDirs($, [cur.root, ...cur.expanded])
563 await detectRepo($)
564 await refreshGit($)
565 if (cur.query.trim()) await search($, cur.query)
566}
567
568function collapseAll($: EngineInterface): Promise<void> {
569 return patch($, cur => ({ expanded: [], nodes: dropBelow(cur.nodes, cur.nodes.filter(x => x.parent === cur.root && x.kind === 'dir').map(x => x.id)) }))
570}
571
572async function followCwd($: EngineInterface): Promise<boolean> {
573 if (!follow) return false
574 const cwd = await cwdOf($)
575 const t = await get($)
576 if (t.root === cwd) return false
577 await reset($, cwd)
578 return true
579}
580
581type Pending = { actions: GitAction[]; ids: number[]; command: string; head: string }
582type Job = { actions: GitAction[]; since: Since; initRepo: boolean; readOnly: boolean }
583type Background = { p: Pending | null; job: Job; reads: string[] }
584type GitOperation = NonNullable<BuiltinToolResults['Bash']['gitOperation']>
585type Outcome = { state: 'done' | 'failed'; label: string; detail: string }
586
587const PROOF: Record<string, (op: GitOperation) => boolean> = {
588 push: op => Boolean(op.push),
589 merge: op => op.branch?.action === 'merged',
590 rebase: op => op.branch?.action === 'rebased',
591 'pr create': op => op.pr?.action === 'created',
592 'pr merge': op => op.pr?.action === 'merged',
593 'pr comment': op => op.pr?.action === 'commented',
594}
595
596async function startGit($: EngineInterface, actions: GitAction[]): Promise<number[]> {
597 const now = await $.clock.now()
598 const ids = actions.map(() => ++activityId)
599 await setActivities($, cur => [
600 ...cur.filter(a => a.state === 'running' || now - a.at < ACTIVITY_TTL_MS),
601 ...actions.map((a, i) => ({
602 id: ids[i] ?? 0,
603 kind: a.kind,
604 label: a.running,
605 state: 'running' as const,
606 detail: '',
607 at: now,
608 tone: a.tone,
609 nerd: a.icon.nerd,
610 plain: a.icon.plain,
611 })),
612 ])
613 return ids
614}
615
616async function headOf($: EngineInterface, cwd: string): Promise<string> {
617 try {
618 const run = await git($, cwd, ['rev-parse', 'HEAD'], 5_000)
619 return run.exitCode === 0 ? run.stdout.trim() : ''
620 } catch {
621 return ''
622 }
623}
624
625async function outcomes($: EngineInterface, p: Pending, ok: boolean, op: GitOperation | undefined): Promise<Outcome[]> {
626 const chain = chainOf(p.command)
627 const after = p.actions.some(a => a.verb === 'commit') && !op?.commit ? await headOf($, (await get($)).root || (await cwdOf($))) : ''
628 return p.actions.map(a => {
629 if (a.verb === 'commit') {
630 const sha = op?.commit?.sha || (after && after !== p.head ? after : '')
631 return sha ? { state: 'done', label: a.done, detail: sha.slice(0, 7) } : { state: 'failed', label: `${a.verb} failed`, detail: '' }
632 }
633 if ((op && PROOF[a.verb]?.(op)) || (ok && chain.and)) return { state: 'done', label: a.done, detail: '' }
634 if (!ok && chain.and && chain.size === 1) return { state: 'failed', label: `${a.verb} failed`, detail: '' }
635 return { state: 'done', label: `ran ${a.kind}`, detail: '' }
636 })
637}
638
639async function finishGit($: EngineInterface, p: Pending, results: Outcome[]): Promise<void> {
640 const now = await $.clock.now()
641 await setActivities($, cur =>
642 cur.map(a => {
643 const i = p.ids.indexOf(a.id)
644 const result = i < 0 ? undefined : results[i]
645 return result ? { ...a, ...result, at: now } : a
646 }),
647 )
648 $.clock.after(ACTIVITY_TTL_MS + 500, () => void setActivities($, cur => cur.filter(a => a.state === 'running' || a.at > now)))
649}
650
651function failAll(p: Pending): Outcome[] {
652 return p.actions.map(a => ({ state: 'failed', label: `${a.verb} failed`, detail: '' }))
653}
654
655function proven(p: Pending | null, results: Outcome[]): GitAction[] {
656 return p ? p.actions.filter((_, i) => results[i]?.state !== 'failed') : []
657}
658
659function taskEnds(text: string): [string, string][] {
660 return [...text.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)].flatMap(m => {
661 const id = /<task-id>([^<]*)<\/task-id>/.exec(m[1] ?? '')?.[1]?.trim()
662 const status = /<status>([^<]*)<\/status>/.exec(m[1] ?? '')?.[1]?.trim()
663 return id && status ? [[id, status] as [string, string]] : []
664 })
665}
666
667async function settleBackground($: EngineInterface, text: string): Promise<void> {
668 if (background.size === 0 || !text.includes('<task-notification>')) return
669 for (const [id, status] of taskEnds(text)) {
670 const task = background.get(id)
671 if (!task) continue
672 background.delete(id)
673 const ok = status === 'completed'
674 const results = task.p ? await outcomes($, task.p, ok, undefined) : []
675 if (task.p) await finishGit($, task.p, results)
676 if (showReads) queuedReads.push(...task.reads)
677 scheduleScan($, { ...task.job, actions: proven(task.p, results) })
678 }
679}
680
681async function gitPaths($: EngineInterface, root: string, prefix: string, committed: boolean): Promise<string[]> {
682 const map = mapper(root, prefix)
683 try {
684 const run = committed
685 ? await git($, root, ['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', '-z', 'HEAD'], 5_000)
686 : await git($, root, ['diff', '--cached', '--name-only', '-z'], 5_000)
687 return run.exitCode === 0 ? run.stdout.split('\0').filter(Boolean).flatMap(rel => map(rel) ?? []) : []
688 } catch {
689 return []
690 }
691}
692
693async function gitDir($: EngineInterface, cwd: string): Promise<string> {
694 for (let dir = cwd, i = 0; i < 64; i++) {
695 try {
696 const dot = join(dir, '.git')
697 const st = await $.fs.stat(dot)
698 if (st.kind === 'dir') return dot
699 const ref = /^gitdir:\s*(.+)$/m.exec(String(await $.fs.read(dot)))?.[1]?.trim()
700 if (ref) return resolve(dir, ref, home)
701 } catch {
702 const up = dirname(dir)
703 if (up === dir) return ''
704 dir = up
705 continue
706 }
707 return ''
708 }
709 return ''
710}
711
712async function sync($: EngineInterface, force = false): Promise<void> {
713 const now = await $.clock.now()
714 if (!force && now - lastSync < 2_000) return
715 lastSync = now
716 searchIndex = null
717 const t = await get($)
718 if (!t.root) return
719 await loadDirs($, [t.root, ...openDirs(t)])
720 if (t.top) await refreshGit($)
721}
722
723async function copyPath($: EngineInterface, id: string, absolute: boolean, surface?: string): Promise<void> {
724 const t = await get($)
725 const text = !absolute && id !== t.root && inside(t.root, id) ? id.slice(t.root.endsWith('/') ? t.root.length : t.root.length + 1) : id
726 const done = await $.ui.copy({ text, ...(surface ? { surface: surface as 'terminal' } : {}) })
727 $.ui.toast(done.isCopied ? `Copied ${text}` : 'Could not copy the path')
728}
729
730async function exists($: EngineInterface, path: string): Promise<boolean> {
731 try {
732 await $.fs.stat(path)
733 return true
734 } catch {
735 return false
736 }
737}
738
739async function afterBash($: EngineInterface, jobs: Job[]): Promise<void> {
740 const reads = queuedReads
741 queuedReads = []
742 if (await followCwd($)) return
743 const t = await get($)
744 if (!t.root) return
745 const actions = jobs.flatMap(j => j.actions)
746 const writers = jobs.filter(j => !j.readOnly)
747 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 })
748 const writes = !jobs.every(j => j.readOnly)
749 const before = dirty.root === t.root ? dirty.files : {}
750 if (jobs.some(j => j.initRepo)) await detectRepo($)
751 else if (!t.top && (await exists($, join(t.root, '.git')))) await detectRepo($)
752 const probed = await get($)
753 if (probed.top && (writes || probed.top !== t.top)) await refreshGit($)
754 if (writes) {
755 searchIndex = null
756 await staleSizes($)
757 }
758 const fresh = await get($)
759 const ignored = new Set(fresh.ignored)
760 const tones: Record<string, string> = {}
761 if (writes) {
762 const found = fresh.top
763 ? await changedInRepo($, t.root, since, before, fresh.ignored)
764 : { hits: since.os === 'win32' ? [] : await changedSince($, t.root, since, NO_REPO_DEPTH), gone: [] }
765 const hits = found.hits.filter(x => inside(t.root, x) && !underAny(x, ignored, t.root)).slice(0, FIND_LIMIT)
766 await revealPaths($, hits)
767 const loaded = await get($)
768 const dirs = [...new Set([loaded.root, ...openDirs(loaded), ...hits.map(dirname), ...found.gone.map(dirname)])].filter(d => inside(loaded.root, d))
769 const listed = await loadDirs($, dirs)
770 if (!fresh.top && since.os === 'win32')
771 for (const kids of listed.values()) for (const n of kids) if (n.mtime > since.ms && hits.length < FIND_LIMIT) hits.push(n.id)
772 await patch($, cur => {
773 const ids = new Set(cur.nodes.map(n => n.id))
774 return { expanded: cur.expanded.filter(id => ids.has(id)) }
775 })
776 const present = new Set((await get($)).nodes.map(n => n.id))
777 const touchTone = actions.find(a => !['commit', 'push', 'add'].includes(a.verb))?.tone ?? 'orange'
778 if (showWrites) for (const id of hits) if (present.has(id)) tones[id] = touchTone
779 }
780 if (showReads) {
781 const found: string[] = []
782 for (const r of reads) {
783 if (found.length >= READ_REVEAL_LIMIT) break
784 if (r !== t.root && inside(t.root, r) && !underAny(r, ignored, t.root) && (await exists($, r))) found.push(r)
785 }
786 if (found.length) {
787 await revealPaths($, found)
788 const shown = new Set((await get($)).nodes.map(n => n.id))
789 for (const r of found) if (shown.has(r) && !tones[r]) tones[r] = 'purple'
790 }
791 }
792 if (showWrites && actions.length) {
793 const final = await get($)
794 const committed = actions.some(a => a.verb === 'commit')
795 if ((committed || actions.some(a => a.verb === 'add')) && final.top) {
796 const paths = (await gitPaths($, final.root, final.prefix, committed)).filter(x => !underAny(x, ignored, final.root)).slice(0, READ_REVEAL_LIMIT)
797 await revealPaths($, paths)
798 const shown = new Set((await get($)).nodes.map(n => n.id))
799 for (const x of paths) if (shown.has(x)) tones[x] = 'green'
800 }
801 const pushLike = actions.find(a => ['push', 'pull', 'fetch', 'checkout', 'switch', 'branch', 'merge', 'rebase', 'tag'].includes(a.verb) || a.kind.startsWith('gh '))
802 if (pushLike) tones[BRANCH_ROW] = pushLike.tone
803 else if (committed) tones[BRANCH_ROW] = 'green'
804 }
805 await flash($, tones)
806}
807
808function scheduleScan($: EngineInterface, job: Job): void {
809 scanJobs.push(job)
810 if (scanning) return
811 scanning = (async () => {
812 while (scanJobs.length) {
813 const jobs = scanJobs
814 scanJobs = []
815 try {
816 await afterBash($, jobs)
817 } finally {
818 const marks = jobs.map(j => j.since.mark).filter(Boolean)
819 if (marks.length) await $.process.run(['rm', '-f', ...marks], { timeoutMs: 3_000 }).catch(() => undefined)
820 }
821 }
822 })().finally(() => {
823 scanning = null
824 })
825}
826
827async function touched($: EngineInterface, paths: string[], tone: string, show: boolean): Promise<void> {
828 if (tone !== 'purple' && previewed && paths.map(posix).includes(previewed.path)) previewed = null
829 if (await followCwd($)) return
830 const t = await get($)
831 if (!t.root) return
832 const within = paths.map(posix).filter(p => inside(t.root, p))
833 if (within.length === 0) return
834 if (tone !== 'purple') {
835 searchIndex = null
836 await staleSizes($, within)
837 await revealPaths($, within.map(dirname))
838 await loadDirs($, [...new Set(within.map(dirname))].filter(d => inside(t.root, d)))
839 await refreshGit($)
840 } else await revealPaths($, within)
841 if (!show) return
842 const tones: Record<string, string> = {}
843 for (const p of within) tones[p] = tone
844 await flash($, tones)
845}
846
847async function reveal($: EngineInterface, paths: string[]): Promise<void> {
848 if (await followCwd($)) return
849 await revealPaths($, paths)
850 await patch($, cur => {
851 const open = new Set(cur.expanded)
852 for (const p of paths) for (const a of ancestorsOf(p, cur.root)) open.add(a)
853 return { expanded: [...open], cursor: paths[paths.length - 1] ?? cur.cursor, scroll: null }
854 })
855 scrollToRow($, paths[paths.length - 1] ?? '')
856}
857
858async function walk($: EngineInterface, root: string, depth: number, limit: number): Promise<string[]> {
859 const out: string[] = []
860 let level = [root]
861 for (let d = 0; d < depth && level.length && out.length < limit; d++) {
862 const next: string[] = []
863 for (const dir of level) {
864 for (const n of (await list($, dir)) ?? []) {
865 if (n.kind === 'dir') {
866 if (!PRUNE.includes(n.name)) next.push(n.id)
867 } else out.push(n.id)
868 }
869 if (out.length >= limit) break
870 }
871 level = next
872 }
873 return out
874}
875
876async function listAll($: EngineInterface, t: FileTree): Promise<string[] | null> {
877 try {
878 if (!t.top && (await osName($)) === 'win32') return await walk($, t.root, rootOfDisk(t.root) ? 3 : 6, 20_000)
879 const run = t.top
880 ? await git($, t.root, ['ls-files', '-co', '--exclude-standard', '-z'], 10_000)
881 : await $.process.run(['find', '-H', t.root, '-xdev', '-maxdepth', rootOfDisk(t.root) ? '3' : '6', ...pruneArgs(t.root), '-type', 'f', '-print0'], { timeoutMs: 10_000 })
882 if (t.top && run.exitCode !== 0) return null
883 const paths = run.stdout.split('\0').filter(Boolean)
884 if (run.isStdoutTruncated) paths.pop()
885 return paths.map(p => (isAbsolute(p) ? p : join(t.root, p)))
886 } catch {
887 return null
888 }
889}
890
891function indexPaths($: EngineInterface, t: FileTree): Promise<string[]> {
892 if (searchIndex?.root !== t.root) {
893 const index: { root: string; paths: Promise<string[]> } = {
894 root: t.root,
895 paths: listAll($, t).then(paths => {
896 if (paths === null && searchIndex === index) searchIndex = null
897 return paths ?? []
898 }),
899 }
900 searchIndex = index
901 }
902 return searchIndex.paths
903}
904
905async function search($: EngineInterface, query: string): Promise<string[]> {
906 if (!(await get($)).query.trim()) searchIndex = null
907 await patch($, () => ({ query }))
908 const q = query.trim().toLowerCase()
909 if (!q) return []
910 const t = await get($)
911 const hits = (await indexPaths($, t)).filter(p => relative(t.root, p).toLowerCase().includes(q)).slice(0, SEARCH_REVEAL_LIMIT)
912 if ((await get($)).query !== query) return []
913 await revealPaths($, hits)
914 const shown = await get($)
915 const ids = new Set(shown.nodes.map(n => n.id))
916 const stale = [...new Set(hits.filter(p => !ids.has(p)).map(dirname))].filter(d => inside(shown.root, d))
917 if (stale.length) await loadDirs($, stale)
918 return hits
919}
920
921async function jump($: EngineInterface, query: string): Promise<void> {
922 const hits = (await search($, query)).slice(0, 10)
923 await patch($, cur => {
924 const open = new Set(cur.expanded)
925 for (const p of hits) for (const a of ancestorsOf(p, cur.root)) open.add(a)
926 return { query: '', expanded: [...open], cursor: hits[0] ?? cur.cursor, scroll: null }
927 })
928 scrollToRow($, hits[0] ?? '')
929}
930
931async function fontState($: EngineInterface, charset: string): Promise<'ok' | 'stale' | 'missing'> {
932 try {
933 const out = (await $.process.run(['sh', '-c', FONT_SCRIPT, 'sh', charset], { timeoutMs: 5_000 })).stdout.trim()
934 return out === 'stale' || out === 'missing' ? out : 'ok'
935 } catch {
936 return 'missing'
937 }
938}
939
940async function finePointerOk($: EngineInterface): Promise<boolean> {
941 if (!(await $.env.get('HERDR_ENV'))) return true
942 try {
943 const out = (await $.process.run([(await $.env.get('HERDR_BIN_PATH')) || 'herdr', '--version'], { timeoutMs: 3_000 })).stdout
944 const [major = 0, minor = 0, fix = 0] = (/(\d+)\.(\d+)\.(\d+)/.exec(out) ?? []).slice(1).map(Number)
945 return major * 1e6 + minor * 1e3 + fix >= 9_001
946 } catch {
947 return false
948 }
949}
950
951const PREVIEW_CHARS = 90_000
952const PREVIEW_LINE = 2_000
953
954function fitSerialized(lines: string[], max: number): string[] {
955 let size = 0
956 for (let i = 0; i < lines.length; i++) {
957 size += JSON.stringify(lines[i]).length
958 if (size > max) return lines.slice(0, i)
959 }
960 return lines
961}
962
963async function loadPreview($: EngineInterface, path: string): Promise<void> {
964 let text = ''
965 let note = ''
966 try {
967 const stat = await $.fs.stat(path)
968 if (stat.size > 4_000_000) note = `${formatSize(stat.size)}, too large to preview`
969 else {
970 text = await $.fs.read(path)
971 if (text.slice(0, 8000).includes('\0')) {
972 text = ''
973 note = `binary file, ${formatSize(stat.size)}`
974 }
975 }
976 } catch (err) {
977 note = `could not read: ${String((err as Error)?.message ?? err).split('\n')[0]}`
978 }
979 const total = text.length
980 let lines = text.slice(0, PREVIEW_CHARS).replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '').split('\n')
981 if (total > PREVIEW_CHARS && lines.length > 1) lines.pop()
982 lines = lines.map(l => (l.length > PREVIEW_LINE ? `${l.slice(0, PREVIEW_LINE)}…` : l))
983 const fit = fitSerialized(lines, PREVIEW_CHARS)
984 if (total > PREVIEW_CHARS || fit.length < lines.length) note = `first ${fit.length.toLocaleString('en')} lines of ${total.toLocaleString('en')} characters`
985 previewed = { path, text: fit.join('\n'), note }
986}
987
988async function showPreview($: EngineInterface, path: string): Promise<void> {
989 await loadPreview($, path)
990 await patch($, () => ({ preview: path, previewTop: 0, selected: path }))
991}
992
993async function closePreview($: EngineInterface): Promise<void> {
994 await patch($, () => ({ preview: '', previewTop: 0 }))
995}
996
997async function scrollPreview($: EngineInterface, by: number): Promise<void> {
998 const t = await get($)
999 const last = Math.max(0, (previewed?.text.split('\n').length ?? 1) - previewRows)
1000 const top = Math.max(0, Math.min(last, t.previewTop + by))
1001 if (top !== t.previewTop) await patch($, () => ({ previewTop: top }))
1002}
1003
1004function previewLines(text: string, top: number, rows: number, markdown: boolean): { text: string; top: number } {
1005 const lines = text.split('\n')
1006 const from = Math.max(0, Math.min(top, lines.length - 1))
1007 let shown = lines.slice(from, from + rows)
1008 if (markdown) {
1009 // Reopen a code fence the window starts inside, so the rest is not read as prose.
1010 let fence = ''
1011 let opener = ''
1012 for (const line of lines.slice(0, from)) {
1013 const m = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line)
1014 if (!m) continue
1015 const mark = m[1] ?? ''
1016 if (!fence) {
1017 fence = mark
1018 opener = line.trimStart()
1019 } else if (mark[0] === fence[0] && mark.length >= fence.length && !(m[2] ?? '').trim()) fence = ''
1020 }
1021 if (fence) shown = [opener, ...shown]
1022 }
1023 return { text: shown.join('\n'), top: from }
1024}
1025
1026async function openFile($: EngineInterface, path: string, external = false): Promise<void> {
1027 if (!external && openWithSetting.trim() === 'preview') return showPreview($, path)
1028 const os = await osName($)
1029 const native = (p: string) => (os === 'win32' ? p.replace(/\//g, '\\') : p)
1030 const custom = external ? null : openWithCommand(openWithSetting, native(path), native(dirname(path)))
1031 const { argv, init } = custom ? { argv: os === 'linux' ? ['sh', '-c', LINUX_LAUNCH, 'sh', ...custom] : custom, init: { timeoutMs: 10_000 } } : openCommand(os, path)
1032 try {
1033 const run = await $.process.run(argv, init)
1034 if (run.exitCode !== 0) $.ui.toast(`could not open ${path} with ${custom?.[0] ?? argv[0]}: ${run.stderr.trim().split('\n')[0] || `exit ${run.exitCode}`}`)
1035 } catch {
1036 $.ui.toast(`could not open ${path} with ${custom?.[0] ?? argv[0]}`)
1037 }
1038}
1039
1040async function toggle($: EngineInterface, n: FileNode): Promise<void> {
1041 if (n.kind === 'dir' && !(await get($)).expanded.includes(n.id)) await loadDirs($, [n.id])
1042 await patch($, t => {
1043 const open = new Set(t.expanded)
1044 if (n.kind === 'dir') open.has(n.id) ? open.delete(n.id) : open.add(n.id)
1045 return { expanded: [...open], cursor: n.id, selected: n.kind === 'dir' ? t.selected : n.id }
1046 })
1047}
1048
1049async function press($: EngineInterface, n: FileNode): Promise<void> {
1050 const now = await $.clock.now()
1051 const isDouble = lastPress.key === n.id && now - lastPress.at < DOUBLE_MS
1052 lastPress = { key: isDouble ? '' : n.id, at: now }
1053 if (!isDouble) {
1054 await toggle($, n)
1055 return
1056 }
1057 await openNode($, n)
1058}
1059
1060async function openNode($: EngineInterface, n: FileNode): Promise<void> {
1061 if (n.kind === 'dir') {
1062 follow = false
1063 await reset($, n.id)
1064 } else await openFile($, n.id)
1065}
1066
1067function shortPath(path: string): string {
1068 return home && inside(home, path) ? `~${path.slice(home.length)}` : path
1069}
1070
1071/**
1072 * The tree's first look in a session: whether the pointer is fine, whether a Nerd
1073 * Font is there, and what the last session left (a flash, a preview, git steps
1074 * still running) cleared.
1075 */
1076async function firstLook($: EngineInterface): Promise<void> {
1077 pointer = await finePointerOk($)
1078 try {
1079 const remote = Boolean((await $.env.get('SSH_CONNECTION')) || (await $.env.get('SSH_TTY')))
1080 noNerd = !remote && ((await $.env.get('OS')) === 'Windows_NT' || (await fontState($, 'f04eb')) !== 'ok')
1081 } catch {
1082 noNerd = true
1083 }
1084 const t = await get($)
1085 if (t.flashOn) await patch($, () => ({ flash: [], flashDim: [], flashOn: false, flashTones: {} }))
1086 if (t.preview) await patch($, () => ({ preview: '', previewTop: 0 }))
1087 await setActivities($, cur => cur.map(a => (a.state === 'running' ? { ...a, state: 'failed', label: `${a.kind} interrupted` } : a)))
1088}
1089
1090/** The pane of a mod that draws the tree in its own pane (a tab of it) rather than filetree's; null in filetree's own. */
1091let host: string | null = null
1092/** Whether a host's tree is on screen: set as it draws, cleared by `hideTree`. */
1093let onScreen = false
1094/** Whether a host's tree has caught up since it came on screen. */
1095let caughtUp = false
1096/** Whether a host's tree has made the session's reads and its first look. */
1097let looked = false
1098/** A host's pinned folder (`pinTree`); null, the tree follows the cwd. */
1099let pinned: string | null = null
1100
1101/** The reads every session needs before the tree reads anything: the OS, home, the last activity's id. */
1102async function sessionReads($: EngineInterface): Promise<void> {
1103 useDrives((await $.env.get('OS')) === 'Windows_NT')
1104 home = posix(((await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || '').replace(/[\\/]+$/, ''))
1105 activityId = Math.max(activityId, ...(await activities($)).map(a => a.id))
1106}
1107
1108/**
1109 * A host's tree just drew, so it's on screen. The first draw since it came on
1110 * screen catches up, on a timer since a drawing writes nothing: the session's
1111 * reads and the first look the first time, then the folder listed (the cwd, or
1112 * the pinned one) when the tree isn't on it, or what changed while it was off.
1113 */
1114function drawnInHost($: EngineInterface): void {
1115 onScreen = true
1116 if (caughtUp) return
1117 caughtUp = true
1118 $.clock.after(0, () => void catchUp($).catch(() => undefined))
1119}
1120
1121async function catchUp($: EngineInterface): Promise<void> {
1122 if (!looked) {
1123 looked = true
1124 await sessionReads($)
1125 await firstLook($)
1126 }
1127 const cwd = await cwdOf($)
1128 follow = pinned === null
1129 const want = pinned === null ? cwd : resolve(cwd, pinned, home)
1130 const t = await get($)
1131 if (t.root !== want || t.nodes.length === 0) await reset($, want)
1132 else await sync($)
1133}
1134
1135/** For a host: the tree went off screen (another tab, the pane hidden); its hooks pass through until it draws again. */
1136export function hideTree(): void {
1137 onScreen = false
1138 caughtUp = false
1139}
1140
1141/** For a host: the folder the tree shows from its next draw (`root`, pinned), or the cwd, followed (null). */
1142export function pinTree(root: string | null): void {
1143 pinned = root
1144 caughtUp = false
1145}
1146
1147/** In a host's pane while the tree is off screen: every hook then passes straight through. */
1148function hidden(): boolean {
1149 return host !== null && !onScreen
1150}
1151
1152/** The tree's watch on Claude's edits, writes and Bash commands (git steps, files changed, reads). */
1153const watchTool: Hook<'tool.call'> = async ($, e, next) => {
1154 {
1155 if (hidden()) return next(e)
1156 if (e.tool !== 'Edit' && e.tool !== 'Write' && e.tool !== 'NotebookEdit' && e.tool !== 'Bash') return next(e)
1157 const command = e.tool === 'Bash' ? e.command : ''
1158 const cwd = e.tool === 'Bash' ? await cwdOf($) : ''
1159 const actions = gitActions(command)
1160 const quiet = !actions.length && readOnly(command)
1161 const since = await sinceNow($, e.tool === 'Bash' && !quiet)
1162 const before = actions.some(a => a.verb === 'commit') ? await get($) : null
1163 const head = before?.top ? await headOf($, before.root) : ''
1164 const pending: Pending | null = actions.length ? { actions, ids: await startGit($, actions), command, head } : null
1165 let result: Awaited<ReturnType<typeof next>>
1166 try {
1167 result = await next(e)
1168 } catch (err) {
1169 if (pending) await finishGit($, pending, failAll(pending))
1170 if (since.mark) void $.process.run(['rm', '-f', since.mark], { timeoutMs: 3_000 }).catch(() => undefined)
1171 throw err
1172 }
1173 if (pending && result.deny) {
1174 const ids = pending.ids
1175 void setActivities($, cur => cur.filter(a => !ids.includes(a.id)))
1176 }
1177 if (result.deny || (result.isError && e.tool !== 'Bash')) {
1178 if (since.mark) void $.process.run(['rm', '-f', since.mark], { timeoutMs: 3_000 }).catch(() => undefined)
1179 return result
1180 }
1181 if (e.tool === 'Bash') {
1182 const out: Partial<BuiltinToolResults['Bash']> = !result.isError && result.result && typeof result.result === 'object' ? result.result : {}
1183 const reads = showReads ? readTargets(command, cwd, typeof out.stdout === 'string' ? out.stdout : '', home) : []
1184 const job: Job = { actions: [], since, initRepo: actions.some(a => a.init), readOnly: quiet || result.isReadOnly === true }
1185 if (out.backgroundTaskId) background.set(out.backgroundTaskId, { p: pending, job, reads })
1186 else void (async () => {
1187 const results = pending ? await outcomes($, pending, !result.isError, out.gitOperation) : []
1188 if (pending) await finishGit($, pending, results)
1189 queuedReads.push(...reads)
1190 scheduleScan($, { ...job, actions: proven(pending, results) })
1191 })()
1192 if (follow && /(^|[;&|\s])(cd|pushd|popd)(\s|$)/.test(command)) $.clock.after(400, () => void followCwd($))
1193 } else {
1194 const file =
1195 'file_path' in e && typeof e.file_path === 'string'
1196 ? e.file_path
1197 : 'notebook_path' in e && typeof e.notebook_path === 'string'
1198 ? e.notebook_path
1199 : ''
1200 if (file) void touched($, [file], 'orange', showWrites)hooks/open.ts 68 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
25const WIN_OPEN = "$ErrorActionPreference = 'Stop'; [Diagnostics.Process]::Start([Diagnostics.ProcessStartInfo]@{ FileName = $env:PANE_OPEN_TARGET; UseShellExecute = $true }) | Out-Null"
26
27export function openCommand(os: 'linux' | 'darwin' | 'win32', target: string) {
28 const argv = os === 'linux'
29 ? ['sh', '-c', LINUX_LAUNCH, 'sh', 'sh', '-c', LINUX_OPEN, 'sh', target]
30 : os === 'darwin'
31 ? ['open', '--', target]
32 : ['powershell', '-NoProfile', '-NonInteractive', '-Command', WIN_OPEN]
33 return {
34 argv,
35 init: {
36 timeoutMs: 10_000,
37 ...(os === 'win32' ? { env: { PANE_OPEN_TARGET: /^[a-z][a-z0-9+.-]*:\/\//i.test(target) ? target : target.replace(/\//g, '\\') } } : {}),
38 },
39 }
40}
41
42function words(command: string): string[] {
43 const out: string[] = []
44 let word: string | null = null
45 let quote = ''
46 for (const ch of command) {
47 if (quote) {
48 if (ch === quote) quote = ''
49 else word = (word ?? '') + ch
50 } else if (ch === '"' || ch === "'") {
51 quote = ch
52 word ??= ''
53 } else if (/\s/.test(ch)) {
54 if (word !== null) out.push(word)
55 word = null
56 } else word = (word ?? '') + ch
57 }
58 if (word !== null) out.push(word)
59 return out
60}
61
62export function openWithCommand(command: string, path: string, dir: string): string[] | null {
63 const argv = words(command)
64 if (!argv.length) return null
65 const filled = argv.map(w => w.replace(/\{(path|dir)\}/g, (_, key) => (key === 'path' ? path : dir)))
66 return argv.some(w => w.includes('{path}')) ? filled : [...filled, path]
67}
68kit/format.ts 95 lines1const trim = (n: number) => (n >= 100 ? String(Math.round(n)) : n.toFixed(1).replace(/\.0$/, ''))
2
3/** Token counts: 950, 1.2k, 84k, 1M. */
4export const tokens = (n: number): string => {
5 if (n >= 999_500) return `${trim(n / 1_000_000)}M`
6 if (n >= 1_000) return `${trim(n / 1_000)}k`
7 return String(Math.round(n))
8}
9
10/** A duration to the second: 12s, 3m 05s, 2h 05m. */
11export const duration = (ms: number): string => {
12 const s = Math.max(0, Math.floor(ms / 1_000))
13 if (s < 60) return `${s}s`
14 const m = Math.floor(s / 60)
15 if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`
16 return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
17}
18
19/** A duration at a glance: 47m, 2h05, 3d4h. */
20export const short = (ms: number): string => {
21 const m = Math.max(0, Math.floor(ms / 60_000))
22 if (m < 60) return `${m}m`
23 const h = Math.floor(m / 60)
24 if (h < 24) return `${h}h${String(m % 60).padStart(2, '0')}`
25 return `${Math.floor(h / 24)}d${h % 24}h`
26}
27
28/** How long ago, coarsely, as GitHub lists things: 5m, 3h, 2d, 3w, 4mo, 2y. */
29export const ago = (ms: number): string => {
30 const m = Math.max(0, Math.floor(ms / 60_000))
31 if (m < 60) return `${m}m`
32 const h = Math.floor(m / 60)
33 if (h < 24) return `${h}h`
34 const d = Math.floor(h / 24)
35 if (d < 14) return `${d}d`
36 if (d < 60) return `${Math.floor(d / 7)}w`
37 if (d < 365) return `${Math.floor(d / 30)}mo`
38 return `${Math.floor(d / 365)}y`
39}
40
41/** US dollars: $1.50, $123. */
42/** Bytes, short: 812, 41K, 3.2M, 16G. */
43export const bytes = (n: number): string => {
44 const units = ['', 'k', 'M', 'G', 'T']
45 let i = 0
46 while (n >= 1024 && i < units.length - 1) {
47 n /= 1024
48 i++
49 }
50 return `${n >= 10 || i === 0 ? Math.round(n) : n.toFixed(1)}${units[i]}`
51}
52
53export const money =(usd: number) => (usd >= 100 ? `$${Math.round(usd)}` : `$${usd.toFixed(2)}`)
54
55/** `value` as a whole percent of `max`, clamped: the desktop takes whole percentages only. */
56export const pct = (value: number, max: number) => `${Math.round(Math.max(0, Math.min(100, (value / Math.max(max, 1)) * 100)))}%`
57
58/**
59 * `text` cut in the middle to at most `cells` characters, the end kept: a path
60 * keeps its first segment and as many whole segments from its end as fit
61 * (`src/…/hooks/view.tsx`), then `…/view.tsx`, then the end of its name; other
62 * text keeps its start and end around `…`. Text that fits comes back as is.
63 */
64export const middle = (text: string, cells: number): string => {
65 const chars = [...text]
66 const n = Math.max(0, Math.floor(cells))
67 if (chars.length <= n) return text
68 if (n <= 1) return n === 1 ? '…' : ''
69 const parts = text.split('/')
70 if (parts.length >= 2) {
71 const len = (s: string) => [...s].length
72 const head = parts[0]!
73 const tail: string[] = [parts[parts.length - 1]!]
74 // As many whole segments from the end as fit after `head/…/`.
75 for (let i = parts.length - 2; i > 0; i--) {
76 const next = [parts[i]!, ...tail].join('/')
77 if (len(`${head}/…/${next}`) > n) break
78 tail.unshift(parts[i]!)
79 }
80 const kept = tail.join('/')
81 if (len(`${head}/…/${kept}`) <= n) return `${head}/…/${kept}`
82 if (len(`…/${kept}`) <= n) return `…/${kept}`
83 // Not even the name fits: the end of the name.
84 const name = [...kept]
85 return `…${name.slice(name.length - (n - 1)).join('')}`
86 }
87 // Plain text: a little more of the start than the end.
88 const front = Math.ceil((n - 1) / 2)
89 const back = n - 1 - front
90 return `${chars.slice(0, front).join('')}…${back ? chars.slice(chars.length - back).join('') : ''}`
91}
92
93/** The last path segment. */
94export const basename = (path: string) => path.split(/[\\/]/).filter(Boolean).pop() ?? path
95kit/tokens.ts 318 lines1import type { Skin } from './skin'
2import { DEFAULT_SKIN, SKINS } from './skins'
3import type { SkinId } from './skins'
4
5/**
6 * The instrument's colours. Each hue means one thing, everywhere:
7 *
8 * - grey (MUTED): labels, icons, git marks, ordinary values
9 * - slate (INK): "how much" — bars and gauges that only measure
10 * - blue: live or in progress — running, pending, the activity series
11 * - amber: getting close to a limit — 75% and up, the compaction mark
12 * - red: failed, conflicted, 90% and up
13 * - green: finished well — checks passed, an agent done
14 * - purple: merged
15 *
16 * Cyan and orchid aren't statuses: they only tint parts within one chart.
17 *
18 * The values come from the skin the kit wears (kit/skin.ts): a zen theme pack's
19 * palette, mapped to what the app surfaces draw. The kit starts in the
20 * `claude-code` skin; `wear()` puts on another. Read them when drawing, never
21 * into a module-level constant, so a skin worn later reaches every drawing.
22 */
23export const C = {
24 ok: '',
25 warn: '',
26 bad: '',
27 blue: '',
28 purple: '',
29 cyan: '',
30 slate: '',
31 orchid: '',
32}
33
34/** Quantity ink: what a bar or gauge is drawn in when it only says how much. */
35export let INK = ''
36
37/** Shades within one chart: parts of one thing, told apart without new meanings. */
38export const RAMP = {
39 blue: ['', '', '', ''] as [string, string, string, string],
40 slate: ['', '', '', ''] as [string, string, string, string],
41}
42
43/** A bar's empty part and other quiet fills: a grey that reads on light and dark. */
44export const TRACK = 'rgba(128,128,128,0.18)'
45
46/**
47 * `color` (#rgb or #rrggbb) at `alpha` (0 to 1), as rgba(): a tint that lets the
48 * pane show through, so it reads on the light desktop theme and the dark one.
49 * Undefined for anything that isn't a hex colour.
50 */
51export const tint = (color: string, alpha: number): string | undefined => {
52 const hex = color.replace(/^#/, '')
53 const full = hex.length === 3 ? [...hex].map(x => x + x).join('') : hex
54 if (!/^[0-9a-f]{6}$/i.test(full)) return undefined
55 const [r, g, b] = [0, 2, 4].map(i => parseInt(full.slice(i, i + 2), 16))
56 return `rgba(${r},${g},${b},${alpha})`
57}
58
59/**
60 * The app surfaces' fills, behind text. The engine gives render hooks no desktop
61 * theme to read and no theme key for a fill, so each is a see-through tint over
62 * the pane: a neutral grey darkens the light pane and lightens the dark one by
63 * about the same step, and the text over it keeps the theme's own foreground.
64 * Never on the terminal, which draws no fills.
65 *
66 * - head: a section header's band, the step that marks where a section starts;
67 * - tile: one item of a list of several-line items, a step quieter than a band;
68 * - selected: the picked tile or row, tinted towards the blue that means live;
69 * - selectedBlurred: the same while the pane doesn't have the keyboard, half as strong;
70 * - pill: how strongly a label's own colour fills its chip.
71 */
72export const FILL = {
73 head: 'rgba(128,128,128,0.09)',
74 tile: 'rgba(128,128,128,0.05)',
75 selected: '',
76 selectedBlurred: '',
77 pill: 0.22,
78}
79
80/** Text, icons and marks that should recede. */
81export let MUTED = ''
82
83/**
84 * Three greys for text below the main text, each a step quieter, so a row can say
85 * what matters most without colour (tuned for the desktop's dark pane; mid and
86 * dark also read on the light one):
87 * - light: secondary text a reader still reads (a linked PR's title, a byline);
88 * - mid: metadata scanned past (ages, counts, secondary actions);
89 * - dark: the quietest marks (an icon beside a count, a heading over a list).
90 */
91export const GRAY = { light: '', mid: '', dark: '' }
92
93/** A colour with one value for the light desktop theme and one for the dark. */
94export type Duo = { readonly light: string; readonly dark: string }
95
96/**
97 * File-type icon colours. One meaning: this file's type. Only a file tree's icons
98 * take them, never text, a header or a control (those stay grey), and only on the
99 * app surfaces: the terminal draws file icons monochrome (dim), as a file type is
100 * no state a theme key means.
101 *
102 * The values are IntelliJ's New UI ("expui") icon colours, each from the fill or
103 * stroke of the icon named beside it: intellij-community
104 * `platform/icons/src/expui/fileTypes/<name>.svg` (light) and `<name>_dark.svg`
105 * (dark), and `platform/icons/src/expui/nodes/` for the folders. Apache 2.0,
106 * © JetBrains s.r.o. and contributors (each SVG's header).
107 *
108 * An icon drawn in a Duo carries both values and picks one by the page's colour
109 * scheme (`iconSvg` in kit/icons.ts); where that can't be read it takes the dark.
110 */
111export const FILE_TYPE = {
112 /** typeScript.svg */
113 typescript: { light: '#3574F0', dark: '#548AF7' },
114 /** javaScript.svg */
115 javascript: { light: '#FFAF0F', dark: '#F2C55C' },
116 /** json.svg */
117 json: { light: '#834DF0', dark: '#B589EC' },
118 /** markdown.svg */
119 markdown: { light: '#3574F0', dark: '#548AF7' },
120 /** config.svg (its accent), toml.svg, docker.svg */
121 config: { light: '#3574F0', dark: '#548AF7' },
122 /** yaml.svg */
123 yaml: { light: '#DB3B4B', dark: '#DB5C5C' },
124 /** css.svg */
125 style: { light: '#4682FA', dark: '#548AF7' },
126 /** html.svg */
127 html: { light: '#369650', dark: '#57965C' },
128 /** xml.svg */
129 xml: { light: '#E66D17', dark: '#C77D55' },
130 /** csv.svg */
131 data: { light: '#208A3C', dark: '#57965C' },
132 /** sql.svg */
133 sql: { light: '#834DF0', dark: '#B589EC' },
134 /** image.svg */
135 image: { light: '#3574F0', dark: '#548AF7' },
136 /** archive.svg */
137 archive: { light: '#3574F0', dark: '#548AF7' },
138 /** text.svg, shell.svg, unknown.svg, font.svg: any other file, lockfiles included */
139 text: { light: '#6C707E', dark: '#CED0D6' },
140 /** nodes/folder.svg, nodes/folderGithub.svg */
141 folder: { light: '#6C707E', dark: '#CED0D6' },
142 /** nodes/excludeRoot.svg: a folder git ignores (node_modules, build output) */
143 excluded: { light: '#F28C35', dark: '#C77D55' },
144} as const satisfies Record<string, Duo>
145
146export type FileType = keyof typeof FILE_TYPE
147
148/** Slate under 75%, amber from 75%, red from 90%: colour only once it matters. */
149export const levelColor = (percent: number): string => (percent >= 90 ? C.bad : percent >= 75 ? C.warn : INK)
150
151/*
152 * The terminal's colours. The terminal draws each status in the engine theme key
153 * that means the same thing, so it follows the person's terminal theme (dark,
154 * light, colour-blind, ANSI-only) instead of a fixed hex that may not read on it:
155 *
156 * - green C.ok → 'success'
157 * - red C.bad → 'error'
158 * - amber C.warn → 'warning'
159 * - blue C.blue → 'suggestion'
160 * - purple C.purple → 'merged'
161 * - the greys (MUTED, GRAY, TRACK) → the terminal's own dim, never a grey hex
162 * - quantity (INK, slate) → the terminal's default foreground
163 * - the shades within one chart (RAMP, cyan, orchid) → plain and dim in turn
164 * (TERM_TINT), the blue ramp as `suggestion` and dimmed `suggestion`
165 * - any other hex → the default foreground: no fixed hex reaches the terminal,
166 * where it can vanish on a light, an ANSI-only or a colour-blind theme.
167 */
168const themeKey: Record<string, string> = {}
169export const THEME_KEY: Readonly<Record<string, string>> = themeKey
170
171const QUIET = new Set<string>()
172
173/** Text props on the terminal: a theme key, the terminal's dim, or neither (the default foreground). */
174export type TermInk = { color?: string; dimColor?: boolean }
175
176/**
177 * The chart shades on the terminal, which has no colours to spare for them: the
178 * slate ramp (quantity) is the default foreground and dim in turn, the blue ramp
179 * `suggestion` and dimmed `suggestion` in turn, cyan plain and orchid dim. A
180 * bar's parts are all runs of the same heavy line (METER), told apart by these
181 * inks alone: no shade or dither glyphs, which render as a checkerboard.
182 */
183const termTint: Record<string, TermInk> = {}
184export const TERM_TINT: Readonly<Record<string, TermInk>> = termTint
185
186/** Whether `color` is one of the kit's tokens (a status, a grey, a chart shade), not data such as a GitHub label's hex. */
187export const isKitColor = (color: string): boolean => QUIET.has(color) || color in THEME_KEY || color in TERM_TINT
188
189/** A raw colour (hex, rgb(), hsl()) rather than a theme key's name. */
190const RAW = /^(#|rgba?\(|hsla?\()/i
191
192/**
193 * A colour's Text props on the terminal: a status as its theme key, a grey as
194 * `dimColor`, a chart shade as TERM_TINT says, a theme key's name as itself, and
195 * any other raw colour as nothing (the default foreground). Nothing for no colour.
196 */
197export const termInk = (color?: string): TermInk => {
198 if (!color) return {}
199 if (QUIET.has(color)) return { dimColor: true }
200 const key = THEME_KEY[color]
201 if (key) return { color: key }
202 const tint = TERM_TINT[color]
203 if (tint) return { ...tint }
204 return RAW.test(color) ? {} : { color }
205}
206
207/**
208 * One glyph per idea, shared so mods read alike (the SVG icons are keyed by these).
209 * Each is what the terminal draws: one cell, and never a character with an emoji
210 * presentation (Unicode's Emoji property: ⚒ ⚠ ⚡ ⏱ ↕ ◼ draw as emoji in some
211 * terminals and take two cells).
212 */
213export const GLYPH = {
214 context: '◕',
215 cost: '$',
216 time: '◷',
217 turns: '↻',
218 // The place-of-interest sign, as on a Command key: what the tools are called with.
219 tools: '⌘',
220 files: '✎',
221 agents: '✦',
222 limits: '◔',
223 layers: '▤',
224 branch: '⎇',
225 pr: '⇡',
226 running: '◐',
227 done: '✓',
228 failed: '✕',
229 stopped: '■',
230 pending: '◌',
231 up: '↑',
232 down: '↓',
233} as const
234
235/**
236 * A status's mark: one glyph and the colour that means it, the same on every
237 * surface. Draw it with `k.status(kind)`, never by hand, so a running task, a
238 * running server and a pending check all read alike.
239 */
240export const STATUS = {
241 running: { glyph: GLYPH.running, color: '' },
242 pending: { glyph: GLYPH.pending, color: '' },
243 done: { glyph: GLYPH.done, color: '' },
244 failed: { glyph: GLYPH.failed, color: '' },
245 warn: { glyph: '▲', color: '' },
246 merged: { glyph: '◆', color: '' },
247 stopped: { glyph: GLYPH.stopped, color: '' },
248 idle: { glyph: '○', color: '' },
249}
250
251export type StatusKind = keyof typeof STATUS
252
253/** The skin the kit wears now: a key of SKINS, or `custom` for a skin passed in whole. */
254export let SKIN: SkinId | 'custom' = DEFAULT_SKIN
255
256/**
257 * Puts on a skin: a zen theme pack's (a key of SKINS, kit/skins.ts) or one made
258 * in full. Every colour above takes the skin's value, and the terminal's mapping
259 * follows, so each meaning keeps its theme key. Wear one before the first
260 * drawing (at the top of the hooks module); the kit starts in DEFAULT_SKIN.
261 */
262export const wear = (skin: SkinId | Skin): void => {
263 const s = typeof skin === 'string' ? SKINS[skin] : skin
264 if (!s) throw new Error(`no skin '${String(skin)}'`)
265 SKIN = typeof skin === 'string' ? skin : 'custom'
266 Object.assign(C, { ok: s.ok, warn: s.warn, bad: s.bad, blue: s.blue, purple: s.purple, cyan: s.cyan, slate: s.slate, orchid: s.orchid })
267 INK = s.slate
268 RAMP.blue.splice(0, 4, ...s.ramp.blue)
269 RAMP.slate.splice(0, 4, ...s.ramp.slate)
270 FILL.selected = tint(s.blue, 0.13)!
271 FILL.selectedBlurred = tint(s.blue, 0.065)!
272 MUTED = s.muted
273 Object.assign(GRAY, s.gray)
274 const status: Record<StatusKind, string> = {
275 running: s.blue,
276 pending: s.blue,
277 done: s.ok,
278 failed: s.bad,
279 warn: s.warn,
280 merged: s.purple,
281 stopped: s.muted,
282 idle: s.muted,
283 }
284 for (const kind of Object.keys(status) as StatusKind[]) STATUS[kind].color = status[kind]
285 for (const key of Object.keys(themeKey)) delete themeKey[key]
286 Object.assign(themeKey, { [s.ok]: 'success', [s.bad]: 'error', [s.warn]: 'warning', [s.blue]: 'suggestion', [s.purple]: 'merged' })
287 QUIET.clear()
288 for (const grey of [s.muted, s.gray.light, s.gray.mid, s.gray.dark, TRACK]) QUIET.add(grey)
289 for (const key of Object.keys(termTint)) delete termTint[key]
290 Object.assign(termTint, {
291 [s.ramp.slate[0]]: {},
292 [s.ramp.slate[1]]: { dimColor: true },
293 [s.ramp.slate[2]]: {},
294 [s.ramp.slate[3]]: { dimColor: true },
295 [s.ramp.blue[1]]: { color: 'suggestion', dimColor: true },
296 [s.ramp.blue[2]]: { color: 'suggestion' },
297 [s.ramp.blue[3]]: { color: 'suggestion', dimColor: true },
298 [s.cyan]: {},
299 [s.orchid]: { dimColor: true },
300 })
301}
302
303wear(DEFAULT_SKIN)
304
305/**
306 * Tree connectors for nested rows (agents under the agent that spawned them,
307 * files under folders), each two cells wide so levels indent on the grid.
308 */
309export const TREE = { tee: '├─', last: '└─', pipe: '│ ', blank: ' ' } as const
310
311/**
312 * A nested row's connector: for each level above it, a pipe where that level has
313 * more rows below, else blank; then a tee, or an elbow for the last child.
314 * Empty at the top level.
315 */
316export const treePrefix = (guides: boolean[], last: boolean, depth = guides.length + 1): string =>
317 depth === 0 ? '' : `${guides.map(g => (g ? TREE.pipe : TREE.blank)).join('')}${last ? TREE.last : TREE.tee}`
318kit/ui.tsx 2021 lines1import type { RenderElement, RenderSurface } from 'claude-code'
2
3import { middle, pct, short } from './format'
4import { ICON_PX, glyphOf, iconSvg } from './icons'
5import { ms } from './perf'
6import type { PerfRow, PerfSnapshot } from './perf'
7import { C, FILL, GLYPH, GRAY, INK, MUTED, STATUS, TRACK, isKitColor, termInk, tint, treePrefix } from './tokens'
8import type { Duo, StatusKind } from './tokens'
9
10/**
11 * The shared components. Each draws for the surface it is given: on the app
12 * surfaces (desktop, VS Code, mobile) as boxes laid out by flexbox, crisp at
13 * any width; in the terminal as cells, `columns` wide.
14 *
15 * What the desktop accepts (2.1.293): whole percentages only, `flexGrow` up to
16 * 10000, fractional heights and margins; no `hover`. Borders `round` (rounded
17 * corners), single, double, bold and classic draw; `dashed` doesn't. SVG is
18 * drawn as an image: crisp only at its exact pixel size, so it's kept to small
19 * fixed pieces like bar ends.
20 *
21 * Units on the desktop (measured on 2.1.295): a column (widths, paddingX, marginX,
22 * gaps between columns) is ~7.8px; a row of size (`height`, `minHeight`) is ~19px,
23 * one text line; but vertical spacing (marginTop/Bottom/Y, paddingTop/Bottom/Y,
24 * rowGap) is half that, ~9.5px a unit. A `transparent` border colour is drawn in
25 * the text's colour, so the kit draws no border to round a fill.
26 */
27
28/** The element constructors the kit draws with. `Button` is needed only for collapsible sections. */
29export type Els = {
30 Box: (props: any, ...children: any[]) => RenderElement
31 Text: (props: any, ...children: any[]) => RenderElement
32 Button?: (props: any, ...children: any[]) => RenderElement
33 /** Rounds the ends of bars where the surface has it. */
34 Svg?: (props: any, ...children: any[]) => RenderElement
35}
36
37/**
38 * The Buttons one pane drew last, per scope: each by its key, with the props and
39 * children it was drawn with, the element built for them, and the latest press.
40 */
41type HeldButton = { look: string; made: unknown; element: RenderElement; frame: number; press: (e: any) => void }
42const heldButtons = new Map<string, { frame: number; buttons: Map<string, HeldButton> }>()
43
44/**
45 * The element table `els` with a `Button` that keeps each button's press handle
46 * from one drawing to the next, for the drawing `scope` names (the render event
47 * itself: its component, requestId and surface). Call it once per render, as
48 * the drawing starts (after the render's awaits), and draw every Button of that
49 * render with the table it returns (the kit's own included: pass it to `kit`).
50 *
51 * Why: the engine's JSX gives every Button it builds a new handle, and frees the
52 * old one as soon as a drawing leaves it out; a press reaches its `onPress` by
53 * that handle alone. So a redraw between drawing and click (the click focusing
54 * the pane, a timer's write) left the press on a freed handle, and it did
55 * nothing. Here a Button whose key, props and children match the last render's
56 * is that same element, its handle unchanged, its press running the latest
57 * `onPress`. One that changed is built anew (a stale press on it misses, as it
58 * should); one absent from the last render is forgotten.
59 */
60export const holdButtons = <E extends { Button?: unknown }>(els: E, scope: string | { component: string; requestId: string; surface: string }): E => {
61 const Made = els.Button as ((props: any, ...children: any[]) => RenderElement) | undefined
62 if (!Made) return els
63 const id = typeof scope === 'string' ? scope : `${scope.component}\u0000${scope.requestId}\u0000${scope.surface}`
64 const pane = heldButtons.get(id) ?? { frame: 0, buttons: new Map<string, HeldButton>() }
65 heldButtons.set(id, pane)
66 // A new render: keep only what the last one drew.
67 for (const [key, b] of pane.buttons) if (b.frame < pane.frame) pane.buttons.delete(key)
68 pane.frame += 1
69 const frame = pane.frame
70 const Button = (props: any, ...rest: any[]): RenderElement => {
71 const { children: given, onPress, ...plain } = props ?? {}
72 const children: unknown[] = rest.length ? rest : given === undefined ? [] : Array.isArray(given) ? given : [given]
73 const key = plain.key ?? plain.label ?? (children.length === 1 && typeof children[0] === 'string' ? children[0] : undefined)
74 if (typeof key !== 'string' || typeof onPress !== 'function') {
75 return (
76 <Made {...plain} onPress={onPress}>
77 {children}
78 </Made>
79 )
80 }
81 const look = JSON.stringify([plain, children])
82 const was = pane.buttons.get(key)
83 // A second Button on one key in one render is built apart: one element can't hold two presses.
84 if (was?.frame === frame) {
85 return (
86 <Made {...plain} onPress={onPress}>
87 {children}
88 </Made>
89 )
90 }
91 // The same look from the same constructor (another plugin may restyle the table's).
92 if (was && was.look === look && was.made === Made) {
93 was.frame = frame
94 was.press = onPress
95 return was.element
96 }
97 const b: HeldButton = { look, made: Made, frame, press: onPress, element: null as unknown as RenderElement }
98 b.element = (
99 <Made {...plain} onPress={(e: any) => b.press(e)}>
100 {children}
101 </Made>
102 )
103 pane.buttons.set(key, b)
104 return b.element
105 }
106 // Everything else read through to the table as it was (whatever kind of object it is).
107 // Defined, not assigned: the engine's table is frozen, and its read-only Button would refuse an assignment.
108 return Object.create(els, { Button: { value: Button, enumerable: true } }) as E
109}
110
111/**
112 * A bar part: its value, its color, and the label its legend shows. `room: true`
113 * marks room rather than use (free space, a reserve): the terminal draws it as
114 * the bar's light rail in its colour, and a `bar: true` legend keys it with `─`.
115 */
116export type Part = { value: number; color: string; label?: string; room?: boolean }
117
118const BAR = 0.45
119
120/** One row's height in CSS pixels on the desktop, measured: a 0.45-row bar is 8.5px tall. */
121const ROW_PX = 8.5 / 0.45
122/** One column's width in CSS pixels on the desktop (as the playground calibrates it): what an SVG sized in cells is drawn at. */
123const COL_PX = 7.8
124
125/*
126 * The grid. Mods use these, never raw spacing numbers:
127 *
128 * - SPACE.pair between the two halves of a pair (glyph and figure, dot and label),
129 * SPACE.item between items in a row, SPACE.group between groups of items;
130 * SPACE.block between blocks inside a section body (rows in a list sit at 0).
131 * - INSET: a band's (header, footer) padding on both sides, and a body's on the right,
132 * so header figures, body figures and the footer end on one line.
133 * - BODY_INDENT: a body's left edge, just past the header's fold arrow.
134 * - Reading columns: LABEL | gauge (grows) | FIG right-aligned | FIG2 right-aligned.
135 */
136export const SPACE = { pair: 0.75, item: 1.25, group: 2, block: 0.8 } as const
137const INSET = 1
138const BODY_INDENT = 1.6
139/**
140 * The content edges: everything that isn't a band (the header totals, section bodies)
141 * starts at `left` and ends at `right`. Alignment rules, for every row:
142 * - labels, names and titles start on the left edge;
143 * - the row's last figure or action ends on the right edge (rows are space-between);
144 * - figures are right-aligned; a row's right side is one cluster, never two.
145 */
146export const EDGE = { left: BODY_INDENT, right: INSET } as const
147const LABEL = 10
148const FIG = 5
149const FIG2 = 6
150/** The terminal's fold-arrow gutter. */
151const GUTTER = 2
152
153/*
154 * The terminal's grid. A terminal lays out in whole cells, so it has its own
155 * spacing (a fraction would round differently row to row) and draws to an exact
156 * width in cells. `k.space` and `k.edge` give the grid for the kit's surface.
157 *
158 * - CELLS: pair 1 (glyph and figure), item 1 (columns of a reading), group 2
159 * (figures in a summary, actions in a row), block 1 (a blank row).
160 * - Indent: a section body starts GUTTER (2) in, under the header's icon column;
161 * each tree level indents 2 more.
162 * - Reading columns: LABEL (10) | gauge (the rest) | FIG (5) | FIG2 (6), one cell
163 * apart; under MIN_GAUGE cells of gauge the label narrows, then the gauge goes.
164 */
165export const CELLS = { pair: 1, item: 1, group: 2, block: 1 } as const
166/** The fewest cells a terminal gauge is drawn in; narrower, the reading drops it and keeps its figures. */
167const MIN_GAUGE = 6
168/** The narrowest a terminal reading's label gets before its gauge is dropped. */
169const MIN_LABEL = 6
170/** The cells of its text a terminal list item keeps (with its ellipsis) before its right cluster is cut. */
171const ITEM_KEEPS = 6
172/**
173 * The narrowest pane the terminal draws section bodies in. Narrower, each section
174 * draws its header and figures only (a floor, as bottom's basic mode), rather
175 * than every reading collapsing to an ellipsis.
176 */
177export const MIN_COLUMNS = 30
178
179/**
180 * The terminal's meter glyphs. A gauge and a bar are both a heavy line on a light
181 * one, to half a cell (╸): a bar's parts are runs of the heavy line, each in its
182 * own solid theme colour, and its room (free space, a reserve) the light rail in
183 * its colour. No shades or dithers: they render as a checkerboard in real fonts.
184 */
185export const METER = {
186 line: '━',
187 half: '╸',
188 rail: '─',
189 /** Lower eighths, 0 to 8: for columns that rise. */
190 rise: [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'],
191} as const
192
193/**
194 * The widest a dashboard's sections are drawn on the terminal, in cells, when a
195 * mod passes `measure: MEASURE` to `kit()`: past it, more width only spreads
196 * readings apart and sends the eye across the pane.
197 */
198export const MEASURE = 72
199/** The terminal's right margin: figures stop one cell short of the pane's edge. */
200const RIGHT = 1
201/** The cells a section title's column takes, so header figures start on one column. */
202const TITLE_COL = 10
203
204/** The terminal's fold arrows: the small triangles, a cell each. */
205export const FOLD = { open: '▾', closed: '▸' } as const
206
207/**
208 * The terminal's marks for lists and footers: a selected row's gutter mark (a
209 * left half block, in `suggestion`, Claude Code's own selection colour), the key
210 * hints' separator, and the ellipsis that ends anything cut.
211 */
212export const MARK = { selected: '▌', sep: ' · ', more: '…', stale: '↻' } as const
213
214/**
215 * Braille dots for a sparkline, filled from the bottom: a cell holds two points
216 * (its left and right dot columns), four levels each. `left[n]` is the left
217 * column's bottom n dots, `right[n]` the right column's; OR them onto U+2800.
218 */
219export const SPARK = {
220 base: 0x2800,
221 left: [0, 0x40, 0x44, 0x46, 0x47],
222 right: [0, 0x80, 0xa0, 0xb0, 0xb8],
223} as const
224/**
225 * A band's (a section header's, a footer's) height on the app surfaces, in rows of
226 * size: one text row and a little room. Fixed rather than padded, so every band is
227 * as tall as every other whatever it holds: a native Button, a Link or an icon in
228 * a row of the real desktop is taller or shorter than a line of text, and a padded
229 * band grew or shrank with it (the launcher's rows came out uneven).
230 */
231export const BAND_ROWS = 1.3
232
233/**
234 * A Braille blank (U+2800): drawn as nothing, as a space is, but never trimmed or
235 * collapsed. A Button labelled with them lies over what the desktop can't draw in a
236 * Button (an icon, a coloured word), which shows through; its key is its address.
237 */
238export const BLANK = '⠀'
239/** A tile's padding above and below its lines, and the room between its lines: enough to breathe. */
240const TILE_PAD_Y = 0.6
241const TILE_ROW_GAP = 0.2
242
243/**
244 * The desktop's fold arrow: full-size triangles, since text can't be sized.
245 * U+FE0E keeps ▶ a text glyph; without it macOS can draw it as an emoji. Never
246 * on the terminal, which draws FOLD and never gets a variation selector.
247 */
248export const fold = (collapsed: boolean) => (collapsed ? '▶︎' : '▼')
249
250
251/**
252 * `text` split for two terminal lines `width` cells wide: the first holds as many
253 * whole words as fit (a word longer than the line is cut to it), the second the
254 * rest, which the caller truncates. The second is empty when it all fits on one.
255 */
256const breakLine = (text: string, width: number): [string, string] => {
257 const chars = [...text]
258 if (chars.length <= width) return [text, '']
259 // The last space at or before the line's end; none, a hard cut.
260 let cut = -1
261 for (let i = Math.min(width, chars.length - 1); i > 0; i--) {
262 if (chars[i] === ' ') {
263 cut = i
264 break
265 }
266 }
267 if (cut <= 0) return [chars.slice(0, width).join(''), chars.slice(width).join('').trimStart()]
268 return [chars.slice(0, cut).join('').trimEnd(), chars.slice(cut + 1).join('').trimStart()]
269}
270
271
272/** The debug footer's glyph for each job the mods time; all muted, a slow job's figure turns amber or red. */
273export const PERF_GLYPH: Readonly<Record<string, string>> ={ render: '▣', tick: '↻', resources: '◎', usage: '◕', git: '⎇', gh: '⇡' }
274
275/*
276 * The terminal's look ("rules", after lazygit and gh-dash, made quieter). A header
277 * is a dim arrow and icon, the title in bold default foreground, its figures on a
278 * shared column right after it (so the eye doesn't cross the pane), then a dim `─`
279 * rule out to the edge. Status colours keep their one meaning; nothing else is coloured.
280 *
281 * Two tones and one weight carry the hierarchy: dim for what a thing is (labels,
282 * units, notes), the default foreground for what it says (values, list text),
283 * bold for the title and the one figure that matters in each section (a header's
284 * first figure). No italics: real terminal fonts draw them oddly, and some not at
285 * all. Tune the look here, in one place:
286 * - title: a section's name: bold;
287 * - label: what a value is (a reading's, a prop's, a legend entry's label): dim;
288 * - value: the figure or word that answers (a reading's figure, a prop's value): plain;
289 * - strong: the one figure per section that matters (a header's first figure): bold;
290 * - secondary: text a reader still reads, after the main text (a list row's note): dim;
291 * - meta: what's scanned past (units, ages, counts in a list row): dim.
292 *
293 * The alternatives it was picked from (inspector, panel, ledger) are in commit ab3a37a.
294 */
295
296/** Text props a terminal role is drawn with. */
297type Role = { color?: string; dimColor?: boolean; bold?: boolean; italic?: boolean }
298
299export const TERM_ROLES: Readonly<Record<'title' | 'label' | 'value' | 'strong' | 'secondary' | 'meta', Role>> = {
300 title: { bold: true },
301 label: { dimColor: true },
302 value: {},
303 strong: { bold: true },
304 secondary: { dimColor: true },
305 meta: { dimColor: true },
306}
307
308/**
309 * The header icon's colour on the terminal: dim (MUTED), as the fold arrow, since
310 * grey is what icons mean. The title beside it is bold in the default foreground,
311 * so a header carries no hue that could be read as a status in the rows under
312 * it. Read when drawing, so a skin worn later is the one drawn.
313 */
314const termHeaderIcon = () => MUTED
315
316/** What the kit is told about the pane beyond its surface and width. */
317export type KitOptions = {
318 /**
319 * Whether the pane has the keyboard: pass `e.props.isFocused`. Key hints show
320 * only while it's true, and a selected list row is drawn dimmed while it's
321 * false. Left out, the pane counts as focused.
322 */
323 focused?: boolean
324 /**
325 * The widest the terminal draws sections, in cells (`MEASURE` for a dashboard
326 * of readings). Past it a section keeps this width and the rest of the pane
327 * stays empty. Left out, sections span the pane (a file tree wants every cell).
328 */
329 measure?: number
330 /**
331 * The column view's switch, as the mod holds it in its own state (key
332 * `columns`, default on): off, `flow` gives one stack at any width. With a
333 * `toggle`, a wide desktop pane's footer (`perf`, `footer`) draws the quiet
334 * action that flips it (see `flowToggle`).
335 */
336 flow?: FlowSwitch
337}
338
339/** The column view's switch: `on` as the mod's state holds it, `toggle` to flip it from a press. */
340export type FlowSwitch = { on: boolean; toggle?: () => void }
341
342/**
343 * A part of a list row's right cluster that says when it gives way: at narrow
344 * widths the part with the highest `drop` goes first (ties: the later one), then
345 * the next, until the row fits; parts without one (rank 0) are never dropped,
346 * only cut. `cells` is its width on the terminal when it's an element the kit
347 * can't measure (a string measures itself).
348 */
349export type RightPart = { part: RenderElement | string; drop?: number; cells?: number }
350
351/** One key hint: the key as the person types it, and what it does. */
352export type Hint = { key: string; label: string }
353
354const isRightPart = (x: RenderElement | string | RightPart): x is RightPart => typeof x === 'object' && x !== null && 'part' in x && !('type' in x)
355
356/**
357 * The cells a part draws in on the terminal, as far as the kit can tell: a
358 * string's characters; a Text's text; a row Box's children and gaps; a plain
359 * Button's label (and `1: ` before it with a hotkey). Undefined when it can't.
360 */
361const cellsOf = (x: unknown): number | undefined => {
362 if (x == null || x === false) return 0
363 if (typeof x === 'string' || typeof x === 'number') return [...String(x)].length
364 if (Array.isArray(x)) {
365 let n = 0
366 for (const c of x) {
367 const m = cellsOf(c)
368 if (m === undefined) return undefined
369 n += m
370 }
371 return n
372 }
373 if (typeof x !== 'object') return undefined
374 const el = x as { type?: string; props?: Record<string, any>; children?: unknown[] }
375 const p = el.props ?? {}
376 const kids = el.children ?? (p.children as unknown[] | undefined) ?? []
377 if (el.type === 'Text') return cellsOf(kids)
378 if (el.type === 'Button') {
379 const label = p.label === undefined ? cellsOf(kids) : [...String(p.label)].length
380 if (label === undefined) return undefined
381 return (p.plain ? label : label + 4) + (p.hotkey ? 3 : 0)
382 }
383 if (el.type === 'Box') {
384 const list = (Array.isArray(kids) ? kids : [kids]).flat(Infinity as 1).filter(c => c != null && c !== false)
385 const sizes = list.map(cellsOf)
386 if (sizes.some(s => s === undefined)) return undefined
387 const n = sizes as number[]
388 if (p.flexDirection === 'column') return Math.max(0, ...n)
389 const gap = Number(p.columnGap ?? p.gap ?? 0)
390 return n.reduce((a, b) => a + b, 0) + gap * Math.max(0, n.length - 1)
391 }
392 return undefined
393}
394
395/*
396 * Flow: a pane's sections in columns when the desktop gives it the width.
397 *
398 * The engine tells a render its pane's width (`e.props.bodyColumns`; on the desktop
399 * the pane's width over its code font's advance), and draws again when it changes.
400 * At a sidebar's width the sections stack as they always have; past two of
401 * FLOW_MIN (plus the gutter) they split into two columns, past three into three.
402 * The terminal always stacks: its panes dock beside the transcript and are narrow.
403 */
404
405/**
406 * The narrowest a flow column gets, in the pane's reported columns: about the
407 * widest a desktop sidebar is drawn (Storybook's 110), halved, so a section in a
408 * column reads as it does in a roomy sidebar. Two columns from 82 (about 640px), three from 124 (about 970px).
409 */
410export const FLOW_MIN = 40
411/** The most columns a flow splits into: more makes every section a sliver. */
412export const FLOW_MAX = 3
413/** The gutter between flow columns: a group's room, as between groups of items in a row. No rule or panel marks it. */
414const FLOW_GAP = SPACE.group
415
416/** How a flow is told to lay out; each defaults to the kit's FLOW_MIN, FLOW_MAX. */
417export type FlowOptions = {
418 /** The narrowest column, in reported columns. */
419 min?: number
420 /** The most columns. */
421 max?: number
422 /**
423 * Each block's height in rows, where the caller knows better than the kit's
424 * estimate (`undefined` entries are estimated). In the order the blocks are given.
425 */
426 rows?: (number | undefined)[]
427 /**
428 * The room between blocks in a column, in the desktop's vertical units, for
429 * blocks that space themselves apart in one stack (a list's tiles, each with a
430 * margin above all but the first): in columns they drop that margin and the
431 * column spaces them, so every column's first block starts on one line.
432 */
433 gap?: number
434 /** One stack at any width: the column view switched off (KitOptions' `flow`). */
435 single?: boolean
436}
437
438/** Whether a pane `columns` across on `surface` is wide enough to split into flow columns at all. */
439export const flowSplits = (surface: RenderSurface, columns: number, o: Pick<FlowOptions, 'min'> = {}) =>
440 surface !== 'terminal' && Math.floor((Math.floor(columns) + FLOW_GAP) / ((o.min ?? FLOW_MIN) + FLOW_GAP)) >= 2
441
442/**
443 * How many columns a flow of `blocks` blocks takes at `columns` across on
444 * `surface`, and each column's width in reported columns: 1 and the whole width
445 * on the terminal, at a sidebar's width, with fewer than two blocks, or `single`.
446 */
447export const flowColumns = (surface: RenderSurface, columns: number, blocks: number, o: FlowOptions = {}) => {
448 const min = o.min ?? FLOW_MIN
449 const max = o.max ?? FLOW_MAX
450 const fit = surface === 'terminal' || o.single ? 1 : Math.floor((Math.floor(columns) + FLOW_GAP) / (min + FLOW_GAP))
451 const count = Math.max(1, Math.min(fit, max, blocks))
452 return { count, width: count === 1 ? columns : Math.floor((columns - FLOW_GAP * (count - 1)) / count) }
453}
454
455/** A drawn element's own props and children, as the engine's JSX and Storybook's both build them. */
456const partsOf = (x: any): { type?: string; p: Record<string, any>; kids: unknown[] } => {
457 const p = (x?.props ?? {}) as Record<string, any>
458 const given = x?.children ?? p.children ?? []
459 return { type: x?.type, p, kids: (Array.isArray(given) ? given : [given]).flat(Infinity as 1).filter(c => c != null && c !== false) }
460}
461
462/**
463 * About how many rows tall an element draws on the desktop: a Text or a Button a
464 * row, a column Box its children stacked, a row Box its tallest child, a `height`
465 * as given; vertical margins, padding and gaps at half a row a unit, as the
466 * desktop draws them. Only good enough to balance columns, never to size them.
467 */
468export const rowsOf = (x: unknown): number => {
469 if (x == null || x === false) return 0
470 if (typeof x === 'string' || typeof x === 'number') return 1
471 if (Array.isArray(x)) return x.reduce((n: number, c) => n + rowsOf(c), 0)
472 if (typeof x !== 'object') return 0
473 const { type, p, kids } = partsOf(x)
474 if (p.display === 'none' || p.position === 'absolute') return 0
475 const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
476 // Vertical spacing in half rows (the desktop's unit for it), as rows.
477 const around =
478 (num(p.marginTop) + num(p.marginBottom) + 2 * num(p.marginY) + 2 * num(p.margin) + num(p.paddingTop) + num(p.paddingBottom) + 2 * num(p.paddingY) + 2 * num(p.padding)) / 2
479 let own: number
480 if (typeof p.height === 'number') own = p.height
481 else if (type === 'Box') {
482 const sizes = kids.map(rowsOf)
483 if (p.flexDirection === 'column') own = sizes.reduce((a, b) => a + b, 0) + (num(p.rowGap ?? p.gap) * Math.max(0, sizes.length - 1)) / 2
484 else own = Math.max(0, ...sizes)
485 } else own = 1
486 return Math.max(own, num(p.minHeight)) + around
487}
488
489/**
490 * Splits `heights` into `n` runs in order, so the tallest run is as short as it
491 * can be: blocks read top to bottom, then left to right, and the columns end
492 * about even. Returns each run's first index.
493 */
494const balance = (heights: number[], n: number): number[] => {
495 const m = heights.length
496 const sum = [0]
497 for (const h of heights) sum.push(sum[sum.length - 1]! + h)
498 // best[j][i]: the tallest column laying the first i blocks in j columns; cut[j][i] where the last column starts.
499 const best = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(Infinity))
500 const cut = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0))
501 best[0]![0] = 0
502 for (let j = 1; j <= n; j++) {
503 for (let i = j; i <= m; i++) {
504 // Every column holds at least one block.
505 for (let s = j - 1; s < i; s++) {
506 const tall = Math.max(best[j - 1]![s]!, sum[i]! - sum[s]!)
507 // Ties go to the later cut, so earlier columns take more and the last isn't the tall one.
508 if (tall <= best[j]![i]!) {
509 best[j]![i] = tall
510 cut[j]![i] = s
511 }
512 }
513 }
514 }
515 const starts: number[] = []
516 let i = m
517 for (let j = n; j >= 1; j--) {
518 starts.unshift(cut[j]![i]!)
519 i = cut[j]![i]!
520 }
521 return starts
522}
523
524/**
525 * Lays `blocks` (a pane's sections, a list's tiles) out in columns when the pane
526 * is wide enough (see `flowColumns`); otherwise gives them back as they were, so
527 * a narrow pane draws exactly what it drew before. Spread the result where the
528 * blocks went: `{flow(Box, e.surface, columns, 'sections', [a, b, c])}`.
529 *
530 * A block is never split across columns. They read top to bottom, then left to
531 * right, cut where the columns come out most even by `rowsOf`'s estimate (or the
532 * `rows` given). Each column is a plain column Box (key `<key>-col-<n>`), each
533 * block in it spanning the column's width; the gutter between is FLOW_GAP, with
534 * no rule, panel or outline.
535 */
536export const flow = (
537 Box: Els['Box'],
538 surface: RenderSurface,
539 columns: number,
540 key: string,
541 blocks: (RenderElement | null | false | undefined)[],
542 o: FlowOptions = {},
543): (RenderElement | null)[] => {
544 const shown = blocks.map((b, i) => ({ b, rows: o.rows?.[i] })).filter((x): x is { b: RenderElement; rows: number | undefined } => x.b != null && x.b !== false)
545 const { count } = flowColumns(surface, columns, shown.length, o)
546 if (count < 2) return blocks.map(b => b || null)
547 const starts = balance(
548 shown.map(x => x.rows ?? rowsOf(x.b)),
549 count,
550 )
551 const runs = starts.map((s, c) => shown.slice(s, starts[c + 1] ?? shown.length))
552 return [
553 <Box key={key} flexDirection="row" width="100%" columnGap={FLOW_GAP} alignItems="flex-start">
554 {runs.map((run, c) => (
555 // Columns share the row equally, growing from no width, as a legend's do (the desktop doesn't stretch a nested box).
556 <Box key={`${key}-col-${c}`} flexDirection="column" flexGrow={1} flexShrink={1} width={0} minWidth={0} {...(o.gap ? { rowGap: o.gap } : {})}>
557 {run.map((x, i) => (
558 <Box key={`${key}-${c}-${i}`} flexDirection="column" width="100%" minWidth={0}>
559 {x.b}
560 </Box>
561 ))}
562 </Box>
563 ))}
564 </Box>,
565 ]
566}
567
568export const kit = (els: Els, surface: RenderSurface, columns = 40, opts: KitOptions = {}) => {
569 const { Box, Text, Button, Svg } = els
570 const terminal = surface === 'terminal'
571 /** Whether the pane has the keyboard; unknown counts as yes. */
572 const paneFocused = opts.focused !== false
573 /** A flow's options with the column view's switch applied: off, one stack. */
574 const flowOpts = (o: FlowOptions = {}): FlowOptions => (opts.flow?.on === false ? { ...o, single: true } : o)
575 /** Under MIN_COLUMNS the terminal draws section headers only. */
576 const floor = terminal && Math.floor(columns) < MIN_COLUMNS
577 /** A text role's props on the terminal (see TERM_ROLES). */
578 const R = TERM_ROLES
579 /** Text a step below the main text: one of the three greys on the app surfaces; plain dim in the terminal, whose greys are its own. */
580 const tone = (level: keyof typeof GRAY) => (terminal ? { dimColor: true } : { color: GRAY[level] })
581 /** A token colour as Text props for this surface: on the terminal a status's theme key, a grey as dim. */
582 const ink = (color?: string): { color?: string; dimColor?: boolean } => (terminal ? termInk(color) : color ? { color } : {})
583 /** The grid for this surface: CELLS on the terminal, SPACE elsewhere. */
584 const space = terminal ? CELLS : SPACE
585 const edge = terminal ? { left: GUTTER, right: RIGHT } : EDGE
586 /** A section's width in terminal cells: the pane's, up to the measure. */
587 const span = Math.max(1, Math.min(Math.floor(columns), opts.measure ?? Infinity))
588 /** A section body's width in terminal cells: the section's less the gutter and the right margin. */
589 const body = Math.max(1, span - GUTTER - RIGHT)
590 const clamp01 = (x: number) => (Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : 0)
591
592 /** A run of one glyph in one colour, as one Text. */
593 const run = (key: string, glyph: string, n: number, props: Record<string, unknown>) =>
594 n > 0 ? <Text key={key} {...props}>{glyph.repeat(n)}</Text> : null
595
596 /**
597 * A terminal gauge `width` cells wide: a heavy line to the half cell over a
598 * dim light rail. Anything above zero shows at least half a cell.
599 */
600 const lineMeter = (fraction: number, color: string, width: number): RenderElement => {
601 const w = Math.max(1, Math.floor(width))
602 let halves = Math.round(clamp01(fraction) * w * 2)
603 if (fraction > 0 && halves === 0) halves = 1
604 const full = Math.floor(halves / 2)
605 const half = halves % 2
606 return (
607 <Text>
608 {run('fill', METER.line, full, ink(color))}
609 {half ? <Text key="half" {...ink(color)}>{METER.half}</Text> : null}
610 {run('rail', METER.rail, w - full - half, { dimColor: true })}
611 </Text>
612 )
613 }
614
615 /**
616 * A terminal bar `width` cells wide, one row: each part a run of the heavy line
617 * (`━`) in its own solid colour, to the half cell; a `room` part the light rail
618 * (`─`) in its colour; what's left the dim rail. Anything above zero shows at
619 * least half a cell. A cell shared by two parts takes the first one's colour.
620 */
621 const lineBar = (parts: Part[], total: number, width: number): RenderElement => {
622 const w = Math.max(1, Math.floor(width))
623 const halves = w * 2
624 const ends: { end: number; color: string; room: boolean }[] = []
625 let at = 0
626 for (const p of parts) {
627 if (!(p.value > 0) || at >= halves) continue
628 at = Math.min(halves, at + Math.max(1, Math.round((p.value / total) * halves)))
629 ends.push({ end: at, color: p.color, room: Boolean(p.room) })
630 }
631 const partAt = (h: number) => ends.find(x => h < x.end)
632 const rail = { glyph: METER.rail, props: { dimColor: true } as Record<string, unknown> }
633 const cells: { glyph: string; props: Record<string, unknown> }[] = []
634 for (let c = 0; c < w; c++) {
635 const left = partAt(2 * c)
636 const right = partAt(2 * c + 1)
637 if (!left) cells.push(rail)
638 else if (left.room) cells.push({ glyph: METER.rail, props: ink(left.color) })
639 // A used part ending at the half: the half line, then nothing (a room part, or the rail, starts next cell).
640 else if (!right || right.room) cells.push({ glyph: METER.half, props: ink(left.color) })
641 else cells.push({ glyph: METER.line, props: ink(left.color) })
642 }
643 // Runs of the same glyph and colour, as one Text each.
644 const runs: RenderElement[] = []
645 let i = 0
646 while (i < cells.length) {
647 let j = i + 1
648 while (j < cells.length && cells[j]!.glyph === cells[i]!.glyph && JSON.stringify(cells[j]!.props) === JSON.stringify(cells[i]!.props)) j++
649 runs.push(<Text key={`r${i}`} {...cells[i]!.props}>{cells[i]!.glyph.repeat(j - i)}</Text>)
650 i = j
651 }
652 return <Text>{runs}</Text>
653 }
654
655 /*
656 * Rounding on the desktop. Boxes have no corner radius; what rounds is:
657 *
658 * - `capped`: anything thin (bars, gauges). SVG end caps drawn at an exact pixel
659 * size, so they stay crisp; the middle takes no height and stretches to the caps.
660 * - Nothing else: `rounded` boxes (bands, tiles) are square-cornered fills.
661 *
662 * What doesn't work, so nobody tries again: a `round` border in `transparent` to
663 * round a fill (the desktop doesn't know `transparent` as a border colour and draws
664 * the border in the text's colour: every band and tile became an outlined bubble),
665 * a border in a see-through fill's own colour (paints twice and draws a ring),
666 * borders on thin bars (empty, or blobs),
667 * glyph ends (◖━◗, ╺━╸ don't draw), SVG slivers a few pixels wide (don't draw), an
668 * SVG stretched to fit (blurs), a circle drawn over a rect in one SVG (a translucent
669 * colour doubles where they overlap).
670 */
671
672 /**
673 * An icon at the kit's one size (ICON_PX square): the SVG drawing where the
674 * surface has SVG and the kit has one for `key`, else its glyph as text
675 * (`glyphOf`: the terminal glyph for a named icon, never the name itself).
676 *
677 * `color` may be a file type's `Duo` (`FILE_TYPE`): light and dark values on the
678 * app surfaces, dim on the terminal, which draws file types monochrome.
679 * `alt` names the drawing for a reader that can't see it (the desktop's
680 * accessible name); `tooltip` also shows it under the pointer, drawing the SVG
681 * interactive (a sandboxed frame), so keep it to an icon that says something
682 * on its own (a branch's upstream), never one under a Button.
683 */
684 const icon = (key: string, color: string | Duo, px = ICON_PX, opts: { alt?: string; tooltip?: string } = {}): RenderElement => {
685 const source = Svg && !terminal ? iconSvg(key, color, px, opts.tooltip) : null
686 if (!Svg || !source) return <Text {...ink(typeof color === 'string' ? color : MUTED)}>{glyphOf(key)}</Text>
687 const alt = opts.alt ?? opts.tooltip ?? 'icon'
688 return opts.tooltip ? <Svg source={source} alt={alt} width={px} height={px} isInteractive /> : <Svg source={source} alt={alt} width={px} height={px} />
689 }
690
691 /**
692 * One rounded end:a square `px` tall, a half circle on the outside and a filled
693 * strip on the inside, as one shape.
694 */
695 const cap = (px: number, color: string, side: 'left' | 'right'): RenderElement | null => {
696 if (!Svg) return null
697 const r = px / 2
698 const d = side === 'left' ? `M${r} 0H${px}V${px}H${r}A${r} ${r} 0 0 1 ${r} 0Z` : `M0 0H${r}A${r} ${r} 0 0 1 ${r} ${px}H0Z`
699 // Over the track, as the bar's middle paints its parts: a see-through part's end
700 // then comes out the same shade as the rest of it.
701 const under = color === TRACK ? '' : `<path d="${d}" fill="${TRACK}"/>`
702 const source =
703 `<svg xmlns="http://www.w3.org/2000/svg" width="${px}" height="${px}" viewBox="0 0 ${px} ${px}">` +
704 `${under}<path d="${d}" fill="${color}"/></svg>`
705 return (
706 <Box flexShrink={0}>
707 <Svg source={source} alt="rounded end" width={px} height={px} />
708 </Box>
709 )
710 }
711
712 /**
713 * Rounds the ends of something thin: `middle` between a `left` and a `right` cap,
714 * `height` rows tall. Give `middle` no height of its own; it stretches to the caps,
715 * whose pixel size it can't drift from. Where the surface has no SVG, `middle` alone
716 * at `height`.
717 */
718 const capped = (height: number, left: string, right: string, middle: (height: number | undefined) => RenderElement): RenderElement => {
719 if (terminal || !Svg) return middle(height)
720 const px = Math.max(4, Math.round(height * ROW_PX))
721 return (
722 <Box flexDirection="row" flexGrow={1} alignItems="stretch">
723 {cap(px, left, 'left')}
724 {middle(undefined)}
725 {cap(px, right, 'right')}
726 </Box>
727 )
728 }
729
730 /**
731 * A filled box, a row or more tall: a band or a tile. Square-cornered and with no
732 * border on the app surfaces, as the design has no outlines and the desktop has no
733 * transparent border to round a fill with (see "Rounding on the desktop" above).
734 * In the terminal, the box without fill.
735 */
736 const rounded = (color: string, props: Record<string, unknown>, children: RenderElement | (RenderElement | null)[] | null) =>
737 terminal ? (
738 <Box {...props}>{children}</Box>
739 ) : (
740 <Box {...props} backgroundColor={color}>
741 {children}
742 </Box>
743 )
744
745 /**
746 * A tile: one item of a list that has several lines (a branch and its PR), in its
747 * own rounded fill a step lighter than the pane, so items read as units. A
748 * `selected` tile takes a blue-tinted fill: picked, without an outline; half
749 * as tinted while the pane isn't `focused` (the kit's `focused` by default).
750 * On the terminal, a blank row above and no fill, selected or not: a terminal
751 * list that selects draws its rows with `item`, which has a selection mark.
752 */
753 const tile = (key: string, children: (RenderElement | null)[], opts: { selected?: boolean; focused?: boolean } = {}) =>
754 terminal ? (
755 <Box key={key} flexDirection="column" marginTop={1}>
756 {children}
757 </Box>
758 ) : (
759 rounded(
760 opts.selected ? ((opts.focused ?? paneFocused) ? FILL.selected : FILL.selectedBlurred) : FILL.tile,
761 { key, flexDirection: 'column', paddingX: SPACE.item, paddingY: TILE_PAD_Y, rowGap: TILE_ROW_GAP, width: '100%' },
762 children,
763 )
764 )
765
766 /**
767 * A thin bar: parts left to right out of `max`, the rest as track. On the
768 * terminal one row `width` cells wide (a section body's width by default): the
769 * gauge's heavy line, each part in its own colour, `room` parts and the rest
770 * as the light rail (see `lineBar`).
771 */
772 const bar = (parts: Part[], max: number, opts: { height?: number; width?: number } = {}): RenderElement => {
773 const total = Math.max(max, parts.reduce((n, p) => n + Math.max(0, p.value), 0), 1)
774 if (terminal) {
775 const w = opts.width ?? body
776 return lineBar(parts, total, w)
777 }
778 // Grow factors out of 1000 (the desktop caps them at 10000): finer than a pixel at any width.
779 // Rounded by SVG end caps around a flat middle. Borders can't round it: on the desktop a
780 // border takes room inside the box, so a thin bordered track draws empty and bordered fills
781 // draw as blobs; glyph bars (◖━◗, ╺━╸) don't draw at all.
782 const height = opts.height ?? BAR
783 const shown = parts.filter(p => p.value > 0).map(p => ({ ...p, grow: (p.value / total) * 1000 }))
784 const rest = Math.max(0, 1000 - shown.reduce((n, p) => n + p.grow, 0))
785 // Not `h`: that's the JSX factory.
786 const flat = (rows: number | undefined) => (
787 <Box flexDirection="row" flexGrow={1} height={rows} backgroundColor={TRACK} overflow="hidden">
788 {/* An explicit full height: the desktop doesn't stretch a child to its parent's height. */}
789 {shown.map(p => (
790 <Box flexGrow={p.grow} flexShrink={0} width={0} height="100%" backgroundColor={p.color} />
791 ))}
792 {rest > 0 ? <Box flexGrow={rest} width={0} /> : null}
793 </Box>
794 )
795 // The caps sit inside the bar's width, so a scale under it still lines up with its ends.
796 const first = shown[0]?.color ?? TRACK
797 const last = rest > 0 ? TRACK : (shown[shown.length - 1]?.color ?? TRACK)
798 return capped(height, first, last, flat)
799 }
800
801 /**
802 * A label as GitHub draws one: a rounded chip filled with the label's colour,
803 * faint and see-through (FILL.pill), and its name in the theme's foreground, which
804 * reads on the light desktop theme and the dark one whatever the label's colour
805 * (a label without a hex colour gets the track's grey). On the
806 * terminal, a `■` swatch and the name in the default foreground: a label's
807 * colour is data (GitHub's hex) that may not read on the person's theme, so it
808 * only ever tints the one-cell swatch, never the text. A status colour (a kit
809 * token) draws as its theme key, as everywhere.
810 */
811 const pill = (key: string, text: string, color: string) => {
812 if (terminal) {
813 return (
814 <Text key={key}>
815 <Text {...(isKitColor(color) ? ink(color) : { color })}>■</Text>
816 {` ${text}`}
817 </Text>
818 )
819 }
820 return rounded(tint(color, FILL.pill) ?? TRACK, { key, flexDirection: 'row', paddingX: 0.6, flexShrink: 0 }, [<Text key="t">{text}</Text>])
821 }
822
823 /**
824 * A small mark of colour, a legend's key: a dot on the app surfaces, a square
825 * `■` on the terminal. `line: true` keys a line bar's part (`k.bar`) on the
826 * terminal with its own glyph: `━` for a part, `─` for a `room` one.
827 */
828 const swatch = (color: string, o: { line?: boolean; room?: boolean } = {}) => (
829 <Text {...ink(color)}>{terminal ? (o.line ? (o.room ? METER.rail : METER.line) : '■') : '●'}</Text>
830 )
831
832 /**
833 * A legend: each entry a key, a dim label and its figure. `note` is an optional
834 * coloured figure after it (a failure count). With `bar: true` (a legend that
835 * keys a `bar`, its items in the bar's order, `room` ones included) the
836 * terminal's keys are the bar's own glyphs, `━` and `─`.
837 *
838 * On the terminal it's a compact grid, filled row by row in the items' order:
839 * as many columns as fit with labels up to 10 cells (`maxCols`, 3 at most),
840 * 3 cells apart; then each column takes its share of the row, up to what its
841 * longest label, figure and note need, so labels widen into spare room and are
842 * cut with … only when it runs out, and a label stays close to its figure. The app surfaces
843 * share the row out in up to three columns.
844 */
845 const legend = (
846 key: string,
847 items: { label: string; color: string; figure: string; note?: string; noteColor?: string; room?: boolean }[],
848 maxCols?: number,
849 opts: { bar?: boolean } = {},
850 ) => {
851 if (terminal) {
852 const len = (s: string) => [...s].length
853 const longest = Math.max(1, ...items.map(t => len(t.label)))
854 const figW = Math.max(1, ...items.map(t => len(t.figure)))
855 const noteW = Math.max(0, ...items.map(t => (t.note ? len(t.note) + 1 : 0)))
856 const fixed = 2 + 1 + figW + noteW
857 const gap = 3
858 // How many columns fit is decided at the compact width (labels up to LABEL cells)...
859 const compact = fixed + Math.min(LABEL, longest)
860 const fit = Math.max(1, Math.floor((body + gap) / (compact + gap)))
861 const cols = Math.max(1, Math.min(maxCols ?? 3, fit, items.length))
862 const rows = Array.from({ length: Math.ceil(items.length / cols) }, (_, r) => items.slice(r * cols, r * cols + cols))
863 // ...then each column takes its share of the row, up to what its longest label needs,
864 // and the label has what's left after the key, figure and note.
865 const share = Math.floor((body - gap * (cols - 1)) / cols)
866 const width = Math.max(1, Math.min(fixed + longest, share))
867 const labelRoom = Math.max(1, width - fixed)
868 const fitLabel = (s: string) => (len(s) <= labelRoom ? s : [...s].slice(0, Math.max(0, labelRoom - 1)).join('') + MARK.more)
869 return (
870 <Box key={key} flexDirection="column">
871 {rows.map((row, r) => (
872 <Box key={`${key}-row-${r}`} flexDirection="row" gap={gap}>
873 {row.map(t => (
874 <Box key={`${key}-${t.label}`} flexDirection="row" width={width} flexShrink={0}>
875 <Box width={2} flexShrink={0}>
876 {swatch(t.color, { line: opts.bar, room: t.room })}
877 </Box>
878 <Box flexGrow={1} flexShrink={1} minWidth={0}>
879 <Text {...R.label} wrap="truncate">
880 {fitLabel(t.label)}
881 </Text>
882 </Box>
883 <Box width={figW + 1} flexShrink={0} flexDirection="row" justifyContent="flex-end">
884 <Text {...R.value}>{t.figure}</Text>
885 </Box>
886 {noteW ? (
887 <Box width={noteW} flexShrink={0} flexDirection="row" justifyContent="flex-end">
888 {t.note ? <Text {...ink(t.noteColor)}>{t.note}</Text> : null}
889 </Box>
890 ) : null}
891 </Box>
892 ))}
893 </Box>
894 ))}
895 </Box>
896 )
897 }
898 // Few rows: two columns for up to four entries, three beyond. (The pane's reported
899 // width in columns runs smaller than what the desktop draws, so it can't decide this.)
900 const cols = Math.min(maxCols ?? 3, items.length <= 4 ? 2 : 3, Math.max(1, items.length))
901 const per = Math.ceil(items.length / cols)
902 const groups = Array.from({ length: cols }, (_, c) => items.slice(c * per, c * per + per))
903 return (
904 // The legend spans the body (the desktop doesn't stretch a nested box), and its
905 // columns share that width equally, as a bar's parts do: grow from zero width.
906 <Box key={key} flexDirection="row" width="100%" gap={space.group}>
907 {groups.map((col, c) => (
908 <Box key={`${key}-col-${c}`} flexGrow={1} width={0} flexDirection="column">
909 {col.map(t => (
910 // The full column's width (the desktop doesn't stretch it); the label gives way first.
911 <Box key={`${key}-${t.label}`} flexDirection="row" alignItems="center" gap={space.pair} width="100%">
912 {swatch(t.color)}
913 <Box flexGrow={1} flexShrink={1} minWidth={0}>
914 <Text {...tone('light')} wrap="truncate">
915 {t.label}
916 </Text>
917 </Box>
918 {t.note ? <Text {...ink(t.noteColor)}>{t.note}</Text> : null}
919 <Text>{t.figure}</Text>
920 </Box>
921 ))}
922 </Box>
923 ))}
924 </Box>
925 )
926 }
927
928 /** A caption: dim uppercase, for a chart's or a column's heading. The only uppercase besides a state word. */
929 const caption = (text: string) => <Text {...(terminal ? R.meta : tone('mid'))}>{text.toUpperCase()}</Text>
930
931 /**
932 * A header's figures, one form for every section: figures, each followed by
933 * a dim lowercase unit, SPACE.group apart. A figure is coloured only when the
934 * colour is a status. On the terminal the first figure is bold (the one that
935 * matters; put it first) and the rest plain.
936 *
937 * Its state, before the figures, so a cut never takes it:
938 * - `loading`: no figures yet (the first read): the pending mark `◌` holds
939 * their place, so a folded section still says it's coming;
940 * - `age`: how old the figures are when they're last-good figures kept through
941 * a refresh, as a dim `↻ 2m`;
942 * - `failed`: the last refresh failed and these are the last-good figures: an
943 * amber `▲` (with the age after it, dim, when given). Keep the figures; don't
944 * swap the body for an error.
945 */
946 const summary = (items: ([string, string?, string?] | null | false)[], state: { loading?: boolean; age?: string; failed?: boolean } = {}) => {
947 const shown = items.filter((x): x is [string, string?, string?] => Boolean(x))
948 const pending = state.loading && !shown.length
949 if (terminal) {
950 // One line of text, the figures CELLS.group apart: too wide for its room, it's
951 // cut wherever that falls and ends in an ellipsis, never mid-figure without one.
952 const mark = state.failed ? (
953 <Text key="state">
954 <Text {...ink(STATUS.warn.color)}>{STATUS.warn.glyph}</Text>
955 {state.age ? <Text {...R.secondary}>{` ${state.age}`}</Text> : null}
956 {shown.length ? ' '.repeat(CELLS.group) : null}
957 </Text>
958 ) : state.age ? (
959 <Text key="state" {...R.secondary}>
960 {`${MARK.stale} ${state.age}`}
961 {shown.length ? ' '.repeat(CELLS.group) : null}
962 </Text>
963 ) : null
964 return (
965 <Box flexDirection="row" minWidth={0} flexShrink={1}>
966 <Text wrap="truncate">
967 {pending ? <Text key="pending" {...ink(STATUS.pending.color)}>{STATUS.pending.glyph}</Text> : null}
968 {mark}
969 {shown.map(([figure, unit, color], i) => (
970 <Text key={`${figure}-${unit ?? ''}`}>
971 {i ? ' '.repeat(CELLS.group) : null}
972 {/* The first figure is the one that matters: bold; the rest plain. */}
973 <Text {...(i ? R.value : R.strong)} {...ink(color)}>{figure}</Text>
974 {unit ? <Text {...R.meta}>{` ${unit}`}</Text> : null}
975 </Text>
976 ))}
977 </Text>
978 </Box>
979 )
980 }
981 // Too wide for the band, the last item ends in an ellipsis and what's past it is cut.
982 return (
983 <Box flexDirection="row" gap={space.group} minWidth={0} flexShrink={1} overflow="hidden">
984 {pending ? <Text key="pending" color={STATUS.pending.color}>{STATUS.pending.glyph}</Text> : null}
985 {state.failed ? (
986 <Text key="state">
987 <Text color={STATUS.warn.color}>{STATUS.warn.glyph}</Text>
988 {state.age ? <Text {...tone('mid')}>{` ${state.age}`}</Text> : null}
989 </Text>
990 ) : state.age ? (
991 <Text key="state" {...tone('mid')}>{`${MARK.stale} ${state.age}`}</Text>
992 ) : null}
993 {shown.map(([figure, unit, color], i) => (
994 <Text key={`${figure}-${unit ?? ''}`} wrap={i === shown.length - 1 ? 'truncate' : undefined}>
995 <Text {...ink(color)}>{figure}</Text>
996 {unit ? <Text {...tone('mid')}>{` ${unit}`}</Text> : null}
997 </Text>
998 ))}
999 </Box>
1000 )
1001 }
1002
1003 /**
1004 * Labels under a chart, each centred under its place: `at` from 0 to 1 across
1005 * the chart's width (the first and last flush with its ends). On the terminal
1006 * one dim line `width` cells wide (a section body's by default; pass the
1007 * chart's): a label that would touch the one before it is left out.
1008 */
1009 const axis = (key: string, labels: { at: number; text: string }[], width?: number) => {
1010 const row: RenderElement[] = []
1011 const sorted = [...labels].sort((a, b) => a.at - b.at)
1012 if (terminal) {
1013 const w = Math.max(1, Math.floor(width ?? body))
1014 const line = Array.from({ length: w }, () => ' ')
1015 let free = 0
1016 for (const l of sorted) {
1017 const text = [...l.text]
1018 const start = Math.max(0, Math.min(w - text.length, Math.round(clamp01(l.at) * w - text.length / 2)))
1019 if (start < free || start + text.length > w) continue
1020 text.forEach((ch, i) => (line[start + i] = ch))
1021 free = start + text.length + 1
1022 }
1023 return (
1024 <Text key={key} dimColor>
1025 {line.join('').trimEnd()}
1026 </Text>
1027 )
1028 }
1029 sorted.forEach((l, i) => {
1030 // Each label owns the span halfway to its neighbours; the ends own up to the edge.
1031 const from = i === 0 ? 0 : (sorted[i - 1]!.at + l.at) / 2
1032 const to = i === sorted.length - 1 ? 1 : (l.at + sorted[i + 1]!.at) / 2
1033 const align = i === 0 && l.at === 0 ? 'flex-start' : i === sorted.length - 1 && l.at === 1 ? 'flex-end' : 'center'
1034 row.push(
1035 <Box key={`${key}-${i}`} flexGrow={Math.max(1, Math.round((to - from) * 1000))} width={0} flexDirection="row" justifyContent={align}>
1036 <Text {...tone('dark')}>{l.text}</Text>
1037 </Box>,
1038 )
1039 })
1040 return (
1041 <Box key={key} flexDirection="row">
1042 {row}
1043 </Box>
1044 )
1045 }
1046
1047 /**
1048 * An instrument's scale under a bar: ticks at 0, 25, 50, 75 and 100%, and a
1049 * marker where `mark` falls (a fraction of the scale), in `markColor`. On the
1050 * terminal a row `width` cells wide (a section body's by default, as `bar`):
1051 * dim ticks (╵) and the marker as ▴.
1052 */
1053 const scale = (mark?: number, markColor: string = C.warn, width?: number) => {
1054 if (terminal) {
1055 const w = Math.max(2, Math.floor(width ?? body))
1056 const row = Array.from({ length: w }, () => ' ')
1057 for (const at of [0, 0.25, 0.5, 0.75, 1]) row[Math.round(at * (w - 1))] = '╵'
1058 const m = mark === undefined ? -1 : Math.round(clamp01(mark) * (w - 1))
1059 if (m < 0) return <Text dimColor>{row.join('')}</Text>
1060 return (
1061 <Text>
1062 <Text dimColor>{row.slice(0, m).join('')}</Text>
1063 <Text {...ink(markColor)}>▴</Text>
1064 <Text dimColor>{row.slice(m + 1).join('')}</Text>
1065 </Text>
1066 )
1067 }
1068 // Every tick at its place on the scale, the marker among them, gaps between sized by flex-grow.
1069 const marks: Array<{ at: number; color: string; height: number }> = [0, 0.25, 0.5, 0.75, 1].map(at => ({ at, color: TRACK, height: 0.3 }))
1070 if (mark !== undefined) marks.push({ at: Math.max(0, Math.min(1, mark)), color: markColor, height: 0.45 })
1071 marks.sort((a, b) => a.at - b.at)
1072 const row: RenderElement[] = []
1073 marks.forEach((m, i) => {
1074 if (i > 0) row.push(<Box key={`g${i}`} flexGrow={Math.max(0, (m.at - marks[i - 1]!.at) * 1000)} width={0} />)
1075 row.push(<Box key={`m${i}`} width={0.12} height={m.height} backgroundColor={m.color} flexShrink={0} />)
1076 })
1077 return (
1078 <Box flexDirection="row" alignItems="flex-start" height={0.45}>
1079 {row}
1080 </Box>
1081 )
1082 }
1083
1084 /**
1085 * A section as a properties inspector draws one (Premiere's Effect Controls):
1086 * a collapsible header row (icon, title, figures, then the fold arrow last at
1087 * the right end) and its properties indented under it, the terminal's under the
1088 * title. The arrow (a Button, key `toggle-<id>`) folds it. No panel, outline or tint: rows and indentation do
1089 * the grouping. Collapsible when given `onToggle` (the mod keeps which are
1090 * folded in its state); folded, only the header row shows.
1091 */
1092 const section = (s: {
1093 id: string
1094 title: string
1095 /** An icon before the title (a GLYPH, drawn as the kit's SVG icon), always muted. */
1096 icon?: string
1097 right?: RenderElement | null
1098 collapsed?: boolean
1099 onToggle?: () => void
1100 /**
1101 * On the terminal, the right side (figures, buttons) keeps its natural width
1102 * and only the title is cut, with an ellipsis: for a header whose right side
1103 * is controls that must all show. Size the right side to fit the row yourself.
1104 * Without it, the title keeps up to 12 cells and then it and the figures give
1105 * way together. The desktop draws the same either way.
1106 */
1107 keepRight?: boolean
1108 /** No figures yet: with no `right`, the figure slot shows the pending mark (`summary([], { loading: true })`). */
1109 loading?: boolean
1110 /**
1111 * The figures restate what the body shows (the fullest of the readings under
1112 * it): the terminal draws them only while the body is hidden, folded or under
1113 * the narrow floor, so an open section doesn't say it twice.
1114 */
1115 restated?: boolean
1116 children: RenderElement | (RenderElement | null)[] | null
1117 }) => {
1118 if (s.loading && !s.right) s = { ...s, right: summary([], { loading: true }) }
1119 if (terminal) {
1120 // ◔ Title figures ─────────────── ▾
1121 // body, two cells in, under the title
1122 // A dim icon, the title bold, the figures on a shared column right after it
1123 // (TITLE_COL), a dim rule, then the dim fold arrow last, flush right. The icon
1124 // takes the GUTTER the body is indented by, so a title lines up with the
1125 // readings under it. No band or box. Every section leaves one blank row under
1126 // it, open or folded.
1127 const arrow = s.collapsed ? FOLD.closed : FOLD.open
1128 const toggle =
1129 s.onToggle && Button ? (
1130 <Button key={`toggle-${s.id}`} plain dimColor label={arrow} onPress={() => s.onToggle?.()} />
1131 ) : (
1132 <Text dimColor>{arrow}</Text>
1133 )
1134 // The arrow keeps its cell whatever else gives way; `gap` is its room when the row has no gap of its own.
1135 const arrowBox = (gap: number) => (
1136 <Box key="fold" flexShrink={0} marginLeft={gap}>
1137 {toggle}
1138 </Box>
1139 )
1140 const lead = s.icon ? GUTTER : 0
1141 const titleCells = [...s.title].length
1142 const keeps = Math.min(titleCells, 12)
1143 // Under MIN_COLUMNS, the header and its figures only: the body is left out.
1144 const open = !s.collapsed && !floor
1145 const right = s.restated && open ? null : s.right
1146 const head = [
1147 s.icon ? (
1148 <Box key="icon" width={GUTTER} flexShrink={0}>
1149 {icon(s.icon, termHeaderIcon())}
1150 </Box>
1151 ) : null,
1152 <Box key="name" flexShrink={1} minWidth={0}>
1153 <Text wrap="truncate" {...R.title}>
1154 {s.title}
1155 </Text>
1156 </Box>,
1157 ]
1158 // The rule takes only the room left: from no width, cut at its box's edge.
1159 const rule = (
1160 <Box key="rule" flexGrow={1} flexShrink={1} width={0} minWidth={0} height={1} overflow="hidden">
1161 <Text dimColor>{METER.rail.repeat(Math.max(1, Math.floor(columns)))}</Text>
1162 </Box>
1163 )
1164 const row = s.keepRight ? (
1165 // Controls on the right edge: they keep their width and only the title is cut.
1166 <Box flexDirection="row" gap={CELLS.item}>
1167 <Box key="title" flexDirection="row" flexShrink={1} minWidth={lead + 1}>
1168 {head}
1169 </Box>
1170 {rule}
1171 {right ? (
1172 <Box key="right" flexDirection="row" minWidth={0} flexShrink={0} overflow="hidden">
1173 {right}
1174 </Box>
1175 ) : null}
1176 {arrowBox(0)}
1177 </Box>
1178 ) : (
1179 // The title's column gives way first (down to 12 cells of title), then the figures; the rule
1180 // only ever takes what's left.
1181 <Box flexDirection="row">
1182 <Box
1183 key="title"
1184 flexDirection="row"
1185 // Without figures the rule follows the title; with them, they start on the shared column.
1186 width={lead + (right ? Math.max(TITLE_COL, titleCells) + CELLS.group : titleCells + CELLS.item)}
1187 flexShrink={1000}
1188 minWidth={lead + keeps + (right ? CELLS.group : CELLS.item)}
1189 >
1190 {head}
1191 </Box>
1192 {right ? (
1193 <Box key="right" flexDirection="row" minWidth={0} flexShrink={1} overflow="hidden" marginRight={CELLS.item}>
1194 {right}
1195 </Box>
1196 ) : null}
1197 {rule}
1198 {arrowBox(CELLS.item)}
1199 </Box>
1200 )hooks/desktop.tsx 425 lines1// New in this fork (awesome-claude): the desktop version of the pane, drawn with the shared kit.
2import type { Elements, RenderElement } from 'claude-code'
3
4import { ago } from '../kit/format'
5import { C, FILE_TYPE, GLYPH, GRAY, TRACK } from '../kit/tokens'
6import type { FileType } from '../kit/tokens'
7import { EDGE, SPACE, kit } from '../kit/ui'
8
9import type { Activity, FileTree } from '../types'
10import { dirname, formatSize, HOST_ICON, hostKind, remoteName, type Row, underAny } from './tree'
11
12/*
13 * The desktop's file tree, as the other mods' desktop panes draw: a header line
14 * with the folder and its actions as icons, the branch line, the search field,
15 * then one row per file or folder (indent, fold arrow, Octicon, name, figures on
16 * the right), and a footer with the selection and git activity.
17 *
18 * The rows are drawn here, not by the `Client` the terminal uses (rows.tsx): a
19 * surface module can't draw `Svg`, and the rows' icons are Octicons. A press on
20 * a row's name selects it and opens a folder, twice opens it, as a click does in
21 * the terminal; Claude's reads and writes light their rows for as long as the
22 * terminal's shimmer runs.
23 */
24
25export type DesktopElements = Pick<Elements['desktop'], 'Box' | 'Text' | 'Button' | 'Input' | 'Svg'>
26
27/** What the desktop pane draws from, besides the tree itself. */
28export type DesktopView = {
29 t: FileTree
30 rows: Row[]
31 /** The git activity shown in the footer: the running one, else the latest. */
32 latest: Activity | undefined
33 /** Whether the tree follows the session's cwd (the home button lit). */
34 follow: boolean
35 now: number
36 columns: number
37 /** The pane's height in rows, so the footer sits on its bottom edge. */
38 bodyRows: number
39 /** A path relative to the tree's root, or shortened under home. */
40 shown: (path: string) => string
41 /** The id the branch line flashes under. */
42 branchRow: string
43}
44
45/** What the pane's controls do; register.tsx runs them with its `$`. */
46export type DesktopActions = {
47 up: () => void
48 home: () => void
49 refresh: () => void
50 hidden: () => void
51 sizes: () => void
52 collapse: () => void
53 unselect: () => void
54 press: (id: string) => void
55 open: (id: string) => void
56 search: (query: string) => void
57 jump: (query: string) => void
58}
59
60/** The branch name's least width in cells, however many figures share its line. */
61const HEAD_MIN = 6
62
63/** An icon button's label: two Braille blanks, about the icon's two cells, drawn as nothing. */
64export const BLANK = '⠀⠀'
65
66/** The most rows drawn: a tree past it would outgrow what the desktop takes in one drawing. */
67export const MAX_ROWS = 1500
68
69const CODE = new Set(
70 'ts tsx mts cts js jsx mjs cjs py rs go rb java kt kts swift c h cc cpp hpp cs php sh bash zsh fish lua sql html htm css scss sass less vue svelte zig ex exs erl hs ml scala dart r jl pl ps1 nix'.split(' '),
71)
72
73/** A file's Octicon: code for source files, a page for anything else. */
74export const fileIcon = (name: string, kind: string, open: boolean) => {
75 if (kind === 'dir') return open ? 'folder-open' : 'folder'
76 const ext = name.includes('.') ? (name.split('.').pop() ?? '').toLowerCase() : ''
77 return CODE.has(ext) ? 'file-code' : 'file'
78}
79
80/** Extensions by the file type IntelliJ's New UI draws them as (its `fileTypes` icons). */
81const TYPE_BY_EXT = new Map<string, FileType>()
82for (const [type, exts] of Object.entries({
83 typescript: 'ts tsx mts cts',
84 javascript: 'js jsx mjs cjs',
85 json: 'json jsonc json5',
86 markdown: 'md mdx markdown',
87 yaml: 'yml yaml',
88 config: 'toml ini cfg conf properties env editorconfig',
89 style: 'css scss sass less',
90 html: 'html htm xhtml',
91 xml: 'xml xsd xsl plist',
92 data: 'csv tsv',
93 sql: 'sql',
94 image: 'png jpg jpeg gif webp ico bmp svg avif',
95 archive: 'zip tar gz tgz bz2 xz 7z rar jar',
96} satisfies Partial<Record<FileType, string>>))
97 for (const ext of exts.split(' ')) TYPE_BY_EXT.set(ext, type as FileType)
98
99/** Names IntelliJ draws as config whatever their extension. */
100const CONFIG_NAMES = new Set(['dockerfile', '.editorconfig', '.env', '.npmrc', '.nvmrc'])
101
102/**
103 * A row's file type, for its icon's colour (`FILE_TYPE`): a folder git ignores is
104 * excluded (node_modules, build output), any other folder grey, a file by its
105 * extension; lockfiles (`bun.lock`, `yarn.lock`) and anything unknown are text.
106 */
107export const fileType = (name: string, kind: string, excluded = false): FileType => {
108 if (kind === 'dir') return excluded ? 'excluded' : 'folder'
109 const lower = name.toLowerCase()
110 if (CONFIG_NAMES.has(lower) || lower.startsWith('.env.')) return 'config'
111 const ext = lower.includes('.') ? (lower.split('.').pop() ?? '') : ''
112 return TYPE_BY_EXT.get(ext) ?? 'text'
113}
114
115/**
116 * A git status letter's colour, in the kit's meanings: modified amber, new
117 * green, deleted or conflicted red, renamed or copied blue.
118 */
119export const statusColor = (status: string | undefined): string | undefined => {
120 if (!status) return undefined
121 if (status === 'D' || status === 'U') return C.bad
122 if (status === 'A' || status === '?') return C.ok
123 if (status === 'M' || status === 'T') return C.warn
124 if (status === 'R' || status === 'C') return C.blue
125 return GRAY.mid
126}
127
128/**
129 * The colour Claude's activity lights a row in. On a row: reads blue (live),
130 * writes amber (changed, as git marks a modified file), commits green, failures
131 * red. On the branch line purple is a merge, as everywhere in the kit.
132 */
133export const toneColor = (tone: string, branch = false): string => {
134 if (tone === 'green') return C.ok
135 if (tone === 'red') return C.bad
136 if (tone === 'orange') return C.warn
137 if (tone === 'purple') return branch ? C.purple : C.blue
138 return C.blue
139}
140
141/** An activity's Octicon, by what git or gh did. */
142export const activityIcon = (a: Activity) => {
143 const kind = a.kind
144 if (/\bcommit\b/.test(kind)) return 'git-commit'
145 if (/\bpush\b/.test(kind)) return 'repo-push'
146 if (/\b(pull|fetch|clone)\b/.test(kind)) return 'sync'
147 if (/\b(merge|rebase|cherry-pick)\b/.test(kind)) return 'pr-merged'
148 if (/^gh pr\b/.test(kind)) return 'pr-open'
149 if (/^gh issue\b/.test(kind)) return 'issue-opened'
150 if (/\b(reset|restore|revert|rm)\b/.test(kind)) return 'alert'
151 return GLYPH.branch
152}
153
154const activityColor = (a: Activity) => (a.state === 'failed' ? C.bad : a.state === 'running' ? C.blue : C.ok)
155
156export const renderDesktop = (els: DesktopElements, v: DesktopView, act: DesktopActions): RenderElement => {
157 const { Box, Text, Button, Input } = els
158 const { t } = v
159 const k = kit(els, 'desktop', v.columns)
160 const bright = new Set(t.flashOn ? t.flash : [])
161 const dimmed = new Set(t.flashOn ? t.flashDim : [])
162 const ignored = new Set(t.ignored)
163 const untracked = new Set(t.untrackedDirs)
164
165 /** An icon in a fixed column, so every name starts at one x. */
166 const lead = (key: string, icon: RenderElement) => (
167 <Box key={key} width={2} flexShrink={0} flexDirection="row" alignItems="center">
168 {icon}
169 </Box>
170 )
171
172 /**
173 * An action drawn as its icon. The desktop draws a Button as its label text and
174 * no children, so the icon (an SVG, named by `label` as its alt) sits in a box
175 * and a Button with a blank label the icon's size lies over it: a press anywhere
176 * on the icon is a press of the Button, its key the action's address. The label
177 * is two Braille blanks (U+2800): they draw nothing, as spaces do, but no
178 * renderer trims or collapses them.
179 */
180 const iconButton = (key: string, label: string, icon: string, color: string, onPress: () => void) => (
181 <Box key={`ib-${key}`} position="relative" width={2} height={1} flexShrink={0} flexDirection="row" alignItems="center" justifyContent="center">
182 {k.icon(icon, color, undefined, { alt: label })}
183 <Box position="absolute" top={0} left={0}>
184 <Button key={key} plain label={BLANK} onPress={onPress} />
185 </Box>
186 </Box>
187 )
188
189 /** A row: the left side gives way (truncates), the right side keeps its figures. */
190 const line = (key: string, left: (RenderElement | null)[], right: (RenderElement | null)[], extra: Record<string, unknown> = {}) => (
191 <Box key={key} flexDirection="row" justifyContent="space-between" alignItems="center" gap={SPACE.item} width="100%" {...extra}>
192 <Box flexDirection="row" alignItems="center" flexShrink={1} minWidth={0} overflow="hidden">
193 {left}
194 </Box>
195 <Box flexDirection="row" alignItems="center" gap={SPACE.pair} flexShrink={0}>
196 {right}
197 </Box>
198 </Box>
199 )
200
201 /** A folder's file counts: new, modified, deleted, each its letter dim and its figure in its colour. */
202 const counts = (key: string, c: [number, number, number] | undefined): RenderElement[] => {
203 if (!c) return []
204 const parts: [string, number, string][] = [
205 ['?', c[0], C.ok],
206 ['M', c[1], C.warn],
207 ['D', c[2], C.bad],
208 ]
209 return parts
210 .filter(([, n]) => n > 0)
211 .map(([letter, n, color]) => (
212 <Text key={`${key}-${letter}`}>
213 <Text color={GRAY.dark}>{letter}</Text>
214 <Text color={color}>{String(n)}</Text>
215 </Text>
216 ))
217 }
218
219 const lines = (key: string, loc: [number, number] | undefined): RenderElement[] => [
220 ...(loc && loc[0] > 0 ? [<Text key={`${key}-add`} color={C.ok}>{`+${loc[0]}`}</Text>] : []),
221 ...(loc && loc[1] > 0 ? [<Text key={`${key}-del`} color={C.bad}>{`−${loc[1]}`}</Text>] : []),
222 ]
223
224 // The header: the folder, then its actions.
225 const name = t.top ? [t.top.split('/').pop() ?? t.top, t.prefix.replace(/\/$/, '')].filter(Boolean).join('/') : t.root.split('/').pop() || t.root
226 const header = line(
227 'head',
228 [
229 lead('icon', k.icon('folder-open', GRAY.mid)),
230 <Text key="name" bold wrap="truncate">
231 {name}
232 </Text>,
233 ],
234 [
235 iconButton('up', 'Up a folder', 'arrow-up', GRAY.mid, act.up),
236 iconButton('cwd', 'Follow the working directory', 'home', v.follow ? GRAY.light : GRAY.dark, act.home),
237 iconButton('refresh', 'Refresh', 'sync', GRAY.mid, act.refresh),
238 iconButton('hidden', t.showHidden ? 'Hide hidden files' : 'Show hidden files', 'eye', t.showHidden ? GRAY.light : GRAY.dark, act.hidden),
239 iconButton('size', t.showSize ? 'Show dates' : 'Show sizes', GLYPH.context, t.showSize ? GRAY.light : GRAY.dark, act.sizes),
240 iconButton('collapse', 'Collapse all', 'fold', GRAY.mid, act.collapse),
241 t.selected ? iconButton('unselect', 'Clear the selection', GLYPH.failed, GRAY.mid, act.unselect) : null,
242 ],
243 { marginBottom: SPACE.block },
244 )
245
246 // The branch: its name, ahead and behind, then the lines and files changed.
247 const branch = () => {
248 if (!t.top) {
249 return line('branch', [lead('icon', k.icon(GLYPH.branch, GRAY.dark)), <Text key="none" color={GRAY.mid} wrap="truncate">no git repo · git status starts after git init</Text>], [], { marginBottom: SPACE.block })
250 }
251 if (!t.branch) return null
252 const b = t.branch
253 const flashing = bright.has(v.branchRow)
254 const tone = flashing ? toneColor(t.flashTones[v.branchRow] ?? 'teal', true) : undefined
255 const totals = t.diff[t.root] ?? [0, 0]
256 const rootCounts = counts('root', t.counts[t.root])
257 const changed = [...lines('root', totals), ...rootCounts]
258 return line(
259 'branch',
260 [
261 lead('icon', k.icon(GLYPH.branch, tone ?? GRAY.mid)),
262 // Never squeezed out: the figures on the right leave it a few letters (fewer when
263 // the name is shorter, so a short name doesn't leave a gap after it).
264 <Box key="head" flexShrink={1} minWidth={Math.min(HEAD_MIN, [...b.head].length)}>
265 <Text color={tone} wrap="truncate">
266 {b.head}
267 </Text>
268 </Box>,
269 b.ahead > 0 ? <Text key="ahead" color={GRAY.mid}>{` ↑${b.ahead}`}</Text> : null,
270 b.behind > 0 ? <Text key="behind" color={GRAY.mid}>{` ↓${b.behind}`}</Text> : null,
271 // The upstream as its host's icon (GitHub, GitLab, Forgejo, else a remote), its
272 // name the icon's tooltip; none when the branch tracks nothing.
273 // A local upstream (`main...main`, no remote) has no host to show.
274 b.upstream && remoteName(b.upstream) ? (
275 <Box key="upstream" flexShrink={0} marginLeft={SPACE.pair} flexDirection="row" alignItems="center">
276 {k.icon(HOST_ICON[hostKind(b.host)], GRAY.dark, undefined, { tooltip: b.upstream })}
277 </Box>
278 ) : null,
279 ],
280 changed.length ? changed : [<Text key="clean" color={GRAY.mid}>clean</Text>],
281 { marginBottom: SPACE.block },
282 )
283 }
284
285 // The search field, the pane's first row, as wide as the pane. Its submit ("jump":
286 // Enter opens the first match and clears the field) is drawn by the desktop as a
287 // button beside it: every Input has one there, labelled "submit" if not named.
288 const search = (
289 <Box key="search" flexDirection="row" alignItems="center" gap={SPACE.pair} width="100%" marginBottom={SPACE.block}>
290 {Input ? (
291 // A column, so the field stretches across the box.
292 <Box key="field" flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0}>
293 <Input key="q" placeholder="Search files" submitLabel="jump" autoFocus value={t.query} onInput={(q: string) => act.search(q)} onSubmit={(q: string) => act.jump(q)} />
294 </Box>
295 ) : null}
296 {t.query ? iconButton('clear', 'Clear the search', GLYPH.failed, GRAY.mid, () => act.search('')) : null}
297 </Box>
298 )
299
300 // One file or folder.
301 const row = (r: Row) => {
302 const n = r.node
303 const own = t.git[n.id]
304 const status = own ?? (underAny(dirname(n.id), untracked, t.root) ? '?' : undefined)
305 const isIgnored = !status && underAny(n.id, ignored, t.root)
306 const isBright = bright.has(n.id)
307 const isDim = !isBright && dimmed.has(n.id)
308 const tone = isBright || isDim ? toneColor(t.flashTones[n.id] ?? 'orange') : undefined
309 const muted = isIgnored || n.hidden
310 // The icon in its file type's colour (IntelliJ's), a folder git ignores in the excluded tone;
311 // Claude's activity lights it in the activity's colour instead.
312 const iconColor = tone ?? FILE_TYPE[fileType(n.name, n.kind, isIgnored)]
313 const loc = t.diff[n.id]
314 const meta = t.showSize
315 ? n.kind === 'dir'
316 ? n.id in t.dirSizes
317 ? formatSize(t.dirSizes[n.id] ?? -1)
318 : '…'
319 : n.kind === 'file' || (n.kind === 'link' && n.size > 0)
320 ? formatSize(n.size)
321 : ''
322 : loc || n.kind !== 'file' || !n.mtime
323 ? ''
324 : ago(v.now - n.mtime)
325 const dirCounts = n.kind === 'dir' ? counts(n.id, t.counts[n.id]) : []
326 const isCursor = n.id === t.cursor
327 // A Button sizes to its label, so the name is cut to the room left here, with an ellipsis,
328 // rather than clipped mid-letter: the pane less the indent, arrow, icon, figures and badge.
329 const c = n.kind === 'dir' ? t.counts[n.id] : undefined
330 const figures = [meta, loc?.[0] ? `+${loc[0]}` : '', loc?.[1] ? `−${loc[1]}` : '', ...(c ?? []).filter(x => x > 0).map(x => `?${x}`)].filter(Boolean)
331 const room = Math.floor(v.columns - EDGE.left - EDGE.right - r.depth * SPACE.item - SPACE.item * 2 - 2 - figures.join(' ').length - SPACE.pair)
332 const label = n.name.length > Math.max(4, room) ? `${n.name.slice(0, Math.max(3, room - 1))}…` : n.name
333 return (
334 <Box key={`row-${n.id}`} flexDirection="row" alignItems="center" width="100%" backgroundColor={isCursor ? TRACK : undefined}>
335 {/* A rail in the activity's colour while Claude reads or writes it. */}
336 <Box width={0.25} height={1} flexShrink={0} backgroundColor={isBright ? tone : undefined} />
337 <Box width={EDGE.left - 0.25 + r.depth * SPACE.item} flexShrink={0} />
338 <Box width={SPACE.item} flexShrink={0}>
339 <Text color={GRAY.dark}>{n.kind === 'dir' ? (r.open ? '▾' : '▸') : ' '}</Text>
340 </Box>
341 {lead('icon', k.icon(fileIcon(n.name, n.kind, r.open), iconColor))}
342 <Box flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
343 {/* The desktop draws a Button as its label, plain, never its children, so a name can't
344 take its git colour or a weight here: it's dim when ignored or hidden, the git letter
345 on the right says its state, the cursor's tint and the footer say what's selected. */}
346 <Button key={n.id} plain label={label} {...(muted ? { dimColor: true } : {})} onPress={() => act.press(n.id)} />
347 </Box>
348 <Box flexDirection="row" alignItems="center" gap={SPACE.pair} flexShrink={0} marginLeft={SPACE.pair}>
349 {meta ? <Text color={meta === '…' ? GRAY.dark : GRAY.mid}>{meta}</Text> : null}
350 {lines(n.id, loc)}
351 {dirCounts}
352 </Box>
353 <Box width={SPACE.item + EDGE.right} flexShrink={0} flexDirection="row" justifyContent="center">
354 {dirCounts.length || !status ? null : <Text color={statusColor(status)}>{status}</Text>}
355 </Box>
356 </Box>
357 )
358 }
359
360 const drawn = v.rows.slice(0, MAX_ROWS)
361 const tree = (
362 <Box key="tree" flexDirection="column" width="100%">
363 {v.rows.length === 0 ? (
364 <Box paddingLeft={EDGE.left}>
365 <Text color={GRAY.mid}>{t.query ? 'no match' : 'empty'}</Text>
366 </Box>
367 ) : null}
368 {drawn.map(row)}
369 {v.rows.length > drawn.length ? (
370 <Box paddingLeft={EDGE.left}>
371 <Text color={GRAY.mid}>{`${v.rows.length - drawn.length} more; search to find them`}</Text>
372 </Box>
373 ) : null}
374 </Box>
375 )
376
377 // The footer: what's selected (Claude sees it as context), and git's latest step.
378 const chip = (a: Activity) => (
379 <Box key="chip" flexDirection="row" alignItems="center" gap={SPACE.pair} flexShrink={0}>
380 {k.icon(activityIcon(a), activityColor(a))}
381 <Text color={a.state === 'running' ? C.blue : a.state === 'failed' ? C.bad : GRAY.light}>{a.state === 'running' ? `${a.label}…` : a.label}</Text>
382 {a.state === 'done' && a.kind === 'git commit' && a.detail ? <Text color={GRAY.dark}>{a.detail.split(' ')[0] ?? ''}</Text> : null}
383 </Box>
384 )
385 const selected = t.selected
386 const footer =
387 selected || v.latest
388 ? line(
389 'foot',
390 selected
391 ? [
392 lead('icon', k.icon('file', GRAY.dark)),
393 <Box key="path" flexShrink={1} minWidth={0}>
394 <Text color={GRAY.mid} wrap="truncate-start">
395 {v.shown(selected)}
396 </Text>
397 </Box>,
398 <Box key="open" flexShrink={0} marginLeft={SPACE.pair}>
399 <Button key="open" plain dimColor label="open" onPress={() => act.open(selected)} />
400 </Box>,
401 ]
402 : [],
403 [v.latest ? chip(v.latest) : null],
404 { marginTop: SPACE.block },
405 )
406 : null
407
408 return (
409 <Box flexDirection="column" minHeight={Math.max(1, v.bodyRows)} paddingTop={SPACE.block}>
410 <Box flexDirection="column" paddingLeft={EDGE.left} paddingRight={EDGE.right}>
411 {search}
412 {header}
413 {branch()}
414 </Box>
415 {tree}
416 <Box flexGrow={1} />
417 {footer ? (
418 <Box flexDirection="column" paddingLeft={EDGE.left} paddingRight={EDGE.right}>
419 {footer}
420 </Box>
421 ) : null}
422 </Box>
423 )
424}
425hooks/git.ts 259 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
27/**
28 * The activity tones a lit row or git step takes. Changed in this fork: the names
29 * only, no hex ramps; each draws in the kit's colour for its meaning (`toneColor`
30 * in desktop.tsx), a theme key on the terminal through the kit's `termInk`.
31 */
32export const TONES = ['orange', 'green', 'teal', 'blue', 'purple', 'cyan', 'red'] as const
33
34const GIT_VERBS: Record<string, Omit<GitAction, 'kind'>> = {
35 commit: { verb: 'commit', running: 'committing', done: 'committed', tone: 'green', icon: ICON.commit },
36 push: { verb: 'push', running: 'pushing', done: 'pushed', tone: 'teal', icon: ICON.push },
37 pull: { verb: 'pull', running: 'pulling', done: 'pulled', tone: 'blue', icon: ICON.pull },
38 fetch: { verb: 'fetch', running: 'fetching', done: 'fetched', tone: 'blue', icon: ICON.pull },
39 checkout: { verb: 'checkout', running: 'checking out', done: 'checked out', tone: 'blue', icon: ICON.branch },
40 switch: { verb: 'switch', running: 'switching', done: 'switched', tone: 'blue', icon: ICON.branch },
41 branch: { verb: 'branch', running: 'branching', done: 'branched', tone: 'blue', icon: ICON.branch },
42 merge: { verb: 'merge', running: 'merging', done: 'merged', tone: 'purple', icon: ICON.merge },
43 rebase: { verb: 'rebase', running: 'rebasing', done: 'rebased', tone: 'purple', icon: ICON.merge },
44 'cherry-pick': { verb: 'cherry-pick', running: 'cherry-picking', done: 'cherry-picked', tone: 'purple', icon: ICON.merge },
45 stash: { verb: 'stash', running: 'stashing', done: 'stashed', tone: 'orange', icon: ICON.stash },
46 reset: { verb: 'reset', running: 'resetting', done: 'reset', tone: 'red', icon: ICON.undo },
47 restore: { verb: 'restore', running: 'restoring', done: 'restored', tone: 'red', icon: ICON.undo },
48 revert: { verb: 'revert', running: 'reverting', done: 'reverted', tone: 'red', icon: ICON.undo },
49 add: { verb: 'add', running: 'staging', done: 'staged', tone: 'green', icon: ICON.git },
50 rm: { verb: 'rm', running: 'removing', done: 'removed', tone: 'red', icon: ICON.git },
51 mv: { verb: 'mv', running: 'moving', done: 'moved', tone: 'orange', icon: ICON.git },
52 tag: { verb: 'tag', running: 'tagging', done: 'tagged', tone: 'purple', icon: ICON.tag },
53 init: { verb: 'init', running: 'initialising', done: 'initialised', tone: 'green', icon: ICON.git, init: true },
54 clone: { verb: 'clone', running: 'cloning', done: 'cloned', tone: 'blue', icon: ICON.pull, init: true },
55}
56
57const GH_VERBS: Record<string, Omit<GitAction, 'kind'>> = {
58 'pr create': { verb: 'pr create', running: 'opening PR', done: 'opened PR', tone: 'purple', icon: ICON.pr },
59 'pr merge': { verb: 'pr merge', running: 'merging PR', done: 'merged PR', tone: 'purple', icon: ICON.merge },
60 'pr checkout': { verb: 'pr checkout', running: 'checking out PR', done: 'checked out PR', tone: 'blue', icon: ICON.pr },
61 'pr comment': { verb: 'pr comment', running: 'commenting on PR', done: 'commented on PR', tone: 'purple', icon: ICON.pr },
62 'pr review': { verb: 'pr review', running: 'reviewing PR', done: 'reviewed PR', tone: 'purple', icon: ICON.pr },
63 'repo clone': { verb: 'repo clone', running: 'cloning', done: 'cloned', tone: 'blue', icon: ICON.github, init: true },
64 'release create': { verb: 'release create', running: 'releasing', done: 'released', tone: 'purple', icon: ICON.tag },
65 'issue create': { verb: 'issue create', running: 'opening issue', done: 'opened issue', tone: 'purple', icon: ICON.github },
66}
67
68const READ_ONLY_GIT = new Set(['status', 'log', 'diff', 'show', 'blame', 'rev-parse', 'ls-files', 'grep', 'describe', 'config', 'remote', 'reflog', 'shortlog', 'help', 'version', 'check-ignore'])
69
70function segments(command: string, seps: string[] = []): string[][] {
71 const out: string[][] = []
72 let cur: string[] = []
73 let tok = ''
74 let quote = ''
75 let has = false
76 const endTok = () => {
77 if (has) cur.push(tok)
78 tok = ''
79 has = false
80 }
81 const endSeg = () => {
82 endTok()
83 if (cur.length) out.push(cur)
84 cur = []
85 }
86 for (let i = 0; i < command.length; i++) {
87 const ch = command[i] ?? ''
88 if (quote) {
89 if (ch === quote) quote = ''
90 else if (ch === '\\' && quote === '"' && i + 1 < command.length) tok += command[++i]
91 else tok += ch
92 } else if (ch === '"' || ch === "'") {
93 quote = ch
94 has = true
95 } else if (ch === '\\' && i + 1 < command.length) {
96 tok += command[++i]
97 has = true
98 } else if (ch === '#' && !has) {
99 while (i + 1 < command.length && command[i + 1] !== '\n') i++
100 } else if (ch === ' ' || ch === '\t') endTok()
101 else if (ch === '\n' || ch === ';') {
102 seps.push(';')
103 endSeg()
104 } else if (ch === '&' && (command[i - 1] === '>' || command[i + 1] === '>')) {
105 tok += ch
106 has = true
107 } else if (ch === '&' || ch === '|') {
108 const twice = command[i + 1] === ch
109 if (twice) i++
110 seps.push(twice ? ch + ch : ch)
111 endSeg()
112 } else {
113 tok += ch
114 has = true
115 }
116 }
117 endSeg()
118 if (!quote) return out
119 seps.push(';')
120 return command
121 .split(/&&|\|\||;|\||\n/)
122 .map(s => s.trim().split(/\s+/).filter(Boolean))
123 .filter(s => s.length > 0)
124}
125
126function stripGlobals(tokens: string[]): string[] {
127 const out = [...tokens]
128 if (out[0] === 'env') out.shift()
129 while (out.length && /^[A-Z_][A-Z0-9_]*=/.test(out[0] ?? '')) out.shift()
130 return out
131}
132
133function gitVerb(tokens: string[]): number {
134 let i = 1
135 while (i < tokens.length && (tokens[i] ?? '').startsWith('-')) {
136 if (tokens[i] === '-C' || tokens[i] === '-c') i++
137 i++
138 }
139 return i
140}
141
142function gitReadOnly(verb: string, rest: string[]): boolean {
143 if (!verb || READ_ONLY_GIT.has(verb)) return true
144 if (verb === 'stash') return ['list', 'show'].includes(rest[0] ?? '')
145 if (verb === 'branch') return rest.every(t => t.startsWith('-'))
146 if (verb === 'tag') return rest.length === 0 || rest.some(t => /^(-l|--list|-n\d*|--contains|--points-at|--merged|--no-merged|-v|--verify)$/.test(t))
147 return false
148}
149
150export function chainOf(command: string): { size: number; and: boolean } {
151 const seps: string[] = []
152 const size = segments(command, seps).length
153 return { size, and: seps.every(s => s === '&&') }
154}
155
156export function gitActions(command: string): GitAction[] {
157 const out: GitAction[] = []
158 for (const raw of segments(command)) {
159 const tokens = stripGlobals(raw)
160 const head = tokens[0]?.split('/').pop()
161 if (head === 'git') {
162 const i = gitVerb(tokens)
163 const verb = tokens[i] ?? ''
164 const rest = tokens.slice(i + 1)
165 if (gitReadOnly(verb, rest)) continue
166 const spec = verb === 'checkout' && rest.includes('--') ? GIT_VERBS.restore : GIT_VERBS[verb]
167 if (spec) out.push({ kind: `git ${verb}`, ...spec })
168 } else if (head === 'gh') {
169 const pair = `${tokens[1] ?? ''} ${tokens[2] ?? ''}`
170 const spec = GH_VERBS[pair]
171 if (spec) out.push({ kind: `gh ${pair}`, ...spec })
172 }
173 }
174 return out
175}
176
177export const BRANCH_ICON = ICON.branch
178
179const READERS = new Set(['rg', 'grep', 'egrep', 'fgrep', 'find', 'fd', 'fdfind', 'cat', 'head', 'tail', 'bat', 'less', 'more', 'ls', 'eza', 'tree', 'wc', 'sed', 'awk', 'jq'])
180const PATTERN_FIRST = new Set(['rg', 'grep', 'egrep', 'fgrep', 'sed', 'awk', 'jq'])
181const OUTPUT_PATHS = new Set(['rg', 'grep', 'egrep', 'fgrep', 'find', 'fd', 'fdfind'])
182
183function tidy(path: string): string {
184 const s = path.replace(/\/+$/, '')
185 return s === '' ? '/' : /^[A-Za-z]:$/.test(s) && isAbsolute(`${s}/`) ? `${s}/` : s
186}
187
188export function resolve(cwd: string, p: string, home = ''): string {
189 let path = posix(p)
190 if (home && (path === '~' || path.startsWith('~/'))) path = home + path.slice(1)
191 const drive = isAbsolute(path) && !path.startsWith('/') ? path.slice(0, 2) : ''
192 const parts = isAbsolute(path) ? [drive] : posix(cwd).replace(/\/+$/, '').split('/')
193 for (const seg of path.slice(drive.length).split('/')) {
194 if (!seg || seg === '.') continue
195 if (seg === '..') {
196 if (parts.length > 1) parts.pop()
197 } else parts.push(seg)
198 }
199 return tidy(parts.join('/'))
200}
201
202const WRITE_FLAGS = /^-(delete|exec|execdir|ok|okdir|fprint\w*|fls|x|X|-exec|-exec-batch)$/
203
204export function readOnly(command: string): boolean {
205 const bare = ` ${command}`.replace(/\d*>&\d/g, ' ').replace(/(\d*|&)>>?\s*\/dev\/null/g, ' ')
206 if (/[^>]>[^>&]|>>/.test(bare)) return false
207 return segments(command).every(raw => {
208 const tokens = stripGlobals(raw)
209 const head = tokens[0]?.split('/').pop() ?? ''
210 if (head === 'cd' || head === 'echo' || head === 'printf' || head === 'true' || head === 'pwd') return true
211 if (head === 'git') {
212 const i = gitVerb(tokens)
213 return gitReadOnly(tokens[i] ?? '', tokens.slice(i + 1))
214 }
215 if (head === 'sed') return !tokens.some(t => /^-i/.test(t))
216 if (head === 'find' || head === 'fd' || head === 'fdfind') return !tokens.some(t => WRITE_FLAGS.test(t))
217 return READERS.has(head)
218 })
219}
220
221export function readTargets(command: string, sessionCwd: string, stdout: string, home = ''): string[] {
222 let cwd = sessionCwd
223 const out = new Set<string>()
224 let listsPaths = false
225 for (const raw of segments(command)) {
226 const tokens = stripGlobals(raw).map(t => posix(t.replace(/^["']|["']$/g, '')))
227 const head = tokens[0]?.split('/').pop() ?? ''
228 if (head === 'cd' && tokens[1]) {
229 cwd = resolve(cwd, tokens[1], home)
230 continue
231 }
232 if (!READERS.has(head)) continue
233 if (head === 'sed' && tokens.some(t => /^-i/.test(t))) continue
234 if (OUTPUT_PATHS.has(head)) listsPaths = true
235 const args = tokens.slice(1)
236 let skipPattern = PATTERN_FIRST.has(head) && !args.some(t => t === '-e' || t === '-f' || t === '--files')
237 for (let i = 0; i < args.length; i++) {
238 const a = args[i] ?? ''
239 if (a.startsWith('-')) {
240 if (/^-(e|f|g|t|T|m|A|B|C|-glob|-type|-max-count|name|iname|maxdepth|mindepth)$/.test(a)) i++
241 continue
242 }
243 if (skipPattern) {
244 skipPattern = false
245 continue
246 }
247 if (/[*?<>|]/.test(a)) continue
248 out.add(resolve(cwd, a, home))
249 }
250 }
251 if (listsPaths) {
252 for (const line of stdout.split('\n').slice(0, 400)) {
253 const path = posix(line).match(drivesOn() ? /^((?:[A-Za-z]:)?[^:\0]+?)(?::\d+[:-]|$|:)/ : /^([^:\0]+?)(?::\d+[:-]|$|:)/)?.[1]?.trim()
254 if (path && !path.includes(' ')) out.add(resolve(cwd, path, home))
255 }
256 }
257 return [...out].slice(0, 60)
258}
259hooks/rows.tsx 165 lines1// Changed in this fork (awesome-claude) for the kit's terminal look: colours are the
2// kit's terminal inks (theme keys or dim), and the shimmer is a bold band sweeping
3// through a row drawn in its activity's colour, not a hex gradient. The cursor row is
4// the kit's selected list row: a `▌` gutter mark in `suggestion` (dim while the pane
5// doesn't have the keyboard), never a background, which no fixed hex reads on every theme.
6// The scrollbar is only its thumb, dim.
7import type { ClientModule } from 'claude-code'
8
9/**
10 * One run of a row: `c` a colour (an engine theme key, never a hex: the kit's
11 * `termInk` gives it), `dc` the terminal's dim, `sh` a shimmer tone (a key of
12 * `tones`), `dim` the faint flash.
13 */
14export type Seg = { t: string; c?: string; dc?: boolean; b?: boolean; s?: boolean; i?: boolean; sh?: string; dim?: boolean; one?: boolean; spin?: boolean; tab?: string }
15export type RowSpec = { id: string; left: Seg[]; right: Seg[] }
16export type RowsProps = {
17 rows: RowSpec[]
18 /** The cursor row's id: its one-cell gutter holds `mark`. */
19 active: string
20 /** The kit's selected-row mark (`MARK.selected`) and its colour (a theme key). */
21 mark: string
22 markColor?: string
23 /** Whether the pane has the keyboard: the mark is dim while it hasn't. */
24 focused: boolean
25 /** Each shimmer tone's colour on the terminal (a theme key, from the kit's `termInk`). */
26 tones: Record<string, string>
27 spinner?: string[]
28 pointer?: boolean
29 /** The scrollbar: its thumb a dim `┃`, no track (a full-height rule beside the names read as a border). */
30 bar?: { pos: number; size: number }
31 preview?: { text: string; path: string; startLine: number; markdown: boolean }
32}
33type Local = { phase: number; drag: boolean; ref: { stop?: () => void; unpoint?: () => void } }
34
35const TICK_MS = 90
36const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
37
38/** Whether letter `i` of `len` sits in the band the shimmer sweeps across a name. */
39function inBand(i: number, phase: number, len: number): boolean {
40 const band = ((phase * 1.6) % (len + 8)) - 4
41 return Math.abs(i - band) < 1.8
42}
43
44const Rows: ClientModule<RowsProps, Local> = (props, surface) => {
45 const { Box, Text } = surface.elements
46 let state = surface.state
47 if (state === undefined) {
48 state = { phase: 0, drag: false, ref: {} }
49 surface.setState(state)
50 }
51 const lit = props.rows.some(r => r.left.some(s => s.sh || s.spin) || r.right.some(s => s.sh || s.spin))
52 if (lit && !state.ref.stop) {
53 state.ref.stop = surface.every(TICK_MS, () => {
54 const cur = surface.state
55 if (cur) surface.setState({ ...cur, phase: cur.phase + 1 })
56 })
57 } else if (!lit && state.ref.stop) {
58 state.ref.stop()
59 state.ref.stop = undefined
60 }
61 if (props.pointer === false) {
62 state.ref.unpoint?.()
63 state.ref.unpoint = undefined
64 } else state.ref.unpoint = surface.onPointer(e => {
65 const cur = surface.state ?? state
66 const span = Math.max(1, props.rows.length - 1)
67 const onBar = Boolean(props.bar) && e.x >= surface.columns - 1
68 if (props.bar && (cur.drag || (onBar && e.type === 'down' && (e.button ?? 'left') === 'left'))) {
69 if (e.type === 'up' || e.type === 'leave') {
70 surface.setState({ ...cur, drag: false })
71 return
72 }
73 if (e.type === 'down' || e.type === 'move') {
74 if (!cur.drag) surface.setState({ ...cur, drag: true })
75 surface.post({ scrollTo: Math.max(0, Math.min(1, e.y / span)) })
76 return
77 }
78 }
79 if (e.type === 'leave' || e.y < 0 || e.y >= props.rows.length) return
80 const row = props.rows[e.y]
81 if (e.type === 'down' && e.button === 'right' && row?.id) {
82 surface.post({ copy: row.id, shift: Boolean(e.shift) })
83 return
84 }
85 if (e.type !== 'down' || (e.button ?? 'left') !== 'left' || !row) return
86 // After the one-cell gutter.
87 let x = 1
88 for (const seg of row.left) {
89 const w = [...seg.t].length
90 if (seg.tab && e.x >= x && e.x < x + w) {
91 surface.post({ tab: seg.tab })
92 return
93 }
94 x += w
95 }
96 if (row.id) surface.post({ press: row.id, ctrl: Boolean(e.ctrl), shift: Boolean(e.shift) })
97 })
98 surface.onKey(e => surface.post({ key: e.key, ctrl: Boolean(e.ctrl), shift: Boolean(e.shift) }))
99 // The preview draws in this same element, so the focus a click gave the tree stays put.
100 if (props.preview) {
101 const { Markdown, Code } = surface.elements
102 const p = props.preview
103 return p.markdown ? <Markdown text={p.text} /> : <Code source={p.text} path={p.path} startLine={p.startLine} wrap="truncate-end" />
104 }
105 const frames = props.spinner?.length ? props.spinner : FRAMES
106 const draw = (s: Seg) => {
107 if (s.spin) {
108 return (
109 <Text color={s.c} bold={s.b}>
110 {s.t + (frames[state.phase % frames.length] ?? '')}
111 </Text>
112 )
113 }
114 const tone = s.sh ? props.tones[s.sh] : undefined
115 if (!tone) {
116 return (
117 <Text color={s.c} dimColor={s.dc} bold={s.b} strikethrough={s.s} italic={s.i}>
118 {s.t}
119 </Text>
120 )
121 }
122 // The faint flash (files Claude touched a moment ago) stays dim and still.
123 if (s.one || s.dim) {
124 return (
125 <Text color={tone} dimColor={s.dim} bold={s.b}>
126 {s.t}
127 </Text>
128 )
129 }
130 const chars = [...s.t]
131 return (
132 <Text color={tone}>
133 {chars.map((ch, i) => (
134 <Text bold={s.b || inBand(i, state.phase, chars.length)}>{ch}</Text>
135 ))}
136 </Text>
137 )
138 }
139 return (
140 <Box flexDirection="column">
141 {props.rows.map((r, i) => (
142 <Box flexDirection="row" height={1} overflow="hidden">
143 <Box width={1} flexShrink={0}>
144 {r.id && r.id === props.active ? (
145 <Text color={props.markColor} dimColor={!props.focused || undefined}>
146 {props.mark}
147 </Text>
148 ) : (
149 <Text> </Text>
150 )}
151 </Box>
152 <Box flexShrink={1} overflow="hidden">
153 {r.left.map(draw)}
154 </Box>
155 <Box flexGrow={1} />
156 {r.right.map(draw)}
157 {props.bar && <Text dimColor>{i >= props.bar.pos && i < props.bar.pos + props.bar.size ? ' ┃' : ' '}</Text>}
158 </Box>
159 ))}
160 </Box>
161 )
162}
163
164export default Rows
165hooks/icons.ts 127 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
117// Changed in this fork (awesome-claude): no git status hex palette here. A status
118// draws in the kit's colour for it (`statusColor` in desktop.tsx), a theme key on
119// the terminal through the kit's `termInk`.
120
121const PRIORITY = ['U', 'D', 'M', 'T', 'R', 'C', 'A', '?']
122
123export function stronger(a: string | undefined, b: string): string {
124 if (!a) return b
125 return PRIORITY.indexOf(b) < PRIORITY.indexOf(a) ? b : a
126}
127hooks/tree.ts 349 lines1import type { Branch, FileNode, FileTree } 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 function emptyTree(root: string): FileTree {
10 return {
11 root,
12 nodes: [],
13 expanded: [],
14 cursor: '',
15 selected: '',
16 query: '',
17 showHidden: true,
18 showSize: false,
19 dirSizes: {},
20 git: {},
21 diff: {},
22 ignored: [],
23 untrackedDirs: [],
24 top: '',
25 prefix: '',
26 branch: null,
27 counts: {},
28 flash: [],
29 flashDim: [],
30 flashOn: false,
31 flashTones: {},
32 scroll: null,
33 preview: '',
34 previewTop: 0,
35 }
36}
37
38export function join(dir: string, name: string): string {
39 return dir.endsWith('/') ? dir + name : `${dir}/${name}`
40}
41
42let drives = false
43
44export function dirname(path: string): string {
45 if (drives && /^[A-Za-z]:\/[^/]*$/.test(path)) return path.slice(0, 3)
46 const i = path.lastIndexOf('/')
47 return i <= 0 ? '/' : path.slice(0, i)
48}
49
50export function useDrives(on: boolean): void {
51 drives = on
52}
53
54export function drivesOn(): boolean {
55 return drives
56}
57
58export function posix(path: string): string {
59 if (!drives) return path
60 return path.replace(/\\/g, '/').replace(/^\/([A-Za-z])(\/|$)/, (_, d: string) => `${d.toUpperCase()}:/`)
61}
62
63export function dropBelow(nodes: FileNode[], dirs: string[]): FileNode[] {
64 if (dirs.length === 0) return nodes
65 const under = (id: string) => dirs.some(d => id !== d && inside(d, id))
66 const gone = new Set(dirs)
67 return nodes.filter(n => !under(n.id)).map(n => (gone.has(n.id) && n.loaded ? { ...n, loaded: false } : n))
68}
69
70export function isAbsolute(path: string): boolean {
71 return path.startsWith('/') || (drives && /^[A-Za-z]:\//.test(path))
72}
73
74export function inside(root: string, path: string): boolean {
75 return path === root || path.startsWith(root.endsWith('/') ? root : `${root}/`)
76}
77
78export function toNodes(dir: string, list: Entry[]): FileNode[] {
79 return list
80 .map(e => ({
81 id: join(dir, e.name),
82 parent: dir,
83 name: e.name,
84 kind: e.isLink ? ('link' as const) : e.kind === 'dir' ? ('dir' as const) : ('file' as const),
85 hidden: e.name.startsWith('.'),
86 mtime: e.mtimeMs,
87 size: e.size,
88 loaded: false,
89 }))
90 .sort((a, b) => (a.kind === 'dir' ? 0 : 1) - (b.kind === 'dir' ? 0 : 1) || collator.compare(a.name, b.name))
91}
92
93export function replaceChildren(nodes: FileNode[], listed: Map<string, FileNode[]>): FileNode[] {
94 if (listed.size === 0) return nodes
95 const keep = new Map<string, FileNode>()
96 for (const n of nodes) if (listed.has(n.parent)) keep.set(n.id, n)
97 const freshIds = new Set<string>()
98 for (const kids of listed.values()) for (const k of kids) freshIds.add(k.id)
99 const gone = new Set([...keep.keys()].filter(id => !freshIds.has(id)))
100 const under = (id: string) => {
101 if (gone.size === 0) return false
102 for (let dir = dirname(id), prev = id; dir !== prev; prev = dir, dir = dirname(dir)) if (gone.has(dir)) return true
103 return false
104 }
105 const fresh: FileNode[] = []
106 for (const kids of listed.values()) {
107 for (const k of kids) {
108 if (under(k.id)) continue
109 const old = keep.get(k.id)
110 fresh.push(old && old.kind === 'dir' && k.kind === 'dir' ? { ...k, loaded: old.loaded } : k)
111 }
112 }
113 const out = nodes.filter(n => !listed.has(n.parent) && !under(n.id))
114 return [...out.map(n => (listed.has(n.id) ? { ...n, loaded: true } : n)), ...fresh]
115}
116
117export type GitStatus = {
118 git: Record<string, string>
119 ignored: string[]
120 untrackedDirs: string[]
121 branch: Branch | null
122 files: Record<string, Change>
123}
124
125export type Change = 'new' | 'mod' | 'del'
126
127export function parseBranch(record: string): Branch {
128 const body = record.replace(/^## /, '')
129 if (body.startsWith('HEAD (no branch)')) return { head: 'detached', upstream: '', host: '', ahead: 0, behind: 0 }
130 const head = body.match(/^(?:No commits yet on |Initial commit on )?(.+?)(?:\.\.\.|\s|$)/)?.[1] ?? body
131 const upstream = body.match(/\.\.\.(\S+)/)?.[1] ?? ''
132 const ahead = Number(body.match(/ahead (\d+)/)?.[1] ?? 0)
133 const behind = Number(body.match(/behind (\d+)/)?.[1] ?? 0)
134 return { head, upstream, host: '', ahead, behind }
135}
136
137/** The remote an upstream lives on: `origin` of `origin/feat/x`. */
138export function remoteName(upstream: string): string {
139 const slash = upstream.indexOf('/')
140 return slash > 0 ? upstream.slice(0, slash) : ''
141}
142
143/**
144 * A remote URL's host, lowercased: `https://github.com/o/r.git`,
145 * `ssh://git@host:22/o/r`, scp-like `git@gitlab.com:o/r.git`. Empty for a local
146 * path or anything else without one.
147 */
148export function remoteHost(url: string): string {
149 const u = url.trim()
150 const full = u.match(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]*@)?(\[[^\]]+\]|[^/:?#]+)/i)
151 if (full) return (full[1] ?? '').toLowerCase()
152 const scp = u.match(/^(?:[^@/]+@)?([^/:]+):(?!\/)/)
153 return scp ? (scp[1] ?? '').toLowerCase() : ''
154}
155
156/** Where an upstream lives, by its remote's host. */
157export type HostKind = 'github' | 'gitlab' | 'forgejo' | 'other'
158
159/** GitHub by its host, GitLab and Forgejo by theirs or a host that names them (a self-hosted `gitlab.corp`). */
160export function hostKind(host: string): HostKind {
161 const h = host.toLowerCase()
162 if (h === 'github.com' || h.endsWith('.github.com')) return 'github'
163 if (h.includes('gitlab')) return 'gitlab'
164 if (h.includes('codeberg') || h.includes('forgejo')) return 'forgejo'
165 return 'other'
166}
167
168/** Each host's icon (kit/icons.ts): GitHub's mark, GitLab's, Forgejo's, else a generic remote. */
169export const HOST_ICON: Record<HostKind, string> = { github: 'mark-github', gitlab: 'gitlab', forgejo: 'forgejo', other: 'cloud' }
170
171/** Each host's mark on the terminal, which has no glyph for them: two letters, or ⇅ for any other remote. */
172export const HOST_MARK: Record<HostKind, string> = { github: 'gh', gitlab: 'gl', forgejo: 'fj', other: '⇅' }
173
174export function mapper(root: string, prefix: string): (rel: string) => string | null {
175 return rel => (rel.startsWith(prefix) ? join(root, rel.slice(prefix.length).replace(/\/$/, '')).replace(/\/$/, '') : null)
176}
177
178export function parseGit(stdout: string, root: string, prefix: string): GitStatus {
179 const map = mapper(root, prefix)
180 const git: Record<string, string> = {}
181 const ignored: string[] = []
182 const untrackedDirs: string[] = []
183 const files: Record<string, Change> = {}
184 let branch: Branch | null = null
185 const parts = stdout.split('\0')
186 for (let i = 0; i < parts.length; i++) {
187 const rec = parts[i] ?? ''
188 if (rec.startsWith('## ')) {
189 branch = parseBranch(rec)
190 continue
191 }
192 if (rec.length < 4) continue
193 const xy = rec.slice(0, 2)
194 const raw = rec.slice(3)
195 if (xy[0] === 'R' || xy[0] === 'C') i++
196 const path = map(raw)
197 if (!path) continue
198 if (xy === '!!') {
199 ignored.push(path)
200 continue
201 }
202 if (xy === '??' && raw.endsWith('/')) untrackedDirs.push(path)
203 else files[path] = xy === '??' || (xy.includes('A') && !xy.includes('D')) ? 'new' : xy.includes('D') && !/U/.test(xy) ? 'del' : 'mod'
204 const letter =
205 xy === '??'
206 ? '?'
207 : /U/.test(xy) || xy === 'AA' || xy === 'DD'
208 ? 'U'
209 : (['D', 'M', 'R', 'C', 'A', 'T'].find(c => xy.includes(c)) ?? '')
210 if (!letter) continue
211 git[path] = stronger(git[path], letter)
212 let dir = dirname(path)
213 while (dir.length >= root.length && dir !== '/') {
214 git[dir] = stronger(git[dir], letter)
215 if (dir === root) break
216 dir = dirname(dir)
217 }
218 }
219 return { git, ignored, untrackedDirs, branch, files }
220}
221
222export function parseNumstat(stdout: string, root: string, prefix: string, into: Record<string, [number, number]>): void {
223 const map = mapper(root, prefix)
224 const parts = stdout.split('\0')
225 for (let i = 0; i < parts.length; i++) {
226 const rec = parts[i] ?? ''
227 if (!rec) continue
228 const m = rec.match(/^(-|\d+)\t(-|\d+)\t(.*)$/s)
229 if (!m) continue
230 let path = m[3] ?? ''
231 if (path === '') {
232 i += 2
233 path = parts[i] ?? ''
234 }
235 if (!path) continue
236 const add = m[1] === '-' ? 0 : Number(m[1])
237 const del = m[2] === '-' ? 0 : Number(m[2])
238 const abs = map(path)
239 if (!abs) continue
240 const prev = into[abs] ?? [0, 0]
241 into[abs] = [prev[0] + add, prev[1] + del]
242 }
243}
244
245export function rollUp(diff: Record<string, [number, number]>, root: string): Record<string, [number, number]> {
246 const out: Record<string, [number, number]> = { ...diff }
247 for (const [path, [add, del]] of Object.entries(diff)) {
248 let dir = dirname(path)
249 while (dir.length >= root.length && dir !== '/') {
250 const prev = out[dir] ?? [0, 0]
251 out[dir] = [prev[0] + add, prev[1] + del]
252 if (dir === root) break
253 dir = dirname(dir)
254 }
255 }
256 return out
257}
258
259export function rollCounts(files: Record<string, Change>, root: string): Record<string, [number, number, number]> {
260 const out: Record<string, [number, number, number]> = {}
261 const slot = { new: 0, mod: 1, del: 2 } as const
262 for (const [path, change] of Object.entries(files)) {
263 let dir = dirname(path)
264 while (dir.length >= root.length && dir !== '/') {
265 const cur = out[dir] ?? [0, 0, 0]
266 cur[slot[change]] += 1
267 out[dir] = cur
268 if (dir === root) break
269 dir = dirname(dir)
270 }
271 }
272 return out
273}
274
275export function underAny(id: string, set: Set<string>, root: string): boolean {
276 if (set.size === 0) return false
277 let cur = id
278 while (cur.length >= root.length) {
279 if (set.has(cur)) return true
280 const up = dirname(cur)
281 if (up === cur) break
282 cur = up
283 }
284 return false
285}
286
287export function ancestorsOf(id: string, root: string): string[] {
288 const out: string[] = []
289 if (!root) return out
290 for (let dir = dirname(id), prev = id; dir !== prev && dir !== root && inside(root, dir); prev = dir, dir = dirname(dir)) out.push(dir)
291 return out
292}
293
294export function relative(root: string, path: string): string {
295 return path === root ? '' : path.slice(root.endsWith('/') ? root.length : root.length + 1)
296}
297
298export function visibleRows(t: FileTree): Row[] {
299 const kids = new Map<string, FileNode[]>()
300 for (const n of t.nodes) {
301 if (!t.showHidden && n.hidden) continue
302 const list = kids.get(n.parent)
303 if (list) list.push(n)
304 else kids.set(n.parent, [n])
305 }
306 const q = t.query.trim().toLowerCase()
307 let keep: Set<string> | null = null
308 if (q) {
309 keep = new Set()
310 for (const n of t.nodes) {
311 if (!relative(t.root, n.id).toLowerCase().includes(q)) continue
312 keep.add(n.id)
313 for (const a of ancestorsOf(n.id, t.root)) keep.add(a)
314 }
315 }
316 const open = new Set(t.expanded)
317 const rows: Row[] = []
318 const walk = (dir: string, depth: number) => {
319 for (const n of kids.get(dir) ?? []) {
320 if (keep && !keep.has(n.id)) continue
321 const isOpen = n.kind === 'dir' && (keep ? kids.has(n.id) : open.has(n.id))
322 rows.push({ node: n, depth, open: isOpen })
323 if (isOpen) walk(n.id, depth + 1)
324 }
325 }
326 walk(t.root, 0)
327 return rows
328}
329
330export function stamp(ms: number): string {
331 if (!ms) return ''
332 const d = new Date(ms)
333 const p = (v: number) => String(v).padStart(2, '0')
334 return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
335}
336
337export function formatSize(bytes: number): string {
338 if (bytes < 0) return '?'
339 if (bytes < 1024) return `${bytes} B`
340 const units = ['K', 'M', 'G', 'T']
341 let v = bytes / 1024
342 let i = 0
343 while (v >= 1024 && i < units.length - 1) {
344 v /= 1024
345 i++
346 }
347 return `${v < 100 ? v.toFixed(1) : Math.round(v)} ${units[i]}`
348}
349kit/skin.ts 39 lines1/**
2 * A skin: the colours the kit draws with on the app surfaces (desktop, VS Code, mobile), one per
3 * meaning (see kit/tokens.ts). Each zen theme pack gives one (kit/skins.ts, made from the pack's
4 * palette by zen's `src/harness/claude-code/skins.ts`); a mod wears one with `wear()`.
5 *
6 * The terminal never draws these values: it draws each meaning in the terminal theme key that
7 * means the same thing (THEME_KEY, TERM_TINT in kit/tokens.ts), whatever the skin, so a mod
8 * follows the person's terminal theme.
9 *
10 * Every value is #rrggbb, and every value is its meaning's own: no two meanings share a value,
11 * as the terminal finds a meaning by its value. Greys may share one another's.
12 */
13export type Skin = {
14 /** The pack's name, as zen's toolbar shows it. */
15 readonly label: string
16 /** Finished well: checks passed, an agent done. */
17 readonly ok: string
18 /** Getting close to a limit: 75% and up, the compaction mark. */
19 readonly warn: string
20 /** Failed, conflicted, 90% and up. */
21 readonly bad: string
22 /** Live or in progress: running, pending, the activity series. */
23 readonly blue: string
24 /** Merged. */
25 readonly purple: string
26 /** A tint for parts within one chart, never a status. */
27 readonly cyan: string
28 /** Quantity ink: what a bar or gauge is drawn in when it only says how much. */
29 readonly slate: string
30 /** A tint for parts within one chart, never a status. */
31 readonly orchid: string
32 /** Shades within one chart: the live colour and the quantity ink, each base first, then darker, lighter, darkest. */
33 readonly ramp: { readonly blue: readonly [string, string, string, string]; readonly slate: readonly [string, string, string, string] }
34 /** Text, icons and marks that should recede. */
35 readonly muted: string
36 /** Text below the main text, each a step quieter. */
37 readonly gray: { readonly light: string; readonly mid: string; readonly dark: string }
38}
39kit/skins.ts 261 lines1// Every zen theme pack's skin for the Claude Code kit, by pack id: made from the pack's palette by
2// zen's src/harness/claude-code/skins.ts. Generated; don't edit it, run `npx tsx scripts/harness-skins.ts` in zen.
3import type { Skin } from './skin'
4
5export const SKINS = {
6 "yahaha-elektron": {
7 label: "yahaha × Elektron",
8 ok: "#5fdc4a",
9 warn: "#ffd60a",
10 bad: "#e5534b",
11 blue: "#3b8eff",
12 purple: "#a58cff",
13 cyan: "#3ff4ff",
14 slate: "#afc4e0",
15 orchid: "#f57aff",
16 ramp: {
17 blue: [
18 "#3b8eff",
19 "#2c6bbf",
20 "#80b6ff",
21 "#204e8c"
22 ],
23 slate: [
24 "#afc4e0",
25 "#8393a8",
26 "#cbd9eb",
27 "#606c7b"
28 ]
29 },
30 muted: "#8c8c8c",
31 gray: {
32 light: "#d6d6d6",
33 mid: "#8c8c8c",
34 dark: "#4d4d4d"
35 }
36 },
37 "yahaha-classic": {
38 label: "yahaha classic",
39 ok: "#02c31e",
40 warn: "#ffeb44",
41 bad: "#fe4e66",
42 blue: "#4cc2ff",
43 purple: "#b98cff",
44 cyan: "#08b7aa",
45 slate: "#87aac2",
46 orchid: "#ff6bd0",
47 ramp: {
48 blue: [
49 "#4cc2ff",
50 "#3992bf",
51 "#8bd7ff",
52 "#2a6b8c"
53 ],
54 slate: [
55 "#87aac2",
56 "#658092",
57 "#b1c8d7",
58 "#4a5e6b"
59 ]
60 },
61 muted: "#8a929d",
62 gray: {
63 light: "#9aa2ad",
64 mid: "#8a929d",
65 dark: "#555c66"
66 }
67 },
68 "yahaha-push": {
69 label: "yahaha Push (as designed)",
70 ok: "#5fdc4a",
71 warn: "#ffd60a",
72 bad: "#e5534b",
73 blue: "#3b8eff",
74 purple: "#a58cff",
75 cyan: "#3ff4ff",
76 slate: "#afc4e0",
77 orchid: "#f57aff",
78 ramp: {
79 blue: [
80 "#3b8eff",
81 "#2c6bbf",
82 "#80b6ff",
83 "#204e8c"
84 ],
85 slate: [
86 "#afc4e0",
87 "#8393a8",
88 "#cbd9eb",
89 "#606c7b"
90 ]
91 },
92 muted: "#8c8c8c",
93 gray: {
94 light: "#d6d6d6",
95 mid: "#8c8c8c",
96 dark: "#4d4d4d"
97 }
98 },
99 visuals: {
100 label: "visual[flow] (glass)",
101 ok: "#51cd85",
102 warn: "#f0b23c",
103 bad: "#e0483f",
104 blue: "#7c86ff",
105 purple: "#c86ef0",
106 cyan: "#3ff4ff",
107 slate: "#a8abce",
108 orchid: "#f2659f",
109 ramp: {
110 blue: [
111 "#7c86ff",
112 "#5d65bf",
113 "#aab0ff",
114 "#444a8c"
115 ],
116 slate: [
117 "#a8abce",
118 "#7e809b",
119 "#c6c8df",
120 "#5c5e71"
121 ]
122 },
123 muted: "#a4a4ac",
124 gray: {
125 light: "#b7b7be",
126 mid: "#a4a4ac",
127 dark: "#5e5e66"
128 }
129 },
130 elektron: {
131 label: "Elektron",
132 ok: "#3ddc84",
133 warn: "#ff8c1a",
134 bad: "#ff4d4d",
135 blue: "#3d6bff",
136 purple: "#b48cff",
137 cyan: "#00d1ff",
138 slate: "#c2cdf5",
139 orchid: "#ff3dbb",
140 ramp: {
141 blue: [
142 "#3d6bff",
143 "#2e50bf",
144 "#819fff",
145 "#223b8c"
146 ],
147 slate: [
148 "#c2cdf5",
149 "#929ab8",
150 "#d7dff9",
151 "#6b7187"
152 ]
153 },
154 muted: "#b4b4b8",
155 gray: {
156 light: "#eeeef2",
157 mid: "#b4b4b8",
158 dark: "#6a6a6a"
159 }
160 },
161 ableton: {
162 label: "Ableton",
163 ok: "#36b958",
164 warn: "#ffe630",
165 bad: "#ff3254",
166 blue: "#4d4dff",
167 purple: "#8a8aff",
168 cyan: "#018fd2",
169 slate: "#d3d3ff",
170 orchid: "#db77ff",
171 ramp: {
172 blue: [
173 "#4d4dff",
174 "#3a3abf",
175 "#8b8bff",
176 "#2a2a8c"
177 ],
178 slate: [
179 "#d3d3ff",
180 "#9e9ebf",
181 "#e2e2ff",
182 "#74748c"
183 ]
184 },
185 muted: "#aaaaaa",
186 gray: {
187 light: "#ffffff",
188 mid: "#aaaaaa",
189 dark: "#666666"
190 }
191 },
192 linear: {
193 label: "Linear",
194 ok: "#86e08a",
195 warn: "#f4c430",
196 bad: "#eb5757",
197 blue: "#5e6ad2",
198 purple: "#bb87fc",
199 cyan: "#02b8cc",
200 slate: "#b4bbdd",
201 orchid: "#ff7ad9",
202 ramp: {
203 blue: [
204 "#5e6ad2",
205 "#47509e",
206 "#969ee2",
207 "#343a74"
208 ],
209 slate: [
210 "#b4bbdd",
211 "#878ca6",
212 "#ced3e9",
213 "#63677a"
214 ]
215 },
216 muted: "#8a8f98",
217 gray: {
218 light: "#d0d6e0",
219 mid: "#8a8f98",
220 dark: "#62666d"
221 }
222 },
223 "claude-code": {
224 label: "Claude Code",
225 ok: "#5bc08a",
226 warn: "#e3a33b",
227 bad: "#e5636f",
228 blue: "#6c8ef5",
229 purple: "#9d85e8",
230 cyan: "#4fb3c8",
231 slate: "#8c9bb5",
232 orchid: "#c58ad6",
233 ramp: {
234 blue: [
235 "#6c8ef5",
236 "#4c68c9",
237 "#a9bbf9",
238 "#38509e"
239 ],
240 slate: [
241 "#8c9bb5",
242 "#6f7c93",
243 "#a7b2c6",
244 "#5b667a"
245 ]
246 },
247 muted: "#8e8e93",
248 gray: {
249 light: "#b9b9b6",
250 mid: "#8a8a87",
251 dark: "#62625f"
252 }
253 }
254} as const satisfies Record<string, Skin>
255
256/** A pack's id: the skins a mod can wear. */
257export type SkinId = keyof typeof SKINS
258
259/** The skin the kit starts in: awesome-claude's look. */
260export const DEFAULT_SKIN: SkinId = 'claude-code'
261