A sidebar tree of the files Claude touched this session, marked read, edited, written or created, with the current one lit while Claude works. A replay steps…

<!-- mod-file-tree / README.md, by v-amorim --> <!-- Generated from the root README, plugin.json and showcase.json; edit those. -->
Part of [moonlight-claude-mods][doc-root].
The files Claude read and changed, with a replay of each edit.
Inside Claude Code:
/plugin marketplace add v-amorim/moonlight-claude-mods
/plugin install mod-file-tree@claude-mods
/reload-plugins
/plugin install asks where to install the mod. Keep Install for you (user scope) and press Enter, then press Esc to leave the plugin screen before /reload-plugins.
To install every mod at once, see [Install][doc-install]. For sharper icons, see [Icons and Nerd Fonts][doc-icons].
Files pill.
| The Files panel | The fold toggle |
|---|---|
| ![][img-file-tree-panel] | ![][img-file-tree-folded] |
| Live while Claude works |
|---|
| https://github.com/user-attachments/assets/92fb4b27-e977-4ea1-a553-32bc9e695040 |
~/.claude/mods-data/mod-file-tree/bridge/<session id>.jsonl (under $CLAUDE_CONFIG_DIR when that is set), and the mod reads the file every 250 ms. A record can hold the session id, the working folder and the transcript path. SessionEnd deletes the session's file, and SessionStart deletes files older than 7 days. The hooks need a POSIX shell (Git Bash on Windows). Without one they write nothing, and the mod falls back to what it can find on its own. Some plans block plugin hooks on these events; the mod then falls back the same way.Change them with /mod-file-tree settings or the gear pill.
| Setting | Default | What it does |
|---|---|---|
| Animations | on | Entries shimmer while they wait. Hover colors also fade. Off, everything draws still |
| Icons | auto | auto picks Nerd Font or safe glyphs. It uses Nerd Font icons when the terminal's font is a Nerd Font (read from Windows Terminal, iTerm2, VS Code, Ghostty, kitty, WezTerm, Alacritty, GNOME Terminal or Konsole) and safe Unicode glyphs elsewhere. nerd and safe force one |
| Show files Claude only read | on | Read files join the changed ones. Off, it lists only changed files |
| Wrap long diff lines | on | Replay lines wrap onto the next row. Off, they are cut at the pane edge |
| Recent view (turns) | 5 | How many latest turns to cover. The middle view shows them, from 2 to 20 |
Made by [Vinicius Amorim][doc-author], [MIT license][doc-license].
[doc-author]: https://github.com/v-amorim [doc-icons]: https://github.com/v-amorim/moonlight-claude-mods/blob/main/README.md#icons-and-nerd-fonts [doc-install]: https://github.com/v-amorim/moonlight-claude-mods/blob/main/README.md#install [doc-license]: https://github.com/v-amorim/moonlight-claude-mods/blob/main/LICENSE [doc-root]: https://github.com/v-amorim/moonlight-claude-mods/blob/main/README.md
[img-file-tree-folded]: https://github.com/user-attachments/assets/5c744ca8-4dc1-476c-9405-2cd6d3a618a6 [img-file-tree-panel]: https://github.com/user-attachments/assets/6955de1f-4221-4e34-b8f9-205b32caa154
hooks/register.tsx 1032 lines1// mod-file-tree / hooks/register.tsx, by v-amorim
2// Registers the files sidebar: pane, pill, command, toasts and settings wiring with the engine.
3import type { ConfigValue, EngineInterface, Register } from 'claude-code'
4
5import { bridgeOn, endBridge, startBridge, type BridgeRecord } from './bridge'
6import { NAME_COLOR, OPEN_CELLS, type ListMessage, type Row as ListRow } from './list'
7import { detectNerd, FOOTER_ROWS, lookFor, section, paneFooter, sepText, subpill, withIcon, type IconMode, type Look, withMark } from './look'
8import { isTipMessage, mix, type EntryMessage, type EntryProps, type Tip } from './entry'
9import { labelsOf, menuEntry, pill, pillRow, splitRow, tabRow, tipLine } from './pills'
10import { TOAST_MS, ToastList, toastCards } from './toasts'
11import { choiceBar as sharedChoiceBar, settingChoice, settingsBody, type Action, type Previews, type Range } from './settings'
12import text from './text'
13import { fill, MARK, statusColor, THEME } from './theme'
14import { shellPlans, TranscriptFiles } from './transcript'
15import { cells, cut } from './cells'
16import { commit, diffLines, emptyLog, iconOf, latestStepOf, liveFolds, relPath, rootOf, recentView, rowsOf, stepsOfTurn, touch, turnView, type Entry, type Kind, type Line, type Log } from './tree'
17
18const PANE = 'filetree'
19const SETTINGS = 'filetree-settings'
20
21let showReads = true
22let wrapDiff = true
23let iconMode: IconMode = 'auto'
24let look = lookFor(false)
25
26// The transcript is read once per session start, since a long session's file is large; live calls then update the log. Kept in $.state for the pane and reloads.
27let log: Log | undefined
28let transcript = ''
29// `/mod-file-tree clear` hides what came before it; the transcript would bring it back otherwise.
30let cleared = { seq: 0, step: 0 }
31let scope: 'turn' | 'recent' | 'session' = 'turn'
32let recentTurns = 5
33let mode: 'tree' | 'replay' = 'tree'
34// null follows the latest turn that edited something.
35let replayTurn: number | null = null
36let index = 0
37let collapsed: string[] = []
38let note = ''
39let actions: Action[] = []
40let previews: Previews | undefined
41let fields: Record<string, Range> | undefined
42
43const isOpen = async ($: EngineInterface, id: string) => (await $.ui.panes()).some(pane => pane.id === id && pane.isPlaced)
44
45// The session the log belongs to. A /resume or /clear keeps the process and changes the id, so the old log must never show for the new one.
46let logSession = ''
47
48function startLog(session: string, path = '') {
49 logSession = session
50 transcript = path
51 log = undefined
52 loading = null
53 loadFailed = false
54 loadError = ''
55 changes = []
56 cleared = { seq: 0, step: 0 }
57 replayTurn = null
58 index = 0
59 collapsed = []
60 opened = []
61 note = ''
62 landed.clear()
63}
64
65// Asking the id on every hook would cost a round trip each, so hooks ask after a session.end, or once this long has passed.
66const SESSION_CHECK_MS = 500
67let sessionCheckedAt = 0
68
69async function followSession($: EngineInterface) {
70 const now = Date.now()
71 if (logSession && now - sessionCheckedAt < SESSION_CHECK_MS) return
72 sessionCheckedAt = now
73 const asked = logSession
74 const session = String(await $.session.id().catch(() => logSession))
75 // A SessionStart that switched the log while the id was on its way knows better than this answer.
76 if (session !== logSession && asked === logSession) startLog(session)
77}
78
79// The transcript read in flight, one per session; changes made meanwhile replay onto what it builds, so a live edit is never lost.
80type Loading = { session: string; done: Promise<void> }
81let loading: Loading | null = null
82let changes: ((current: Log) => Log)[] = []
83// A read that failed leaves the log partial, so the next hydrate reads again, keeping the changes made since.
84let loadFailed = false
85// The short reason the last failed read gave, kept until a read succeeds so the empty state can name it.
86let loadError = ''
87let readFailure = ''
88const reasonOf = (error: unknown) => (error instanceof Error ? error.message : String(error)).replace(/\s+/g, ' ').trim().slice(0, 80)
89
90// A hook waits this long at most for the read, so a long session's transcript never holds up the start or the pane.
91const LOAD_WAIT_MS = 300
92
93// A reload empties the module copy and $.state still holds it; only an empty state, or another session's, reads the transcript.
94async function hydrate($: EngineInterface) {
95 await followSession($)
96 if (log && !loadFailed) return log
97 const session = logSession
98 const owner = (await $.state.get({ plugin: 'mod-file-tree', key: 'session' })).value
99 const kept = (await $.state.get({ plugin: 'mod-file-tree', key: 'log' })).value as Log | undefined
100 if (session !== logSession) return log ?? emptyLog()
101 if (kept && owner === session && !loadFailed) {
102 cleared = (await $.state.get({ plugin: 'mod-file-tree', key: 'cleared' })).value ?? cleared
103 log = kept
104 return log
105 }
106 if (!loading) {
107 loadFailed = false
108 const started: Loading = {
109 session,
110 done: load($, session).catch(error => {
111 if (loading !== started) return
112 loading = null
113 loadFailed = true
114 loadError = readFailure || reasonOf(error)
115 }),
116 }
117 loading = started
118 }
119 log = emptyLog()
120 await Promise.race([loading.done, $.clock.sleep(LOAD_WAIT_MS).catch(() => {})])
121 return log ?? emptyLog()
122}
123
124// Every change to the log goes through here, so one made while the transcript loads is replayed onto it.
125async function apply($: EngineInterface, change: (current: Log) => Log) {
126 await hydrate($)
127 log = change(log ?? emptyLog())
128 if (loading || loadFailed) changes.push(change)
129 await save($)
130}
131
132async function save($: EngineInterface) {
133 await $.state.set({ plugin: 'mod-file-tree', key: 'log' }, log ?? emptyLog())
134 await $.state.set({ plugin: 'mod-file-tree', key: 'session' }, logSession)
135 $.ui.invalidate('ui.render')
136}
137
138async function configDir($: EngineInterface) {
139 const set = ((await $.env.get('CLAUDE_CONFIG_DIR')) ?? '').replace(/\\/g, '/').replace(/\/+$/, '')
140 if (set) return set
141 const home = ((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '').replace(/\\/g, '/')
142
143 return `${home}/.claude`
144}
145
146// When no SessionStart named the transcript, it is `<session id>.jsonl` in one of the config folder's projects folders.
147async function findTranscript($: EngineInterface) {
148 const projects = `${await configDir($)}/projects`
149 const file = `${await $.session.id().catch(() => '')}.jsonl`
150 for (const entry of await $.fs.list(projects).catch(() => [])) {
151 const path = `${projects}/${entry.name}/${file}`
152 if (entry.kind === 'dir' && (await $.fs.exists(path).catch(() => false))) return path
153 }
154 return ''
155}
156
157// A transcript path is kept with the time it was named; an older bare string counts as a path with no age yet.
158const pathOf = (kept: unknown) => {
159 const path = typeof kept === 'object' && kept !== null ? (kept as { path?: unknown }).path : kept
160 return typeof path === 'string' ? path : ''
161}
162
163const TRANSCRIPT_KEEP_MS = 30 * 24 * 60 * 60 * 1000
164
165// A bare string from an older version starts its clock now.
166async function pruneTranscripts($: EngineInterface) {
167 try {
168 const now = await $.clock.now()
169 for (const key of await $.store.keys()) {
170 if (!key.startsWith('transcript:')) continue
171 const kept = await $.store.get(key)
172 const at = typeof kept === 'object' && kept !== null ? Number((kept as { at?: unknown }).at) : NaN
173 if (Number.isFinite(at)) {
174 if (at < now - TRANSCRIPT_KEEP_MS) await $.store.delete(key)
175 } else if (typeof kept === 'string') await $.store.set(key, { path: kept, at: now })
176 }
177 } catch {
178 // Old entries stay until the next start.
179 }
180}
181
182// Builds the log from the whole transcript; a long session outgrows $.fs.read's 4 MiB cap, so it then streams through the platform's file printer.
183async function load($: EngineInterface, session: string) {
184 const path = transcript || pathOf(await $.store.get(`transcript:${session}`).catch(() => '')) || (await findTranscript($))
185 if (session !== logSession) return
186 transcript = path
187 let raw = ''
188 readFailure = ''
189 if (path) {
190 raw = String(await $.fs.read(path).catch(error => (readFailure = reasonOf(error), '')))
191 if (!raw) {
192 const isWindows = /^[A-Za-z]:|\\/.test(path)
193 // cmd's type rejects forward slashes.
194 const argv = isWindows ? ['cmd', '/c', 'type', path.replace(/\//g, '\\')] : ['cat', path]
195 for await (const chunk of $.process.spawn({ argv })) {
196 if (chunk.stream === 'stdout') raw += chunk.text
197 }
198 }
199 if (!raw && readFailure) throw new Error(readFailure)
200 }
201 // A switch while the file was read makes it another session's.
202 if (session !== logSession) return
203 const parsed = new TranscriptFiles(String((await $.session.cwd().catch(() => '')) ?? ''))
204 for (const line of raw.split('\n')) if (line.trim()) parsed.feed(line)
205 if (session !== logSession) return
206 log = changes.reduce<Log>((current, change) => change(current), { ...parsed.log, working: false })
207 changes = []
208 loading = null
209 loadError = ''
210 $.ui.invalidate('ui.render')
211 // A pane render can be what started the read, and a render may not write state.
212 $.clock.after(0, () => void save($))
213}
214
215// Only the SessionStart record names the transcript, and the bridge delivers it. A compact keeps the same file and the log already holds it, so only a new transcript is read.
216async function sessionStarted($: EngineInterface, record: BridgeRecord) {
217 const session = String(record.session_id ?? '')
218 const path = String(record.transcript_path ?? '')
219 if (!session || !path) return
220 try {
221 if (session !== logSession || path !== transcript || !log) {
222 startLog(session, path)
223 await $.state.set({ plugin: 'mod-file-tree', key: 'cleared' }, cleared)
224 await $.store.set(`transcript:${session}`, { path, at: await $.clock.now() })
225 // The state may hold another session's log, which this one must not reuse.
226 await $.state.set({ plugin: 'mod-file-tree', key: 'session' }, '')
227 await hydrate($)
228 }
229 } catch {
230 // The tree stays as it was.
231 }
232}
233
234function viewOf(current: Log): Log {
235 const scoped = scope === 'turn' ? turnView(current) : scope === 'recent' ? recentView(current, recentTurns) : current
236 return { ...scoped, files: scoped.files.filter(file => file.seq > cleared.seq), steps: scoped.steps.filter(step => step.id > cleared.step) }
237}
238
239async function detectLook($: EngineInterface) {
240 if (iconMode !== 'auto') return lookFor(iconMode === 'nerd')
241 const env = {
242 WT_SESSION: await $.env.get('WT_SESSION'),
243 TERM_PROGRAM: await $.env.get('TERM_PROGRAM'),
244 ITERM_PROFILE: await $.env.get('ITERM_PROFILE'),
245 KITTY_WINDOW_ID: await $.env.get('KITTY_WINDOW_ID'),
246 ALACRITTY_WINDOW_ID: await $.env.get('ALACRITTY_WINDOW_ID'),
247 GNOME_TERMINAL_SCREEN: await $.env.get('GNOME_TERMINAL_SCREEN'),
248 KONSOLE_VERSION: await $.env.get('KONSOLE_VERSION'),
249 }
250 const host = {
251 localAppData: ((await $.env.get('LOCALAPPDATA')) ?? '').replace(/\\/g, '/'),
252 home: ((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '').replace(/\\/g, '/'),
253 read: (path: string) => $.fs.read(path).then(String),
254 run: async (argv: string[]) => {
255 const result = await $.process.run(argv, { timeoutMs: 5000 })
256 if (result.exitCode !== 0) throw new Error(result.stderr)
257 return result.stdout
258 },
259 }
260 return lookFor(await detectNerd(env, host))
261}
262
263// Reading the terminal's settings takes a while, so on auto the last answer, kept in this mod's store, draws until it returns.
264async function savedLook($: EngineInterface) {
265 if (iconMode !== 'auto') return look
266 try {
267 const nerd = await $.store.get('look-nerd')
268 return typeof nerd === 'boolean' ? lookFor(nerd) : look
269 } catch {
270 return look
271 }
272}
273
274async function saveLook($: EngineInterface) {
275 try {
276 await $.store.set('look-nerd', look.nerd)
277 } catch {
278 // Without a store the mod still draws; it only starts from safe again next time.
279 }
280}
281
282// Hooks of one event run in turn, so the slow font check runs beside them instead of holding up every mod after this one.
283let lookLoad: Promise<void> | null = null
284
285function loadLook($: EngineInterface) {
286 lookLoad ??= (async () => {
287 look = await savedLook($)
288 void detectLook($).then(async found => {
289 const isChanged = found.nerd !== look.nerd
290 look = found
291 await saveLook($)
292 if (isChanged) $.ui.invalidate('ui.render')
293 }).catch(() => {
294 // A failed check keeps the icons drawn now.
295 })
296 })()
297 return lookLoad
298}
299
300// One panel at a time, this mod's two panes included; opening one closes the other.
301async function togglePane($: EngineInterface) {
302 if (await isOpen($, PANE)) {
303 await $.ui.close({ id: PANE })
304 await $.state.set({ plugin: 'mod-file-tree', key: 'pane' }, false)
305 return
306 }
307 if (await isOpen($, SETTINGS)) await $.ui.close({ id: SETTINGS })
308 note = ''
309 await hydrate($)
310 const opened = await $.ui.open({ id: PANE, title: text.pill, closeOnEscape: true })
311 if (!opened.isPlaced) $.ui.toast(fill(text.paneWaiting, { reason: opened.reason }))
312 await $.state.set({ plugin: 'mod-file-tree', key: 'pane' }, opened.isPlaced)
313}
314
315// `/mod-file-tree settings` toggles the panel; an unplaced pane is waiting, not open.
316async function toggleSettings($: EngineInterface) {
317 if (await isOpen($, SETTINGS)) {
318 await $.ui.close({ id: SETTINGS })
319 await $.state.set({ plugin: 'mod-file-tree', key: 'pane' }, false)
320 return
321 }
322 if (await isOpen($, PANE)) await $.ui.close({ id: PANE })
323 note = ''
324 const showcase = await $.fs.read(`${$.plugin.root}/showcase.json`).then(raw => JSON.parse(String(raw))).catch(() => null)
325 previews = showcase?.previews
326 fields = await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`).then(text => JSON.parse(String(text)).userConfig).catch(() => undefined)
327 actions = (Array.isArray(showcase?.actions) ? showcase.actions : []).map((action: Action) => ({ ...action, isLocal: action.command === 'mod-file-tree' }))
328 const opened = await $.ui.open({ id: SETTINGS, title: text.settingsFrame, closeOnEscape: true })
329 if (!opened.isPlaced) $.ui.toast(fill(text.settingsWaiting, { reason: opened.reason }))
330 await $.state.set({ plugin: 'mod-file-tree', key: 'pane' }, opened.isPlaced)
331}
332
333async function clearAll($: EngineInterface) {
334 const current = await hydrate($)
335 cleared = { seq: current.seq, step: current.steps[current.steps.length - 1]?.id ?? 0 }
336 await $.state.set({ plugin: 'mod-file-tree', key: 'cleared' }, cleared)
337 mode = 'tree'
338 replayTurn = null
339 index = 0
340 collapsed = []
341 await save($)
342}
343
344// What `/mod-file-tree <args>` does; the settings panel's own actions call it directly, since $.command.run skips the calling plugin.
345async function answer($: EngineInterface, args: string) {
346 if (args.trim() === 'settings') {
347 await toggleSettings($)
348 return ''
349 }
350 if (args.trim() === 'clear') {
351 await clearAll($)
352 return text.cleared
353 }
354 await togglePane($)
355 return ''
356}
357
358// Runs a command to its end: its exit code, or null and the reason when it could not start.
359async function launch($: EngineInterface, argv: string[]) {
360 try {
361 const result = await $.process.run(argv, { timeoutMs: 15_000 })
362 return { code: result.exitCode as number | null, reason: result.stderr.trim() }
363 } catch (error) {
364 return { code: null, reason: String(error) }
365 }
366}
367
368// Explorer, Finder or the desktop's file manager on a folder, the default app on a file. Only a failed start counts as failing.
369async function openWithSystem($: EngineInterface, isWindows: boolean, native: string) {
370 // A mod's child starts with its window hidden, which Explorer honors, so the shell handler launches it with a normal window; no cmd, so a name's `&` or `%` is never read as a command.
371 if (isWindows) return launch($, ['rundll32.exe', 'url.dll,FileProtocolHandler', native])
372 const opened = await launch($, ['open', native])
373 return opened.code === 0 ? opened : launch($, ['xdg-open', native])
374}
375
376async function nativePath($: EngineInterface, path: string) {
377 const cwd = String((await $.session.cwd().catch(() => '')) ?? '').replace(/\\/g, '/')
378 const full = /^([A-Za-z]:)?\//.test(path) ? path : `${cwd}/${path}`
379 const isWindows = /^[A-Za-z]:/.test(full)
380 return { isWindows, native: isWindows ? full.replace(/\//g, '\\') : full }
381}
382
383async function openFolder($: EngineInterface, path: string, name = path) {
384 const { isWindows, native } = await nativePath($, path)
385 const opened = await openWithSystem($, isWindows, native)
386 note = opened.code === null ? `${MARK.warn} ${fill(text.openFailed, { path: name, reason: opened.reason })}` : `${MARK.ok} ${fill(text.openedFolder, { path: name })}`
387 $.ui.invalidate('ui.render')
388}
389
390// On Windows `code` is a .cmd script that needs a shell, so the installed Code.exe is run directly instead.
391async function codeExe($: EngineInterface) {
392 const local = ((await $.env.get('LOCALAPPDATA')) ?? '').replace(/\\/g, '/')
393 const files = ((await $.env.get('ProgramFiles')) ?? '').replace(/\\/g, '/')
394 const found = [local && `${local}/Programs/Microsoft VS Code/Code.exe`, files && `${files}/Microsoft VS Code/Code.exe`]
395 for (const path of found) if (path && (await $.fs.exists(path).catch(() => false))) return path.replace(/\//g, '\\')
396 return ''
397}
398
399// The default app runs these instead of showing them, and a model wrote the name.
400const RUNNABLE = /\.(exe|bat|cmd|com|ps1|psm1|vbs|vbe|js|jse|wsf|wsh|msi|msp|lnk|scr|pif|hta|cpl|reg|jar|app|command|sh|tool)$/i
401
402async function openFile($: EngineInterface, path: string) {
403 const { isWindows, native } = await nativePath($, path)
404 const editor = isWindows ? await codeExe($) : 'code'
405 if (editor && (await launch($, [editor, native])).code === 0) {
406 note = `${MARK.ok} ${fill(text.openedInCode, { path })}`
407 } else if (RUNNABLE.test(native)) {
408 const folder = native.slice(0, Math.max(native.lastIndexOf('\\'), native.lastIndexOf('/'))) || native
409 const opened = await openWithSystem($, isWindows, folder)
410 note = opened.code === null ? `${MARK.warn} ${fill(text.openFailed, { path, reason: opened.reason })}` : `${MARK.ok} ${fill(text.openedRunnable, { path })}`
411 } else {
412 const opened = await openWithSystem($, isWindows, native)
413 note = opened.code === null ? `${MARK.warn} ${fill(text.openFailed, { path, reason: opened.reason })}` : `${MARK.ok} ${fill(text.opened, { path })}`
414 }
415 $.ui.invalidate('ui.render')
416}
417
418async function openDiff($: EngineInterface, path: string) {
419 const current = viewOf(await hydrate($))
420 const step = latestStepOf(current, path)
421 if (!step) {
422 note = `${MARK.warn} ${fill(text.onlyRead, { path })}`
423 } else {
424 mode = 'replay'
425 replayTurn = step.turn
426 index = Math.max(0, stepsOfTurn(current, step.turn).findIndex(other => other.id === step.id))
427 note = `${MARK.ok} ${fill(text.replaying, { path })}`
428 }
429 $.ui.invalidate('ui.render')
430}
431
432function walk($: EngineInterface, delta: number, total: number) {
433 index = Math.max(0, Math.min(index + delta, total - 1))
434 note = ''
435 $.ui.invalidate('ui.render')
436}
437
438// Folders the person opened while Claude works; the live folds leave them open until the next turn.
439let opened: string[] = []
440
441const foldsOf = (current: Log) => liveFolds(current, showReads)?.filter(key => !opened.includes(key)) ?? collapsed
442
443function fold($: EngineInterface, current: Log, key: string) {
444 const isLiveFold = liveFolds(current, showReads)?.includes(key)
445 if (isLiveFold) opened = opened.includes(key) ? opened.filter(other => other !== key) : [...opened, key]
446 else collapsed = collapsed.includes(key) ? collapsed.filter(other => other !== key) : [...collapsed, key]
447 note = ''
448 $.ui.invalidate('ui.render')
449}
450
451// Every folder at once; while Claude works, `opened` is what the live folds leave open.
452function foldAll($: EngineInterface, current: Log, isShut: boolean) {
453 const folders = rowsOf(current, [], showReads).filter(row => row.isDir).map(row => row.key)
454 collapsed = isShut ? folders : []
455 opened = isShut ? [] : folders
456 note = ''
457 $.ui.invalidate('ui.render')
458}
459
460const WORD: Record<Kind, { done: string; live: string }> = text.words
461
462const STATUS: Record<Kind, { tone: ListRow['tone'] }> = {
463 read: { tone: 'read' },
464 edit: { tone: 'changed' },
465 write: { tone: 'changed' },
466 new: { tone: 'added' },
467}
468
469type ToolEvent = { tool: string; [argument: string]: unknown }
470
471// What a call does to which file, read before it runs so a Write can see the file it replaces.
472async function planOf($: EngineInterface, e: ToolEvent) {
473 const cwd = await $.session.cwd().catch(() => '')
474 const at = (path: unknown) => relPath(String(cwd ?? ''), String(path))
475 if (e.tool === 'Read' && e.file_path) return { path: at(e.file_path), tool: 'Read', kind: 'read' as Kind, note: '', lines: [] as Line[] }
476 if ((e.tool === 'Edit' || e.tool === 'MultiEdit') && e.file_path) {
477 return { path: at(e.file_path), tool: e.tool, kind: 'edit' as Kind, note: e.replace_all ? text.notes.replaceAll : '', lines: diffLines(String(e.old_string ?? ''), String(e.new_string ?? '')) }
478 }
479 if (e.tool === 'NotebookEdit' && e.notebook_path) {
480 const editMode = String(e.edit_mode ?? 'replace')
481 return { path: at(e.notebook_path), tool: 'NotebookEdit', kind: 'edit' as Kind, note: fill(text.notes.cell, { mode: editMode }), lines: diffLines('', String(e.new_source ?? '')) }
482 }
483 if (e.tool === 'Write' && e.file_path) {
484 const path = String(e.file_path)
485 const isThere = await $.fs.exists(path).catch(() => false)
486 const before = isThere ? String(await $.fs.read(path).catch(() => '')) : ''
487 return { path: at(path), tool: 'Write', kind: (isThere ? 'write' : 'new') as Kind, note: isThere ? text.notes.rewrite : text.notes.newFile, lines: diffLines(before, String(e.content ?? '')) }
488 }
489 return undefined
490}
491
492// The engine refuses a Client whose drawing is too large, which a whole session's tree reaches; each list draws this many rows.
493const LIST_CHUNK = 40
494
495// When each file's latest live edit landed, and the log's seq then; transcript rebuilds leave it empty, so old edits don't glow.
496const landed = new Map<string, { at: number; seq: number }>()
497
498const chunksOf = <T,>(items: T[]) => Array.from({ length: Math.ceil(items.length / LIST_CHUNK) }, (_, n) => items.slice(n * LIST_CHUNK, (n + 1) * LIST_CHUNK))
499
500function listRows(current: Log, rows: ReturnType<typeof rowsOf>, glyphs: Look, width: number): ListRow[] {
501 const icon = (name: string) => glyphs.glyph.icon[name] ?? ''
502 return rows.map(row => {
503 const guides = '│ '.repeat(row.depth)
504 // Widths below are terminal cells: a CJK character takes two, so counting characters would overflow the row.
505 const entry: Entry | undefined = row.entry
506 const done = entry ? landed.get(entry.path) : undefined
507 // Once the edit lands the row stops being live and glows instead, until Claude touches another file.
508 const isLive = Boolean(entry && current.working && entry.seq === current.seq && done?.seq !== entry.seq)
509 const lead = row.isDir ? (row.isFolded ? glyphs.glyph.collapsed : glyphs.glyph.expanded) : ''
510 const mark = row.isDir ? icon(row.isFolded ? 'folder' : 'folder-open') : icon(iconOf(row.name))
511 const marks = [lead, mark].filter(Boolean).join(' ')
512 // Safe mode has no folder icon, so a trailing slash tells a folder from a file.
513 const name = row.isDir && !glyphs.nerd ? `${row.name}/` : row.name
514 const status = entry && isLive ? WORD[entry.last].live : ''
515 // A folded folder shows what its files add up to, so folding hides no change.
516 const inside = row.isDir && row.isFolded ? current.files.filter(file => file.path.startsWith(`${row.key}/`)) : []
517 const add = entry?.add ?? inside.reduce((sum, file) => sum + file.add, 0)
518 const del = entry?.del ?? inside.reduce((sum, file) => sum + file.del, 0)
519 const counts = [add > 0 ? `+${add}` : '', del > 0 ? `−${del}` : ''].filter(Boolean).join(' ')
520 // The counts' pill adds a cap on each side where the font has them.
521 const stats = cells(counts) + (counts && glyphs.nerd ? 2 : 0) + (status && counts ? 1 : 0)
522 // The live word sits in a pill, one cell wider on each side.
523 const used = cells(guides) + (marks ? cells(marks) + 1 : 0) + stats + cells(status) + (isLive ? 4 : 0) + OPEN_CELLS
524 const shown = cut(name, Math.max(6, width - used - 1))
525 return {
526 guides,
527 lead,
528 mark,
529 markColor: row.isDir ? THEME.folder : THEME.accent,
530 name: shown,
531 tone: !entry ? 'dir' : isLive ? 'live' : STATUS[entry.kind].tone,
532 add,
533 del,
534 status,
535 pad: width - used - cells(shown),
536 endMark: row.isDir ? glyphs.glyph.reveal : MARK.open,
537 endColor: row.isDir ? THEME.folder : THEME.accent,
538 tip: row.isDir ? text.folderRowTip : entry?.kind === 'read' ? text.readRowTip : text.fileRowTip,
539 tipLabel: row.isDir ? text.folderRowLabel : entry?.kind === 'read' ? text.readRowLabel : text.fileRowLabel,
540 openTip: row.isDir ? text.openFolderTip : text.openFileTip,
541 ...(done ? { changedAt: done.at } : {}),
542 }
543 })
544}
545
546// True while the pointer is on this mod's pill entry, which then shows its tip under the row.
547let tipShown = false
548let hoveredTip: Tip | null = null
549let toastTip = ''
550
551// A band redraw can replace a hovered entry, whose leave and quiet timer then never come, so a band tip ends on its own.
552const BAND_TIP_MS = 5000
553let bandTipAt = 0
554
555function expireBandTip($: EngineInterface) {
556 const at = ++bandTipAt
557 $.clock.after(BAND_TIP_MS, () => {
558 if (at !== bandTipAt || (!tipShown && !toastTip)) return
559 tipShown = false
560 toastTip = ''
561 $.ui.invalidate('ui.render')
562 })
563}
564
565const toasts = new ToastList('filetree')
566// The animations setting, fixed per activation: off, every entry draws still.
567let still = false
568
569// The entry shimmers from the click until the pane draws, so a slow open never looks ignored.
570const OPEN_WAIT_MS = 8000
571let isOpening = false
572
573function endOpening($: EngineInterface) {
574 if (!isOpening) return
575 isOpening = false
576 $.ui.invalidate('ui.render')
577}
578
579async function openingPane($: EngineInterface) {
580 if (!(await isOpen($, PANE))) {
581 isOpening = true
582 $.ui.invalidate('ui.render')
583 $.clock.after(OPEN_WAIT_MS, () => endOpening($))
584 }
585 await togglePane($)
586 if (!(await isOpen($, PANE))) endOpening($)
587}
588
589// The share of each status color left in its legend pill.
590const LEGEND_WASH = 0.72
591
592// A settings card's click: its row, saved through /config; a saved change reloads this mod.
593async function saveSetting($: EngineInterface, key: string, value: ConfigValue) {
594 const row = (await $.config.list()).find(each => each.key === key)
595 if (!row) return
596 const result = await $.config.set({ key, value })
597 note = 'deny' in result && result.deny ? `${MARK.warn} ${result.deny}` : `${MARK.ok} ${fill(text.settingSaved, { label: row.label })}`
598 $.ui.invalidate('ui.render')
599}
600
601const tabs = (isSettings: boolean, touched: number) => [
602 { id: 'filetree-tab', label: text.pill, icon: look.glyph.icon.files ?? look.glyph.title, isOpen: !isSettings, ...(isSettings ? {} : { count: touched }), tip: { glyph: look.glyph.icon.files ?? look.glyph.title, label: text.pill, text: text.filesTabTip } },
603 { id: 'filetree-settings', label: text.settingsLabel, icon: look.glyph.gear, isOpen: isSettings, tip: { glyph: look.glyph.gear, label: text.settingsLabel, text: text.settingsTabTip } },
604]
605
606export const register: Register = (on, options) => {
607 still = options.animations === false
608 toasts.isStill = still
609 // This mod's toasts draw as cards in the band; Claude Code's own box is narrow and cuts them.
610 on('ui.toast', async ($, e) => {
611 const id = toasts.add(e.text)
612 $.ui.invalidate('ui.render')
613 $.clock.after(e.timeoutMs ?? TOAST_MS, () => {
614 toasts.drop(id)
615 $.ui.invalidate('ui.render')
616 })
617 return { value: undefined }
618 })
619
620 showReads = options.show_reads !== false
621 wrapDiff = options.wrap_diff !== false
622 recentTurns = Math.min(20, Math.max(2, Math.round(Number(options.recent_turns ?? 5)) || 5))
623 iconMode = (options.icons as IconMode | undefined) ?? 'auto'
624 look = lookFor(iconMode === 'nerd')
625
626 on('session.start', async ($, e, next) => {
627 bridgeOn('SessionStart', record => sessionStarted($, record))
628 await startBridge($, 'mod-file-tree')
629 try {
630 await loadLook($)
631 await $.command.register({
632 name: 'mod-file-tree',
633 description: text.commandDescription,
634 argumentHint: '[settings|clear]',
635 })
636 await pruneTranscripts($)
637 // The bar entry reads this; an unload drops the pane without raising ui.close here.
638 await $.state.set({ plugin: 'mod-file-tree', key: 'pane' }, (await isOpen($, PANE)) || (await isOpen($, SETTINGS)))
639 await hydrate($)
640 // A hot reload leaves an open pane on its old drawing until something redraws it.
641 $.ui.invalidate('ui.render')
642 } catch {
643 // The tree stays as it was.
644 }
645
646 return next(e)
647 })
648
649 on('command.run', { command: 'mod-file-tree' }, async ($, e) => ({ text: await answer($, e.args) }))
650
651 on('ui.close', async ($, e, next) => {
652 try {
653 if (e.id === PANE || e.id === SETTINGS) {
654 await $.state.set({ plugin: 'mod-file-tree', key: 'pane' }, await isOpen($, e.id === PANE ? SETTINGS : PANE))
655 }
656 } catch {
657 // The tree stays as it was.
658 }
659
660 return next(e)
661 })
662
663 // Deferred past this dispatch, so this mod's own state write is not nested in another plugin's.
664 on('state.set', async ($, e, next) => {
665 // The declared state names no other mod's pane, so the types narrow this event to never.
666 if (e.key === 'pane' && e.plugin !== 'mod-file-tree' && (e as { value?: unknown }).value === true) {
667 $.clock.after(0, async () => {
668 const wasOpen = (await isOpen($, PANE)) || (await isOpen($, SETTINGS))
669 if (!wasOpen) return
670 await $.ui.close({ id: PANE })
671 await $.ui.close({ id: SETTINGS })
672 await $.state.set({ plugin: 'mod-file-tree', key: 'pane' }, false)
673 })
674 }
675
676 return next(e)
677 })
678
679 // Observes only: the call is recorded before it runs (the tree shows it live) and its diff once it succeeds.
680 on('tool.call', async ($, e, next) => {
681 const plan = await planOf($, e as ToolEvent).catch(() => undefined)
682 if (!plan) {
683 if (e.tool !== 'Bash') return next(e)
684 // A shell command's edits come back with its result, as Claude Code diffed them.
685 const ran = await next(e)
686 try {
687 const cwd = String((await $.session.cwd().catch(() => '')) ?? '')
688 const shells = shellPlans((ran as { result?: { bashEditDiff?: unknown } })?.result?.bashEditDiff, cwd)
689 if (shells.length > 0) {
690 await apply($, current => shells.reduce((each, shell) => commit(touch(each, shell.path, shell.kind), shell.path, shell.tool, shell.kind, shell.note, shell.lines), current))
691 for (const shell of shells) landed.set(shell.path, { at: Date.now(), seq: log!.seq })
692 }
693 } catch {
694 // The tree stays as it was.
695 }
696 return ran
697 }
698
699 try {
700 await apply($, current => touch(current, plan.path, plan.kind))
701 } catch {
702 // The tree stays as it was.
703 }
704
705 const result = await next(e)
706 const isFailed = typeof result === 'object' && result !== null && ('deny' in result || ('isError' in result && result.isError === true))
707 try {
708 if (plan.kind !== 'read' && !isFailed) {
709 await apply($, current => commit(current, plan.path, plan.tool, plan.kind, plan.note, plan.lines))
710 landed.set(plan.path, { at: Date.now(), seq: log!.seq })
711 if (replayTurn === null) index = Math.max(0, stepsOfTurn(viewOf(log!), null).length - 1)
712 }
713 } catch {
714 // The tree stays as it was.
715 }
716
717 return result
718 })
719
720 on('turn.start', async ($, e, next) => {
721 try {
722 if (!(e as { agentId?: string }).agentId) {
723 opened = []
724 replayTurn = null
725 await apply($, current => ({ ...current, turn: current.turn + 1, working: true }))
726 }
727 } catch {
728 // The tree stays as it was.
729 }
730
731 return next(e)
732 })
733
734 on('turn.complete', async ($, e, next) => {
735 try {
736 if (!e.agentId) {
737 if (replayTurn === null) index = 0
738 await apply($, current => ({ ...current, working: false }))
739 }
740 } catch {
741 // The tree stays as it was.
742 }
743
744 return next(e)
745 })
746
747 // A /resume or /clear ends this session; the next hook asks for the id at once.
748 on('session.end', async ($, e, next) => {
749 sessionCheckedAt = 0
750 await endBridge($, e.sessionId)
751
752 return next(e)
753 })
754
755 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
756 await loadLook($)
757 const hidden = await $.state.get({ plugin: 'mod-menu', key: 'hidden' })
758 if (e.surface !== 'terminal' || e.props.hasSurvey) return next(e)
759 if (hidden.value === true) {
760 if (toasts.items.length === 0) return next(e)
761 const { Box, Client, Text } = $.ui.resolve(e)
762 return <Box key="toasts-filetree" flexDirection="column">{toastCards({ Box, Client, Text }, toasts, look, text.tipToast)}{await next(e)}</Box>
763 }
764
765 const { Box, Client, Text } = $.ui.resolve(e)
766 // Reading the value subscribes the bar to redraws; the look trusts the pane list itself.
767 await $.state.get({ plugin: 'mod-file-tree', key: 'pane' })
768 const shown = (await isOpen($, PANE)) || (await isOpen($, SETTINGS))
769 const labels = labelsOf((await $.state.get({ plugin: 'mod-menu', key: 'labels' })).value)
770 const { group, row, rest, tip } = splitRow(await next(e))
771 // Opening the pane from here queues nothing, so the entry stays live mid-turn.
772 const entry = menuEntry({ Box, Client, Text }, look.nerd, 'filetree', { label: text.pill, isOpen: shown, idle: THEME.pill, isBusy: isOpening, still, labels })
773
774 return pillRow({ Box, Client, Text }, look.nerd, [entry, ...group], row, [...toastCards({ Box, Client, Text }, toasts, look, text.tipToast), ...rest], toastTip ? tipLine({ Box, Text }, toastTip) : tipShown ? tipLine({ Box, Text }, text.pillTip) : tip, e.props.bodyColumns)
775 })
776
777 on('ui.message', async ($, e, next) => {
778 const clicked = e.data as EntryMessage
779 const dismissed = clicked?.type === 'menu-entry' ? toasts.idOf(clicked.id) : undefined
780 if (dismissed !== undefined) {
781 toasts.drop(dismissed)
782 $.ui.invalidate('ui.render')
783 return next(e)
784 }
785 if (isTipMessage(e.data)) {
786 const { id, glyph, label, text: tipText } = e.data
787 const isToast = Boolean(id?.startsWith('toast-filetree-'))
788 toastTip = isToast ? `${label}: ${tipText}` : ''
789 if (toastTip) expireBandTip($)
790 hoveredTip = !isToast && id && glyph !== undefined && label && tipText ? { glyph, label, text: tipText } : null
791 $.ui.invalidate('ui.render')
792 return next(e)
793 }
794 const hover = e.data as { type?: string; id?: string; isOver?: boolean }
795 if (hover?.type === 'menu-hover' && hover.id === 'filetree') {
796 tipShown = hover.isOver === true
797 if (tipShown) expireBandTip($)
798 $.ui.invalidate('ui.render')
799 }
800 try {
801 const change = settingChoice(e.data)
802 if (change) await saveSetting($, change.key, change.value)
803 const message = e.data as EntryMessage
804 if (message?.type === 'menu-entry') {
805 if (message.id === 'filetree') await openingPane($)
806 if (message.id === 'filetree-settings' && !(await isOpen($, SETTINGS))) await toggleSettings($)
807 if (message.id === 'filetree-tab' && !(await isOpen($, PANE))) await togglePane($)
808 if (message.id === 'tab-tree' || message.id === 'tab-replay') {
809 mode = message.id === 'tab-tree' ? 'tree' : 'replay'
810 note = ''
811 $.ui.invalidate('ui.render')
812 }
813 if (message.id === 'scope-turn' || message.id === 'scope-recent' || message.id === 'scope-session') {
814 scope = message.id === 'scope-turn' ? 'turn' : message.id === 'scope-recent' ? 'recent' : 'session'
815 note = ''
816 $.ui.invalidate('ui.render')
817 }
818 if (message.id === 'fold-all' || message.id === 'unfold-all') {
819 const isFolding = message.id === 'fold-all'
820 foldAll($, viewOf(await hydrate($)), isFolding)
821 // The click swaps the toggle for its opposite under the pointer, and the old one never posts its leave.
822 hoveredTip = isFolding ? { glyph: look.glyph.more, label: text.unfoldAll, text: text.unfoldAllTip } : { glyph: look.glyph.less, label: text.foldAll, text: text.foldAllTip }
823 }
824 if (message.id === 'session-reveal') {
825 const cwd = String((await $.session.cwd().catch(() => '')) ?? '').replace(/\\/g, '/')
826 await openFolder($, cwd, cwd.split('/').pop() || text.rootFallback)
827 }
828 if (message.id === 'replay-prev' || message.id === 'replay-next') {
829 walk($, message.id === 'replay-prev' ? -1 : 1, stepsOfTurn(viewOf(await hydrate($)), replayTurn).length)
830 }
831 }
832 } catch {
833 // The tree stays as it was.
834 }
835
836 return next(e)
837 })
838
839 on('ui.message', { requestId: PANE }, async ($, e, next) => {
840 const message = e.data as ListMessage
841 try {
842 if (message?.type === 'select') {
843 const current = viewOf(await hydrate($))
844 const row = rowsOf(current, foldsOf(current), showReads)[message.index]
845 if (row?.isDir) fold($, current, row.key)
846 else if (row) await openDiff($, row.key)
847 }
848 if (message?.type === 'open') {
849 const current = viewOf(await hydrate($))
850 const row = rowsOf(current, foldsOf(current), showReads)[message.index]
851 if (row?.isDir) await openFolder($, row.key)
852 else if (row) await openFile($, row.key)
853 }
854 } catch {
855 // The tree stays as it was.
856 }
857
858 return next(e)
859 })
860
861 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
862 await loadLook($)
863 if (e.requestId !== PANE && e.requestId !== SETTINGS) return next(e)
864 if (isOpening) $.clock.after(0, () => endOpening($))
865
866 // @ts-expect-error The host's types omit Client from a Pane's elements, yet a Pane draws it.
867 const { Box, Button, Client, Text } = $.ui.resolve(e)
868 const elements = { Box, Button, Text }
869
870 if (e.requestId === SETTINGS) {
871 const rows = (await $.config.list()).filter(row => row.key.startsWith('mod-file-tree.'))
872 const run = async (action: Action) => {
873 const said = answer($, action.args ?? '')
874 note = await said.then(said => `${MARK.ok} ${said || action.label}`).catch(error => `${MARK.warn} ${String(error)}`)
875 $.ui.invalidate('ui.render')
876 }
877 return (
878 <Box flexDirection="column" paddingX={1} minHeight={e.props.scroll.bodyRows} backgroundColor={THEME.bg}>
879 {tabRow({ Box, Client, Text }, look.nerd, tabs(true, 0), still)}
880 {sepText({ Text }, withMark(look, note) || fill(text.settingsHint, { sep: MARK.sep }), statusColor(note), 'status-line', { wrap: 'truncate-end' })}
881 <Box marginTop={0}>{settingsBody({ Box, Button, Client, Text }, look, { rows, actions, working: false, run, previews, fields, still })}</Box>
882 <Box key="footer-space" height={FOOTER_ROWS} flexShrink={0} />
883 {paneFooter({ Box, Text }, hoveredTip, e.props.bodyColumns, e.props.scroll)}
884 </Box>
885 )
886 }
887
888 // Reading the log subscribes the pane to every tool call; a reload empties the copy, so it is read again.
889 await $.state.get({ plugin: 'mod-file-tree', key: 'log' })
890 const current = viewOf(await hydrate($))
891 const rows = rowsOf(current, foldsOf(current), showReads)
892 // Every file in the view, folded or not.
893 const touched = current.files.filter(file => showReads || file.kind !== 'read').length
894 const { glyph } = look
895
896 const entry = (id: string, label: string, isOn: boolean, bg: string = THEME.surface1, extra: Partial<EntryProps> = {}) => (
897 <Box key={`${id}-pad`}>
898 <Client key={id} module="./entry.tsx" height={1} props={{ id, label, isOpen: isOn, bg, nerd: look.nerd, still, ...extra }} />
899 </Box>
900 )
901 // A Nerd icon as a subpill before the label; safe mode has none, so the prop stays out.
902 // A switch is the settings page's choice bar, so both read the same.
903 const choiceBar = (key: string, options: { id: string; label: string; isOn: boolean; icon?: string; tip: string }[]) =>
904 sharedChoiceBar({ Box, Button, Client, Text }, look, key, THEME.bg, options.map(option => ({
905 key: option.id,
906 isOn: option.isOn,
907 // No icon subpill: it changes the options' widths between states, so the bar shifts on every switch.
908 props: { id: option.id, label: option.label, still, nerd: look.nerd, tip: { glyph: look.glyph.icon[option.icon ?? ''] ?? look.glyph.title, label: option.label, text: option.tip } },
909 })))
910
911 const emptyText = (legit: string) => (loadError ? fill(text.unreadableWhy, { reason: loadError }) : legit)
912 // The pane pads one column on both sides, the section its border and one more column.
913 const width = Math.max(20, e.props.bodyColumns - 6)
914 const tree =
915 rows.length === 0
916 ? <Text key="tree-empty" color={THEME.muted}>{emptyText(text.treeEmpty)}</Text>
917 : (
918 <Box key="lists" flexDirection="column">
919 {chunksOf(listRows(current, rows, look, width)).map((chunk, n) => (
920 <Client key={`list-${n}`} module="./list.tsx" height={chunk.length} props={{ rows: chunk, offset: n * LIST_CHUNK, still, nerd: look.nerd, width, glyph: look.glyph.icon.files ?? look.glyph.title, openLabel: text.openLabel }} />
921 ))}
922 </Box>
923 )
924 const root = rootOf(current, showReads)
925 const rootName = root.split('/').pop() || String(await $.session.cwd().catch(() => '')).replace(/\\/g, '/').split('/').pop() || text.rootFallback
926
927 const steps = stepsOfTurn(current, replayTurn)
928 const at = Math.max(0, Math.min(index, steps.length - 1))
929 const step = steps[at]
930 const color = (op: Line['op']) => (op === '+' ? THEME.good : op === '-' ? THEME.alert : THEME.muted)
931 const replay = !step
932 ? <Text key="replay-empty" color={THEME.muted}>{emptyText(text.replayEmpty)}</Text>
933 : (
934 <Box key="replay-body" flexDirection="column">
935 <Box key="replay-file" flexDirection="row" marginTop={1}>
936 <Text color={THEME.accent} bold wrap="truncate-start">{withIcon(look, iconOf(step.path), step.path)}</Text>
937 </Box>
938 <Box key="replay-meta" flexDirection="row" columnGap={1}>
939 {sepText({ Text }, [step.tool, step.note].filter(Boolean).join(` ${MARK.sep} `), THEME.muted, 'replay-meta-text')}
940 {step.add > 0 ? <Text color={THEME.good}>{`+${step.add}`}</Text> : null}
941 {step.del > 0 ? <Text color={THEME.alert}>{`−${step.del}`}</Text> : null}
942 </Box>
943 <Box key="replay-diff" flexDirection="column" marginTop={1}>
944 {step.lines.length === 0 ? <Text color={THEME.muted}>{text.noLineChanges}</Text> : null}
945 {step.lines.map((line, n) => (
946 // Red text on the delete tint fails contrast, so only the sign carries the signal color.
947 <Box key={`diff-${n}`} flexDirection="row" backgroundColor={line.op === '+' ? THEME.addBg : line.op === '-' ? THEME.delBg : undefined}>
948 <Text color={color(line.op)} bold={line.op === '+' || line.op === '-'}>{`${line.op === '~' ? ' ' : line.op === '-' ? '−' : line.op} `}</Text>
949 <Text color={line.op === ' ' || line.op === '~' ? THEME.muted : THEME.onSurface} wrap={wrapDiff ? 'wrap' : 'truncate-end'}>{line.t}</Text>
950 </Box>
951 ))}
952 </Box>
953 </Box>
954 )
955
956 // An edge stepper is plain text, so it neither lights nor posts a tip.
957 const stepper = (id: string, label: string, isEdge: boolean, hue: string, place: 'lead' | 'trail', mark: string, tipText: string) => {
958 const sub = place === 'lead' ? { subLead: mark, subLeadColor: hue } : { badge: { text: mark, color: hue, bg: mix(hue, THEME.surface3, LEGEND_WASH) } }
959 const body = isEdge ? (
960 <Box key={`${id}-edge`} flexDirection="row">
961 {place === 'lead' ? subpill(elements, look.nerd, THEME.surface3, mark, `${id}-edge-mark`, THEME.muted) : null}
962 <Text color={THEME.muted} backgroundColor={THEME.surface3}>{` ${label} `}</Text>
963 {place === 'trail' ? subpill(elements, look.nerd, THEME.surface3, mark, `${id}-edge-mark`, THEME.muted) : null}
964 </Box>
965 ) : (
966 entry(id, label, false, THEME.surface3, { idle: THEME.secondary, ...sub, tip: { glyph: mark, label, text: tipText } })
967 )
968 // The steppers sit in the card's top edge, on bg.
969 return pill({ Box, Text }, look.nerd, THEME.surface3, body, `${id}-pill`, THEME.bg)
970 }
971 const replayNav = step ? (
972 <Box key="replay-nav" flexDirection="row" columnGap={1}>
973 {stepper('replay-prev', text.prev, at <= 0, THEME.accent, 'lead', glyph.back, text.prevTip)}
974 <Text color={THEME.onSurface} bold>{fill(text.stepOf, { at: at + 1, total: steps.length })}</Text>
975 {stepper('replay-next', text.next, at >= steps.length - 1, THEME.accent, 'trail', glyph.title, text.nextTip)}
976 </Box>
977 ) : null
978 const replayTitle = fill(text.replayHeading, { sep: MARK.sep, n: steps.length, unit: steps.length === 1 ? text.editOne : text.editMany })
979
980 // Keyed on folded, not open: the live fold keeps the edited folder open through a Fold all.
981 const isAnyFolded = rows.some(row => row.isDir && row.isFolded)
982 const foldToggle = (id: string, label: string, arrow: string, tipText: string) =>
983 entry(id, label, false, THEME.bg, { idle: THEME.muted, lead: arrow, leadColor: THEME.accent, tip: { glyph: arrow, label, text: tipText } })
984 const fold = !rows.some(row => row.isDir) ? null : isAnyFolded ? foldToggle('unfold-all', text.unfoldAll, glyph.more, text.unfoldAllTip) : foldToggle('fold-all', text.foldAll, glyph.less, text.foldAllTip)
985
986 const hint =
987 mode === 'tree'
988 ? `${fill(text.hintTree, { sep: MARK.sep })}${showReads ? '' : fill(text.hintReadsHidden, { sep: MARK.sep })}`
989 : fill(text.hintReplay, { sep: MARK.sep })
990
991 return (
992 <Box flexDirection="column" paddingX={1} minHeight={e.props.scroll.bodyRows} backgroundColor={THEME.bg}>
993 <Box key="title-line" flexDirection="row" columnGap={1}>
994 {tabRow({ Box, Client, Text }, look.nerd, tabs(false, touched), still)}
995 {current.working ? entry('working', text.working, false, THEME.bg, { spinner: true, idle: THEME.muted }) : null}
996 </Box>
997 {sepText({ Text }, withMark(look, note) || hint, statusColor(note), 'status-line', { wrap: 'truncate-end' })}
998 <Box key="tabs" flexDirection="row" columnGap={1} marginTop={1}>
999 {choiceBar('view', [
1000 { id: 'tab-tree', label: text.tabTree, isOn: mode === 'tree', icon: 'files', tip: text.tabTreeTip },
1001 { id: 'tab-replay', label: text.tabReplay, isOn: mode === 'replay', icon: 'replay', tip: text.tabReplayTip },
1002 ])}
1003 {mode === 'tree'
1004 ? choiceBar('scope', [
1005 { id: 'scope-turn', label: text.scopeTurn, isOn: scope === 'turn', tip: text.scopeTurnTip },
1006 { id: 'scope-recent', label: fill(text.scopeRecent, { n: recentTurns }), isOn: scope === 'recent', tip: text.scopeRecentTip },
1007 { id: 'scope-session', label: text.scopeSession, isOn: scope === 'session', tip: text.scopeSessionTip },
1008 ])
1009 : null}
1010 </Box>
1011 {mode === 'tree'
1012 ? section(elements, look, 'tree', 'folder-open', rootName, tree, THEME.accent, { right: fold })
1013 : section(elements, look, 'replay', 'replay', replayTitle, replay, THEME.accent, { right: replayNav })}
1014 {mode === 'tree' ? <Box key="legend-push" flexGrow={1} /> : null}
1015 {mode === 'tree' ? (
1016 <Box key="legend" flexDirection="column">
1017 <Box key="legend-row" flexDirection="row" columnGap={1}>
1018 {(['changed', 'added', 'read'] as const).map(tone =>
1019 pill({ Box, Text }, look.nerd, mix(NAME_COLOR[tone], THEME.bg, LEGEND_WASH), <Text color={NAME_COLOR[tone]}>{look.nerd ? text.legend[tone] : ` ${text.legend[tone]} `}</Text>, `legend-${tone}`, THEME.bg),
1020 )}
1021 <Box key="legend-gap" flexGrow={1} />
1022 {pill({ Box, Text }, look.nerd, THEME.surface3, entry('session-reveal', text.sessionFolder, false, THEME.surface3, { idle: THEME.onSurface, subLead: look.glyph.icon.folder ?? look.glyph.reveal, subLeadColor: THEME.folder, tip: { glyph: look.glyph.icon.folder ?? look.glyph.reveal, label: text.sessionFolder, text: text.sessionFolderTip } }), 'session-reveal-pill', THEME.bg)}
1023 </Box>
1024 </Box>
1025 ) : null}
1026 <Box key="footer-space" height={FOOTER_ROWS} flexShrink={0} />
1027 {paneFooter({ Box, Text }, hoveredTip, e.props.bodyColumns, e.props.scroll)}
1028 </Box>
1029 )
1030 })
1031}
1032hooks/bridge.ts 107 lines1// hooks/bridge.ts, by v-amorim: a poll hands the lines a command hook appended to <mods-data>/<mod>/bridge/<session>.jsonl to this mod.
2import type { EngineInterface } from 'claude-code'
3
4export type BridgeRecord = Record<string, unknown>
5export type BridgeHandler = (record: BridgeRecord) => void | Promise<void>
6
7const POLL_MS = 250
8
9// One handler per event; registering again replaces it, so a mod that registers on every session.start holds one.
10const handlers = new Map<string, BridgeHandler>()
11// Characters of each session's file already delivered.
12const cursors = new Map<string, number>()
13let isStarted = false
14let isTicking = false
15let folder = ''
16
17export function bridgeOn(event: string, handler: BridgeHandler) {
18 handlers.set(event, handler)
19}
20
21async function folderOf($: EngineInterface, mod: string) {
22 const set = ((await $.env.get('CLAUDE_CONFIG_DIR')) ?? '').replace(/\\/g, '/').replace(/\/+$/, '')
23 if (set) return `${set}/mods-data/${mod}/bridge`
24 // The command hooks write under $HOME, so it comes before USERPROFILE; a Git Bash `/c/Users/x` becomes `C:/Users/x`, which $.fs can open.
25 const raw = ((await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || '').replace(/\\/g, '/').replace(/\/+$/, '')
26 const home = raw.replace(/^\/([A-Za-z])(?=\/[^/])/, (_, drive: string) => `${drive.toUpperCase()}:`)
27 return home ? `${home}/.claude/mods-data/${mod}/bridge` : ''
28}
29
30async function deliver(record: BridgeRecord) {
31 try {
32 await handlers.get(String(record.hook_event_name ?? ''))?.(record)
33 } catch {
34 // A handler's failure never stops the poll.
35 }
36}
37
38// The complete lines this session's file gained since the last read, and whether this is the first read since the module started.
39async function readNew($: EngineInterface, session: string) {
40 if (!folder || !session) return { lines: [] as string[], isFirst: false }
41 const text = String(await $.fs.read(`${folder}/${session}.jsonl`).catch(() => ''))
42 const isFirst = !cursors.has(session)
43 let cursor = cursors.get(session) ?? 0
44 // A file shorter than what was read was deleted or emptied, so it starts over.
45 if (text.length < cursor) cursor = 0
46 const end = text.lastIndexOf('\n') + 1
47 cursors.set(session, Math.max(end, cursor))
48 return { lines: end > cursor ? text.slice(cursor, end).split('\n').filter(Boolean) : [], isFirst }
49}
50
51async function poll($: EngineInterface, session: string) {
52 const { lines, isFirst } = await readNew($, session)
53 const records: BridgeRecord[] = []
54 for (const line of lines) {
55 try {
56 const parsed: unknown = JSON.parse(line)
57 if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) records.push(parsed as BridgeRecord)
58 } catch {
59 // A corrupt line is skipped.
60 }
61 }
62 for (const record of isFirst ? lastOfEach(records) : records) await deliver(record)
63}
64
65// After a reload the file holds rows delivered before it, so only the last of each event is state worth replaying.
66export function lastOfEach(records: BridgeRecord[]) {
67 const last = new Map(records.map(record => [String(record.hook_event_name ?? ''), record]))
68 return records.filter(record => last.get(String(record.hook_event_name ?? '')) === record)
69}
70
71async function tick($: EngineInterface) {
72 if (isTicking) return
73 isTicking = true
74 try {
75 // Asked every tick, so a /clear or a resume reads the new session's file.
76 await poll($, String(await $.session.id()))
77 } catch {
78 // The next tick tries again.
79 } finally {
80 isTicking = false
81 }
82}
83
84// Starts the poll once; `mod` names the mod's folder under mods-data.
85export async function startBridge($: EngineInterface, mod: string) {
86 if (isStarted) return
87 isStarted = true
88 try {
89 folder = await folderOf($, mod)
90 if (!folder) return
91 $.clock.every(POLL_MS, () => void tick($))
92 await tick($)
93 } catch {
94 // Without the bridge the mod keeps to what it finds on its own.
95 }
96}
97
98// A session ended: what its file still holds is delivered; the SessionEnd command hook deletes the file.
99export async function endBridge($: EngineInterface, session: string) {
100 if (!folder || !session) return
101 try {
102 await poll($, session)
103 } catch {
104 // The next read of the file delivers it.
105 }
106}
107hooks/list.tsx 227 lines1// mod-file-tree / hooks/list.tsx, by v-amorim
2// Draws the file tree rows with indent guides, icons and tone colors for Claude's activity.
3import type { ClientModule, RenderElement } from 'claude-code'
4
5import { HOVER_STEPS, HOVER_TICK_MS, hoverMix, mix, postTip, shimmerColor, SPIN_FRAMES, SPIN_MS, stepToward, TICK_MS } from './entry'
6import { cardFill } from './look'
7import { CAPS, THEME } from './theme'
8
9// live: Claude is on this file right now; added, changed and read set the name's color.
10export type Tone = 'live' | 'added' | 'changed' | 'read' | 'dir'
11
12export type Row = {
13 // Indent guides, one `│ ` per level above the row.
14 guides: string
15 // A folder's chevron.
16 lead?: string
17 // The folder or file-type icon, drawn in markColor.
18 mark: string
19 markColor?: string
20 name: string
21 tone: Tone
22 add: number
23 del: number
24 // The status letter (A, M, R), or the action word while Claude is on the file.
25 status: string
26 // Spaces that push the counts and the status to the pane's right edge.
27 pad: number
28 // When this file's latest edit landed, so the row glows and fades back.
29 changedAt?: number
30 // The mark the row ends in: a file's opens it in VS Code or the default app, a folder's opens it in the file manager.
31 endMark: string
32 // The open mark's color: a folder's differs from a file's arrow.
33 endColor: string
34 // What the footer says while the pointer is on the row text, and on the end mark.
35 tip: string
36 tipLabel: string
37 openTip: string
38}
39
40// offset is the first row's index in the whole tree: a long tree draws as several lists.
41// still: the animations setting is off: no shimmer, no glow, and the hover switches at once.
42// nerd: the font has the rounded caps the live word's pill uses. width: every row's cells, the open mark in the last.
43export type ListProps = { rows: Row[]; offset?: number; still?: boolean; nerd?: boolean; width: number; glyph: string; openLabel: string }
44
45export type ListMessage = { type: 'select' | 'open'; index: number }
46
47// The space, then the open mark's pill: caps around it on Nerd, a space each side on safe.
48export const OPEN_CELLS = 4
49
50// A file's name carries its status as color, named once in the pane's legend instead of a letter on every row.
51export const NAME_COLOR: Record<Tone, string> = { live: THEME.onSurface, added: THEME.good, changed: THEME.warn, read: THEME.secondary, dir: THEME.onSurface }
52
53// Lines added and removed in one pill; plain colored text where the font has no caps.
54const countsPill = (Text: (props: any) => RenderElement, add: number, del: number, nerd: boolean) => {
55 const parts = [add > 0 ? <Text key="add" color={THEME.good} backgroundColor={nerd ? THEME.surface3 : undefined}>{`+${add}`}</Text> : null, del > 0 ? <Text key="del" color={THEME.alert} backgroundColor={nerd ? THEME.surface3 : undefined}>{`${add > 0 ? ' ' : ''}−${del}`}</Text> : null]
56 if (!nerd) return parts
57 return [<Text key="counts-left" color={THEME.surface3}>{CAPS.left}</Text>, ...parts, <Text key="counts-right" color={THEME.surface3}>{CAPS.right}</Text>]
58}
59
60// levels: each row's hover fade, marks: the open mark's, kept only while above 0.
61type State = { hovered: number; phase: number; spin?: number; levels: Record<number, number>; marks?: Record<number, number>; onMark?: boolean }
62
63// Moonlight surface-3: the row Claude is on stands out from the tree's surface-1 as a band.
64const LIVE_BG = THEME.surface3
65
66// The live word's pill: rounded caps where the font has them, a space on each side elsewhere.
67const LIVE_PILL = THEME.border
68
69const livePill = (Text: (props: any) => RenderElement, word: string, nerd: boolean) => [
70 <Text key="live-cap-left" color={LIVE_PILL}>{nerd ? CAPS.left : ' '}</Text>,
71 <Text key="live-word" color={THEME.pill} backgroundColor={LIVE_PILL}>{word}</Text>,
72 <Text key="live-cap-right" color={LIVE_PILL}>{nerd ? CAPS.right : ' '}</Text>,
73]
74
75// A Client draws Text only, so the open mark's pill draws its caps itself; OPEN_CELLS counts these cells.
76// level lights it like a Button pill: the body goes accent, the glyph takes the body's old color, the caps follow the body.
77const openPill = (Text: (props: any) => RenderElement, glyph: string, color: string, nerd: boolean, level: number) => {
78 const body = hoverMix(THEME.surface3, THEME.accent, level)
79 const ink = hoverMix(color, THEME.surface3, level)
80 return nerd
81 ? [
82 <Text key="open-gap">{' '}</Text>,
83 <Text key="open-cap-left" color={body}>{CAPS.left}</Text>,
84 <Text key="open" color={ink} backgroundColor={body}>{glyph}</Text>,
85 <Text key="open-cap-right" color={body}>{CAPS.right}</Text>,
86 ]
87 : [<Text key="open-gap">{' '}</Text>, <Text key="open" color={ink} backgroundColor={body}>{` ${glyph} `}</Text>]
88}
89
90// A file whose edit just landed starts in the live band's colors and fades to its own over this long.
91const GLOW_MS = 2500
92
93const glowOf = (row: Row, now: number) => (row.changedAt && now - row.changedAt < GLOW_MS ? 1 - (now - row.changedAt) / GLOW_MS : 0)
94
95const fadeLevels = (levels: Record<number, number>, hovered: number) => {
96 const next: Record<number, number> = {}
97 for (const row of new Set([...Object.keys(levels).map(Number), hovered])) {
98 const level = stepToward(levels[row] ?? 0, row === hovered)
99 if (row >= 0 && level > 0) next[row] = level
100 }
101 return next
102}
103
104const isFading = (levels: Record<number, number>, hovered: number) =>
105 Object.entries(levels).some(([row, level]) => level !== (Number(row) === hovered ? HOVER_STEPS : 0)) || (hovered >= 0 && !levels[hovered])
106
107// The tickers read the latest rows from here, since their closures keep the first ones.
108const latest = new WeakMap<object, ListProps>()
109
110// Each list's two timers run only while there is something to move, a live or glowing row or a fading hover: idle lists ticking cost a wakeup each.
111const timers = new WeakMap<object, Partial<Record<'move' | 'fade' | 'spin', () => void>>>()
112
113const run = (surface: Parameters<ClientModule<ListProps, State>>[1], kind: 'move' | 'fade' | 'spin', ms: number, step: () => boolean) => {
114 const own = timers.get(surface) ?? {}
115 if (own[kind]) return
116 const stop = surface.every(ms, () => {
117 if (step()) return
118 stop()
119 delete own[kind]
120 })
121 own[kind] = stop
122 timers.set(surface, own)
123}
124
125// A glow keeps the ticker one step past its end, so the row's last frame is its own colors.
126const isMoving = (rows: ListProps['rows']) => {
127 const now = Date.now()
128 return rows.some(row => row.tone === 'live' || (row.changedAt !== undefined && now - row.changedAt < GLOW_MS + TICK_MS * 2))
129}
130
131// The tree rows; a click anywhere on a row selects it, the row under the pointer lights up, and the file Claude is on shimmers.
132const FileList: ClientModule<ListProps, State> = (props, surface) => {
133 const { Box, Text } = surface.elements
134 const { rows } = props
135 latest.set(surface, props)
136
137 // A Client takes no hover prop, so it tracks the row under the pointer itself.
138 const hovered = surface.state?.hovered ?? -1
139 const phase = surface.state?.phase ?? 0
140 const levels = surface.state?.levels ?? {}
141 const marks = surface.state?.marks ?? {}
142
143 if (!surface.state) surface.setState({ hovered: -1, phase: 0, levels: {} })
144 if (!props.still && isMoving(rows)) {
145 run(surface, 'move', TICK_MS, () => {
146 const { state } = surface
147 if (!state || latest.get(surface)?.still || !isMoving(latest.get(surface)?.rows ?? [])) return false
148 surface.setState({ ...state, phase: state.phase + 1 })
149 return true
150 })
151 }
152 // The spinner beside the live word keeps its own 80 ms clock, apart from the shimmer's.
153 const hasLive = (list: ListProps['rows']) => list.some(row => row.tone === 'live')
154 if (!props.still && hasLive(rows)) {
155 run(surface, 'spin', SPIN_MS, () => {
156 const { state } = surface
157 if (!state || latest.get(surface)?.still || !hasLive(latest.get(surface)?.rows ?? [])) return false
158 surface.setState({ ...state, spin: (state.spin ?? 0) + 1 })
159 return true
160 })
161 }
162
163 surface.onPointer(event => {
164 // The hover fade runs on its own, faster timer.
165 if (!props.still) {
166 run(surface, 'fade', HOVER_TICK_MS, () => {
167 const { state } = surface
168 if (!state || latest.get(surface)?.still) return false
169 const markRow = state.onMark ? state.hovered : -1
170 if (!isFading(state.levels ?? {}, state.hovered) && !isFading(state.marks ?? {}, markRow)) return false
171 surface.setState({ ...state, levels: fadeLevels(state.levels ?? {}, state.hovered), marks: fadeLevels(state.marks ?? {}, markRow) })
172 return true
173 })
174 }
175 const row = event.type !== 'leave' && event.y >= 0 && event.y < rows.length ? event.y : -1
176 const next = props.still ? (row >= 0 ? { [row]: HOVER_STEPS } : {}) : fadeLevels(levels, row)
177 const isOpenMark = row >= 0 && rows[row]!.endMark !== '' && event.x >= props.width - OPEN_CELLS
178 const isClick = event.type === 'down' && event.button === 'left' && row >= 0
179 if (row !== hovered || isOpenMark !== (surface.state?.onMark === true)) {
180 const nextMarks = props.still ? (isOpenMark ? { [row]: HOVER_STEPS } : {}) : fadeLevels(marks, isOpenMark ? row : -1)
181 surface.setState({ hovered: row, phase, levels: row !== hovered ? next : levels, marks: nextMarks, onMark: isOpenMark })
182 if (!isClick) postTip(surface, 'row', row >= 0 ? { glyph: props.glyph, label: isOpenMark ? props.openLabel : rows[row]!.tipLabel, text: isOpenMark ? rows[row]!.openTip : rows[row]!.tip } : null)
183 }
184 if (!isClick) return
185 surface.post({ type: isOpenMark ? 'open' : 'select', index: (props.offset ?? 0) + row } satisfies ListMessage)
186 })
187
188 return (
189 <Box flexDirection="column">
190 {rows.map((row, index) => {
191 const isLive = row.tone === 'live'
192 const glow = props.still ? 0 : glowOf(row, Date.now())
193 const restColor = mix(NAME_COLOR[row.tone], THEME.pill, glow)
194 const nameColor = hoverMix(restColor, THEME.accent, levels[index] ?? 0)
195 const rest = isLive ? LIVE_BG : glow > 0 ? mix(cardFill(), LIVE_BG, glow) : undefined
196 const lift = levels[index] ?? 0
197 const background = lift > 0 ? hoverMix(rest ?? cardFill(), THEME.lifted, lift) : rest
198 return (
199 <Box key={`row-${index}`} flexDirection="row" backgroundColor={background}>
200 <Text color={THEME.border}>{row.guides}</Text>
201 {row.lead ? <Text color={isLive ? THEME.accent : THEME.muted}>{`${row.lead} `}</Text> : null}
202 {row.mark ? <Text color={row.markColor ?? THEME.muted}>{`${row.mark} `}</Text> : null}
203 {isLive && !props.still ? (
204 [...row.name].map((char, at) => (
205 <Text key={`c${at}`} color={shimmerColor(at, phase, row.name.length)} bold>
206 {char}
207 </Text>
208 ))
209 ) : (
210 <Text color={nameColor}>{row.name}</Text>
211 )}
212 <Text>{' '.repeat(Math.max(1, row.pad))}</Text>
213 {/* The live word sits left of the counts, so the counts keep one right edge whether a row is live or not. */}
214 {/* It never shrinks: a row a cell too wide takes the cell from the gap, not from the word. */}
215 {isLive && row.status ? <Box key="live" flexDirection="row" flexShrink={0}>{livePill(Text, row.status, props.nerd === true)}<Text key="live-spin" color={THEME.accent}>{` ${SPIN_FRAMES[props.still ? 0 : (surface.state?.spin ?? 0) % SPIN_FRAMES.length]}`}</Text></Box> : null}
216 {isLive && row.status && (row.add > 0 || row.del > 0) ? <Text> </Text> : null}
217 {row.add > 0 || row.del > 0 ? countsPill(Text, row.add, row.del, props.nerd === true) : null}
218 {row.endMark ? openPill(Text, row.endMark, row.endColor, props.nerd === true, marks[index] ?? 0) : null}
219 </Box>
220 )
221 })}
222 </Box>
223 )
224}
225
226export default FileList
227hooks/look.tsx 279 lines1// hooks/look.tsx, by v-amorim
2// Shared look; every mod carries the same copy.
3import type { RenderElement } from 'claude-code'
4
5import style from './style'
6import type { Tip } from './entry'
7import { CAPS, decodeAll, MARK, mix, THEME, WASH } from './theme'
8
9type Glyphs = Omit<typeof style.glyphs.nerd, 'icon' | 'mark'> & { icon: Record<string, string>; mark: Record<string, string> }
10
11// Safe mode: common Unicode that every modern terminal font draws. Headings and rows carry no icon.
12const SAFE: Glyphs = decodeAll(style.glyphs.safe)
13
14// Nerd mode: Font Awesome, Material and VS Code glyphs from any Nerd Font, controls padded to the same three cells.
15const NERD: Glyphs = decodeAll(style.glyphs.nerd)
16
17export type Look = { nerd: boolean; glyph: typeof SAFE }
18
19export const lookFor = (nerd: boolean): Look => ({ nerd, glyph: nerd ? NERD : SAFE })
20
21// The icons setting every drawing mod declares in its userConfig.
22export type IconMode = 'auto' | 'nerd' | 'safe'
23
24export const terminalSettings = (localAppData: string) => [
25 `${localAppData}/Packages/Microsoft.WindowsTerminal_8wekyb3d8bbwe/LocalState/settings.json`,
26 `${localAppData}/Microsoft/Windows Terminal/settings.json`,
27]
28
29// Matches "Fira Code Nerd Font Mono" and the patched suffixes NF, NFM and NFP, as in "FiraCodeNFM-Reg".
30export const isNerdName = (face: string) => /nerd|\bNFM?\b/i.test(face) || /NF[MP]?(?![A-Za-z])/.test(face)
31
32export const isNerdFace = (settingsText: string) => [...settingsText.matchAll(/"face"\s*:\s*"([^"]*)"/g)].some(match => isNerdName(match[1]!))
33
34// The variables that name the terminal: WT_SESSION from Windows Terminal, TERM_PROGRAM from programs such as mintty and iTerm2.
35export type TerminalEnv = Partial<
36 Record<'WT_SESSION' | 'TERM_PROGRAM' | 'ITERM_PROFILE' | 'KITTY_WINDOW_ID' | 'ALACRITTY_WINDOW_ID' | 'GNOME_TERMINAL_SCREEN' | 'KONSOLE_VERSION', string | undefined>
37>
38
39// What detection reads: files by path, and the stdout of a command that exits 0 (it throws otherwise).
40export type Host = { localAppData: string; home: string; read: (path: string) => Promise<string>; run: (argv: string[]) => Promise<string> }
41
42// mintty inherits WT_SESSION from a Windows Terminal tab, so only an unset or Windows Terminal TERM_PROGRAM counts.
43export const isWindowsTerminal = ({ WT_SESSION, TERM_PROGRAM }: TerminalEnv) =>
44 Boolean(WT_SESSION) && (!TERM_PROGRAM || /windows\s*terminal/i.test(TERM_PROGRAM))
45
46// Terminals whose font sits in a text config: the first file that names a font wins, and within a file the first pattern.
47const CONFIG_TERMINALS: { is: (env: TerminalEnv) => boolean; files: (host: Host) => string[]; faces: RegExp[] }[] = [
48 {
49 is: env => env.TERM_PROGRAM === 'vscode',
50 files: ({ home, localAppData }) => [
51 `${home}/Library/Application Support/Code/User/settings.json`,
52 `${home}/.config/Code/User/settings.json`,
53 `${localAppData.replace(/\/Local$/, '/Roaming')}/Code/User/settings.json`,
54 ],
55 faces: [/"terminal\.integrated\.fontFamily"\s*:\s*"([^"]*)"/, /"editor\.fontFamily"\s*:\s*"([^"]*)"/],
56 },
57 {
58 is: env => env.TERM_PROGRAM === 'ghostty',
59 files: ({ home }) => [`${home}/.config/ghostty/config`, `${home}/Library/Application Support/com.mitchellh.ghostty/config`],
60 faces: [/^\s*font-family\s*=\s*"?([^"\n#]+)/m],
61 },
62 {
63 is: env => Boolean(env.KITTY_WINDOW_ID),
64 files: ({ home }) => [`${home}/.config/kitty/kitty.conf`],
65 faces: [/^\s*font_family\s+(.+)$/m],
66 },
67 {
68 is: env => env.TERM_PROGRAM === 'WezTerm',
69 files: ({ home }) => [`${home}/.wezterm.lua`, `${home}/.config/wezterm/wezterm.lua`],
70 faces: [/wezterm\.font(?:_with_fallback)?\(\s*\{?\s*["']([^"']+)["']/],
71 },
72 {
73 is: env => Boolean(env.ALACRITTY_WINDOW_ID),
74 files: ({ home }) => [`${home}/.config/alacritty/alacritty.toml`],
75 faces: [/\[font\.normal\][^[]*?family\s*=\s*"([^"]+)"/],
76 },
77]
78
79// iTerm2 keeps its profiles in a plist; plutil prints the part asked for. The profile is the one this window runs, else the default.
80async function itermFace(env: TerminalEnv, host: Host) {
81 const plist = `${host.home}/Library/Preferences/com.googlecode.iterm2.plist`
82 const profiles: { Name?: string; Guid?: string; 'Normal Font'?: string }[] = JSON.parse(await host.run(['plutil', '-extract', 'New Bookmarks', 'json', '-o', '-', plist]))
83 const named = profiles.find(profile => profile.Name === env.ITERM_PROFILE)
84 if (named) return named['Normal Font']
85 const guid = (await host.run(['plutil', '-extract', 'Default Bookmark Guid', 'raw', '-o', '-', plist])).trim()
86 return profiles.find(profile => profile.Guid === guid)?.['Normal Font']
87}
88
89// GNOME Terminal keeps the default profile's font in dconf, and ignores it while the profile uses the system font.
90async function gnomeFace(host: Host) {
91 const unquote = (text: string) => text.trim().replace(/^'|'$/g, '')
92 const schema = `org.gnome.Terminal.Legacy.Profile:/org/gnome/terminal/legacy/profiles:/:${unquote(await host.run(['gsettings', 'get', 'org.gnome.Terminal.ProfilesList', 'default']))}/`
93 if (unquote(await host.run(['gsettings', 'get', schema, 'use-system-font'])) === 'true') return undefined
94 return unquote(await host.run(['gsettings', 'get', schema, 'font']))
95}
96
97// Konsole names its profile in konsolerc, and the profile file holds `Font=Name,size,...`.
98async function konsoleFace(host: Host) {
99 const profile = /^DefaultProfile=(.+)$/m.exec(await host.read(`${host.home}/.config/konsolerc`))?.[1]?.trim()
100 return profile ? /^Font=([^,\n]+)/m.exec(await host.read(`${host.home}/.local/share/konsole/${profile}`))?.[1] : undefined
101}
102
103async function configFace(terminal: (typeof CONFIG_TERMINALS)[number], host: Host) {
104 for (const path of terminal.files(host)) {
105 const text = await host.read(path).catch(() => '')
106 for (const pattern of terminal.faces) {
107 const face = pattern.exec(text)?.[1]
108 if (face) return face
109 }
110 }
111 return undefined
112}
113
114function terminalFace(env: TerminalEnv, host: Host) {
115 if (env.TERM_PROGRAM === 'iTerm.app') return itermFace(env, host)
116 if (env.GNOME_TERMINAL_SCREEN) return gnomeFace(host)
117 if (env.KONSOLE_VERSION) return konsoleFace(host)
118 const terminal = CONFIG_TERMINALS.find(candidate => candidate.is(env))
119 return terminal ? configFace(terminal, host) : Promise.resolve(undefined)
120}
121
122// Every mod looks up the font on start at once, so a lookup can fail by chance; a third failure throws and the caller keeps its saved answer.
123async function retried<T>(attempt: () => Promise<T>, tries = 3): Promise<T> {
124 for (let left = tries - 1; ; left--) {
125 try {
126 return await attempt()
127 } catch (error) {
128 if (left === 0) throw error
129 }
130 }
131}
132
133// Nerd glyphs only when the terminal this window runs in names a Nerd font; an unknown terminal stays safe, and a lookup that keeps failing throws.
134export async function detectNerd(env: TerminalEnv, host: Host) {
135 if (isWindowsTerminal(env)) {
136 for (const path of terminalSettings(host.localAppData)) {
137 if (isNerdFace(await host.read(path).catch(() => ''))) return true
138 }
139 return false
140 }
141 const face = await retried(() => terminalFace(env, host))
142 return face !== undefined && isNerdName(face)
143}
144
145// `text` led by its icon in Nerd mode; the text alone in safe mode or when the name has no icon.
146export const withIcon = (look: Look, name: string, text: string) => (look.glyph.icon[name] ? `${look.glyph.icon[name]} ${text}` : text)
147
148// A status note with its leading mark (MARK.warn or MARK.ok from theme.ts) drawn as this look's glyph.
149export function withMark(look: Look, note: string) {
150 if (note.startsWith('! ')) return `${look.glyph.warn} ${note.slice(2)}`
151 if (note.startsWith('✓ ')) return `${look.glyph.ok} ${note.slice(2)}`
152 return note
153}
154
155type Elements = { Box: (props: any) => RenderElement; Text: (props: any) => RenderElement }
156
157// A pane's title line: the name's icon in Nerd mode, the safe mark otherwise.
158export const paneTitle = ({ Text }: Elements, look: Look, icon: string, text: string) => (
159 <Text key="pane-title" color={THEME.accent} bold>{`${look.glyph.icon[icon] ?? look.glyph.title} ${text}`}</Text>
160)
161
162// One line of text in `color` with every separator dot in the separator colour, since a dot in the text's colour reads as part of the words.
163export function sepText({ Text }: Pick<Elements, 'Text'>, text: string, color: string, key: string, props: Record<string, unknown> = {}) {
164 return (
165 <Text key={key} {...props}>
166 {text.split(MARK.sep).flatMap((part, at) => [
167 ...(at > 0 ? [<Text key={`${key}-sep-${at}`} color={style.colors.pills.separator}>{MARK.sep}</Text>] : []),
168 <Text key={`${key}-part-${at}`} color={color}>{part}</Text>,
169 ])}
170 </Text>
171 )
172}
173
174// The footer rows every pane reserves at its bottom: a rule, then the hovered control's glyph, label and what it does.
175export const FOOTER_ROWS = 2
176
177// A Button cannot post a tip, so its tip is drawn hidden and shown by the hover group the Button's pill shares.
178export type Reveal = { scope: string; tip: Tip }
179
180const tipRow = ({ Box, Text }: Elements, key: string, tip: Tip | null, columns: number, extra: Record<string, unknown> = {}) => (
181 <Box key={key} flexDirection="row" paddingX={1} width={columns} height={1} backgroundColor={THEME.bg} {...extra}>
182 {tip ? <Text color={THEME.accent} backgroundColor={THEME.bg}>{tip.glyph}</Text> : null}
183 {tip ? <Box flexShrink={0}><Text color={THEME.onSurface} backgroundColor={THEME.bg}>{` ${tip.label}`}</Text></Box> : null}
184 {tip ? sepText({ Text }, ` ${MARK.sep} ${tip.text}`, THEME.muted, `${key}-text`, { backgroundColor: THEME.bg, wrap: 'truncate-end' }) : <Text backgroundColor={THEME.bg}> </Text>}
185 </Box>
186)
187
188// Pinned at the pane bottom (scroll offset plus body rows) on its own background, so no row shows through; the row under the rule stays blank with no tip, so nothing jumps.
189export function paneFooter(elements: Elements, tip: Tip | null, columns: number, scroll: { offset: number; bodyRows: number }, reveals: Reveal[] = []) {
190 const { Box, Text } = elements
191 return (
192 <Box key="pane-footer" position="absolute" top={Math.max(0, scroll.offset + scroll.bodyRows - FOOTER_ROWS)} left={0} width={columns} height={FOOTER_ROWS} flexDirection="column" backgroundColor={THEME.bg}>
193 <Text key="footer-rule" color={THEME.border} backgroundColor={THEME.bg}>{MARK.rule.repeat(Math.max(1, columns))}</Text>
194 {tipRow(elements, 'footer-tip', tip, columns)}
195 {reveals.map((reveal, at) =>
196 tipRow(elements, `footer-reveal-${at}`, reveal.tip, columns, { position: 'absolute', top: 1, left: 0, display: 'none', hover: { scope: reveal.scope, display: 'flex' } }),
197 )}
198 </Box>
199 )
200}
201
202// A pill's leading icon, mark or digit, or its count: a rounded subpill washed from `hue` into the `fill` it sits on.
203export function subpill({ Box, Text }: Elements, nerd: boolean, fill: string, text: string, key: string, hue: string = THEME.accent) {
204 const bg = mix(hue, fill, WASH)
205 return (
206 <Box key={key} flexDirection="row" flexShrink={0}>
207 {nerd ? <Text key={`${key}-left`} color={bg} backgroundColor={fill}>{CAPS.left}</Text> : null}
208 <Text key={`${key}-text`} color={hue} backgroundColor={bg} bold>{nerd ? text : ` ${text} `}</Text>
209 {nerd ? <Text key={`${key}-right`} color={bg} backgroundColor={fill}>{CAPS.right}</Text> : null}
210 </Box>
211 )
212}
213
214// A section's heading: a surface3 pill led by its icon as a subpill in Nerd mode, bold accent text in safe mode.
215export function heading(elements: Elements, look: Look, key: string, icon: string, title: string) {
216 const { Box, Text } = elements
217 if (!look.nerd) {
218 return (
219 <Box key={`${key}-head`}>
220 <Text color={THEME.accent} bold>{title}</Text>
221 </Box>
222 )
223 }
224 const glyph = look.glyph.icon[icon]
225 return (
226 <Box key={`${key}-head`} flexDirection="row">
227 <Text color={THEME.surface3}>{CAPS.left}</Text>
228 <Box key={`${key}-head-body`} flexDirection="row" backgroundColor={THEME.surface3}>
229 {glyph ? subpill(elements, true, THEME.surface3, glyph, `${key}-head-icon`) : null}
230 <Text color={THEME.onSurface} bold>{glyph ? ` ${title}` : title}</Text>
231 </Box>
232 <Text color={THEME.surface3}>{CAPS.right}</Text>
233 </Box>
234 )
235}
236
237// A card's fill: surface1 washed lightly toward its hue, under a neutral border. Pills on the card sit on it.
238export const cardFill = (hue: string = THEME.accent) => mix(THEME.surface1, hue, 0.06)
239
240// The row above a card, painted on bg so a scrolled card above never shows through the gap.
241export function cardGap({ Box }: Elements, key: string, bg: string = THEME.bg) {
242 return <Box key={`${key}-gap`} height={1} flexShrink={0} backgroundColor={bg} />
243}
244
245// A card's title laid over its top border after the corner (`top` is the border's row); Pane refuses borderTop, so the border stays whole underneath.
246export function titleEdge(elements: Elements, look: Look, key: string, icon: string, title: string, hint = '', top = 1, edge: string = THEME.border) {
247 const { Box, Text } = elements
248 const glyph = look.nerd ? look.glyph.icon[icon] : undefined
249 return (
250 <Box key={`${key}-edge`} position="absolute" top={top} left={1} flexDirection="row" height={1} backgroundColor={THEME.bg}>
251 <Text color={edge}>{`${MARK.rule} `}</Text>
252 {glyph ? <Text color={THEME.accent}>{`${glyph} `}</Text> : null}
253 {sepText(elements, title, THEME.onSurface, `${key}-title`, { bold: true })}
254 {hint ? sepText(elements, ` ${hint}`, THEME.muted, `${key}-hint`) : null}
255 <Text> </Text>
256 </Box>
257 )
258}
259
260// A round card tinted toward `hue`, its title (and muted `hint`) set into the top edge, `right` (such as Fold all) at that edge's end.
261export function section(elements: Elements, look: Look, key: string, icon: string, title: string, body: unknown, hue: string = THEME.accent, { hint = '', right }: { hint?: string; right?: unknown } = {}) {
262 const { Box } = elements
263 return (
264 <Box key={`${key}-block`} flexDirection="column">
265 {cardGap(elements, key)}
266 <Box key={key} flexDirection="column" borderStyle="round" borderColor={THEME.border} backgroundColor={cardFill(hue)} paddingX={1}>
267 {body}
268 </Box>
269 {title ? titleEdge(elements, look, key, icon, title, hint) : null}
270 {right ? (
271 <Box key={`${key}-edge-right`} position="absolute" top={1} right={1} flexDirection="row" height={1} backgroundColor={THEME.bg}>
272 {right}
273 </Box>
274 ) : null}
275 </Box>
276 )
277}
278
279hooks/entry.tsx 241 lines1// hooks/entry.tsx, by v-amorim
2// Shared pill entry; every mod that draws a menu entry carries the same copy.
3import type { ClientModule, ClientSurface } from 'claude-code'
4
5import style from './style'
6import { CAPS, mix, THEME, WASH } from './theme'
7
8// color: open text (a red Run); idle: closed text, muted unless set; isBusy shimmers the label until the click's result lands.
9// still: the mod's animations setting is off, so nothing shimmers and the hover color switches at once.
10// lead: a bare glyph before the label in leadColor (after a space, none when tight: the cap is the gap); subLead: an icon before it as a rounded subpill in subLeadColor;
11// badge: a count after it in its own pill. Subpills are capped where `nerd` has the caps.
12// tight: no label padding; tip: posts `tip` instead of `menu-hover`; rail: a divider (a Text between Clients squeezes them); rule: a full-width hairline.
13// spinner: the braille dots lead the label in accent while work runs (write the label without its …); still holds the first frame.
14export type EntryProps = {
15 id: string
16 label: string
17 isOpen: boolean
18 bg: string
19 color?: string
20 idle?: string
21 isBusy?: boolean
22 still?: boolean
23 lead?: string
24 leadColor?: string
25 subLead?: string
26 subLeadColor?: string
27 badge?: { text: string; color: string; bg: string }
28 nerd?: boolean
29 tight?: boolean
30 tip?: Tip
31 rail?: boolean
32 rule?: boolean
33 spinner?: boolean
34 compact?: boolean // false: the pill row may cut the entry to its lead when the row is wider than the band
35}
36
37const entryText = (props: EntryProps) => {
38 const pad = props.tight ? (props.isOpen && !props.nerd ? ' ' : '') : ' '
39 return `${pad}${props.compact && props.lead ? '' : props.label}${props.tight && props.badge ? ' ' : pad}`
40}
41
42// The cells an entry takes; subpills add their two caps, or their two padding spaces in safe mode.
43export const entryCells = (props: EntryProps) => {
44 if (props.rail) return 1
45 const cells = (text: string) => [...text].length
46 const leadText = props.lead ?? (props.spinner ? SPIN_FRAMES[0]! : '')
47 const lead = leadText ? cells(leadText) + (props.tight ? 0 : 1) : 0
48 return lead + (props.subLead ? cells(props.subLead) + 2 : 0) + cells(entryText(props)) + (props.badge ? cells(props.badge.text) + 2 : 0)
49}
50
51// A hovered control's glyph, label and what it does; the pane footer and the band's tip row both show it.
52export type Tip = { glyph: string; label: string; text: string }
53
54// Posted to the owning mod's ui.message when the pointer enters a control (id set) or leaves it (id null).
55export type TipMessage = { type: 'tip'; id: string | null; glyph?: string; label?: string; text?: string }
56
57export const isTipMessage = (data: unknown): data is TipMessage => (data as TipMessage | undefined)?.type === 'tip'
58
59// The band's tip row text for a tip message: "Label: what it does", or '' once the pointer left.
60export const tipText = (message: TipMessage | null | undefined) => (message?.id && message.label ? `${message.label}: ${message.text ?? ''}` : '')
61
62// A control calls this from onPointer, with its tip on enter and null on leave; never also `menu-hover`, one post per frame lands.
63export const postTip = (surface: { post: (data: any) => void }, id: string, tip: Tip | null) => {
64 surface.post((tip ? { type: 'tip', id, glyph: tip.glyph, label: tip.label, text: tip.text } : { type: 'tip', id: null }) satisfies TipMessage)
65}
66
67// menu-hover tells the owning mod the pointer arrived or left, so it can show its tip under the row.
68export type EntryMessage = { type: 'menu-entry'; id: string } | { type: 'menu-hover'; id: string; isOver: boolean }
69
70// A dialog opening under the pointer can swallow the leave event, so a hover with no pointer event for this long ends on its own.
71const HOVER_QUIET_MS = 5000
72
73export const TICK_MS = 140
74
75// The loading shimmer: a soft primary-70 band sweeps the dim text, then a pause.
76const BAND = style.colors.shimmer
77const SWEEP_PAUSE = 8
78
79export const shimmerColor = (at: number, phase: number, length: number) => {
80 const period = length + BAND.length * 2 + SWEEP_PAUSE
81 const head = (phase % period) - BAND.length
82 return BAND[Math.abs(at - head)] ?? style.colors.pills.readout
83}
84
85// The braille dots spinner, one frame every SPIN_MS, in accent.
86export const SPIN_FRAMES = [...style.marks.spinner]
87export const SPIN_MS = 80
88
89// The pointer lights a control at once and its color fades out over this many steps, one every HOVER_TICK_MS.
90export const HOVER_STEPS = 2
91
92export const HOVER_TICK_MS = 60
93
94export { mix }
95
96// One step of a hover fade: full at once while the pointer is on, one step toward 0 once it left.
97export const stepToward = (level: number, isOver: boolean) => (isOver ? HOVER_STEPS : Math.max(0, level - 1))
98
99// Even steps: an eased last step barely moves the color, so the fade seemed to hang at its end.
100export const hoverMix = (from: string, to: string, level: number) => mix(from, to, level / HOVER_STEPS)
101
102// A click shimmers the entry until the mod answers with new props, or this long at most.
103const PRESS_MS = 1200
104
105// pressed: the props a click saw and when it came, cleared once the mod answers.
106type State = { isOver: boolean; at: number; phase: number; level?: number; spin?: number; pressed?: { props: string; at: number } }
107
108const isPressed = (state: State | undefined, props: EntryProps | undefined) =>
109 Boolean(state?.pressed && Date.now() - state.pressed.at < PRESS_MS && state.pressed.props === JSON.stringify(props))
110
111// Each instance's running fade-out timer, started by the leave event and stopped at 0 or when the pointer returns.
112const fades = new WeakMap<object, () => void>()
113
114const startFade = (surface: ClientSurface<State>) => {
115 if (fades.has(surface)) return
116 const stop = surface.every(HOVER_TICK_MS, () => {
117 const { state } = surface
118 if (!state) return
119 const level = stepToward(state.level ?? 0, state.isOver)
120 if (level !== (state.level ?? 0)) surface.setState({ ...state, level })
121 if (state.isOver || level === 0) {
122 stop()
123 fades.delete(surface)
124 }
125 })
126 fades.set(surface, stop)
127}
128
129// The ticker reads the latest props from here, since its closure keeps the first ones.
130const latest = new WeakMap<object, EntryProps>()
131
132// Each instance's running tick: it runs only while a shimmer moves or a hover is watched, so idle pills cost no wakeups.
133const ticks = new WeakMap<object, () => void>()
134
135const startTick = (surface: ClientSurface<State>) => {
136 if (ticks.has(surface)) return
137 const stop = surface.every(TICK_MS, () => {
138 const { state } = surface
139 if (!state) return
140 const now = latest.get(surface)
141 if (!now?.still && (now?.isBusy || isPressed(state, now))) surface.setState({ ...state, phase: state.phase + 1 })
142 else if (state.pressed) surface.setState({ ...state, pressed: undefined })
143 else if (state.isOver && Date.now() - state.at >= HOVER_QUIET_MS) {
144 surface.setState({ ...state, isOver: false, at: Date.now() })
145 if (!now?.still) startFade(surface)
146 const id = now?.id ?? ''
147 if (now?.tip) postTip(surface, id, null)
148 else surface.post({ type: 'menu-hover', id, isOver: false } satisfies EntryMessage)
149 } else if (!state.isOver) {
150 stop()
151 ticks.delete(surface)
152 }
153 })
154 ticks.set(surface, stop)
155}
156
157// Each spinning instance's frame clock, stopped once the entry stops spinning.
158const spins = new WeakMap<object, () => void>()
159
160const startSpin = (surface: ClientSurface<State>) => {
161 if (spins.has(surface)) return
162 const stop = surface.every(SPIN_MS, () => {
163 const { state } = surface
164 const now = latest.get(surface)
165 if (!state || !now?.spinner || now.still) {
166 stop()
167 spins.delete(surface)
168 return
169 }
170 surface.setState({ ...state, spin: (state.spin ?? 0) + 1 })
171 })
172 spins.set(surface, stop)
173}
174
175// A Button inverts under the pointer, so the entry is plain text that lights up like a sidebar row.
176const Entry: ClientModule<EntryProps, State> = (props, surface) => {
177 const { Box, Text } = surface.elements
178 if (props.rail) return <Text color={style.colors.pills.separator} backgroundColor={props.bg}>{style.marks.rail}</Text>
179 if (props.rule) return <Text color={THEME.border} backgroundColor={props.bg}>{style.marks.rule.repeat(Math.max(1, surface.columns))}</Text>
180 const hovered = surface.state?.isOver === true
181 latest.set(surface, props)
182
183 if (!surface.state) surface.setState({ isOver: false, at: 0, phase: 0 })
184 if (props.isBusy && !props.still) startTick(surface)
185 if (props.spinner && !props.still) startSpin(surface)
186
187 surface.onPointer(event => {
188 startTick(surface)
189 const over = event.type !== 'leave'
190 if (over !== hovered) {
191 if (props.tip) postTip(surface, props.id, over ? props.tip : null)
192 else surface.post({ type: 'menu-hover', id: props.id, isOver: over } satisfies EntryMessage)
193 }
194 const isClick = event.type === 'down' && event.button === 'left'
195 const base = surface.state ?? { phase: 0, isOver: false, at: 0 }
196 const level = over === base.isOver ? base.level : over ? HOVER_STEPS : props.still ? 0 : base.level
197 if (!over && !props.still) startFade(surface)
198 surface.setState({ ...base, isOver: over, at: Date.now(), level, pressed: isClick ? { props: JSON.stringify(props), at: Date.now() } : base.pressed })
199 if (isClick) surface.post({ type: 'menu-entry', id: props.id } satisfies EntryMessage)
200 })
201
202 const text = entryText(props)
203 const isShimmering = !props.still && (props.isBusy || isPressed(surface.state, props))
204 const phase = surface.state?.phase ?? 0
205 const label = isShimmering ? (
206 <Box key="label" flexDirection="row">
207 {[...text].map((char, at) => (
208 <Text key={`c${at}`} color={shimmerColor(at, phase, text.length)} backgroundColor={props.bg}>
209 {char}
210 </Text>
211 ))}
212 </Box>
213 ) : (
214 <Text key="label" color={hoverMix(props.isOpen ? (props.color ?? THEME.onSurface) : (props.idle ?? THEME.muted), THEME.accent, surface.state?.level ?? 0)} bold={props.isOpen && Boolean(props.idle)} backgroundColor={props.bg}>
215 {text.split(style.marks.sep).flatMap((part, at) => (at === 0 ? [part] : [<Text key={`sep-${at}`} color={style.colors.pills.separator}>{style.marks.sep}</Text>, part]))}
216 </Text>
217 )
218 const lead = props.spinner ? SPIN_FRAMES[props.still ? 0 : (surface.state?.spin ?? 0) % SPIN_FRAMES.length] : props.lead
219 if (!lead && !props.subLead && !props.badge) return isShimmering ? <Box flexDirection="row">{label}</Box> : label
220
221 const sub = (key: string, text: string, color: string, bg: string) => (
222 <Box key={key} flexDirection="row">
223 {props.nerd ? <Text key={`${key}-left`} color={bg} backgroundColor={props.bg}>{CAPS.left}</Text> : null}
224 <Text key={`${key}-text`} color={color} backgroundColor={bg} bold>{props.nerd ? text : ` ${text} `}</Text>
225 {props.nerd ? <Text key={`${key}-right`} color={bg} backgroundColor={props.bg}>{CAPS.right}</Text> : null}
226 </Box>
227 )
228 const subHue = props.subLeadColor ?? THEME.accent
229 const { badge } = props
230 return (
231 <Box flexDirection="row">
232 {lead ? <Text key="lead" color={props.spinner ? THEME.accent : (props.leadColor ?? THEME.accent)} backgroundColor={props.bg}>{props.tight ? lead : ` ${lead}`}</Text> : null}
233 {props.subLead ? sub('sub-lead', props.subLead, subHue, mix(subHue, props.bg, WASH)) : null}
234 {label}
235 {badge ? sub('badge', badge.text, badge.color, badge.bg) : null}
236 </Box>
237 )
238}
239
240export default Entry
241hooks/pills.tsx 167 lines1// hooks/pills.tsx, by v-amorim
2// Shared pill row; every mod that draws in the row carries the same copy.
3import type { RenderElement } from 'claude-code'
4
5import { entryCells, mix, type EntryProps, type Tip } from './entry'
6import style from './style'
7import { CAPS, decodeAll, THEME, WASH } from './theme'
8
9// The menu group on Moonlight surface-3, readouts on surface-2.
10export const PILL = { primary: style.colors.pills.primary, secondary: style.colors.pills.secondary }
11
12// Menu entries left to right, by the id in their `menu-pad-<id>` Box key; an unknown id goes last.
13const ENTRY_ORDER = ['manager', 'skills', 'prompts', 'filetree', 'progress', 'headsup']
14
15type Node = { props?: Record<string, unknown>; children?: unknown[] } | null | undefined
16type Elements = { Box: (props: any) => RenderElement; Text: (props: any) => RenderElement }
17
18const keyOf = (node: unknown) => (node as Node)?.props?.key as string | undefined
19const childrenOf = (node: unknown) => (node as Node)?.children ?? []
20
21// Splits next(e) into the menu entries other mods drew, the rest of the row, what else the band holds, and the hover tip under the row.
22export function splitRow(others: unknown) {
23 if (keyOf(others) !== 'menu-rows') return { group: [] as unknown[], row: [] as unknown[], rest: [others], tip: null as unknown }
24 const children = childrenOf(others)
25 const rest = children.filter(child => keyOf(child) !== 'menu-row' && keyOf(child) !== 'menu-tip')
26 const pills = childrenOf(children.find(child => keyOf(child) === 'menu-row'))
27 const groupPill = pills.find(child => keyOf(child) === 'menu-group')
28 const body = childrenOf(groupPill).find(child => keyOf(child) === 'menu-group-body')
29 return {
30 // A mod still running an older copy may draw `menu-group-sep-*` separators between entries; the group drops them.
31 group: childrenOf(body).filter(child => !keyOf(child)?.startsWith('menu-group-sep')),
32 row: pills.filter(child => keyOf(child) !== 'menu-group'),
33 rest,
34 tip: children.find(child => keyOf(child) === 'menu-tip') ?? null,
35 }
36}
37
38// The one-line tip above the pill row, filled while the pointer is on an entry: its "Name:" in bold accent, the rest muted.
39export const tipLine = ({ Box, Text }: Elements, text: string) => {
40 const at = text.indexOf(': ')
41 return (
42 <Box key="menu-tip" paddingLeft={2} flexDirection="row">
43 {at < 0 ? null : <Text color={THEME.accent} bold>{text.slice(0, at + 1)}</Text>}
44 <Text color={THEME.muted}>
45 {(at < 0 ? text : text.slice(at + 1)).split(style.marks.sep).flatMap((part, index) => (index === 0 ? [part] : [<Text key={`sep-${index}`} color={style.colors.pills.separator}>{style.marks.sep}</Text>, part]))}
46 </Text>
47 </Box>
48 )
49}
50
51// One pill with its own caps where the font has them; `on` is what it sits on, `hover` lights the caps with a Button scoped `key`.
52export function pill({ Box, Text }: Elements, nerd: boolean, bg: string, body: unknown, key = 'menu-pill', on?: string, hover?: string) {
53 const under = on ? { backgroundColor: on } : {}
54 const lit = hover ? { hover: { scope: key, color: hover } } : {}
55 return (
56 <Box key={key} flexDirection="row">
57 {nerd ? <Text key={`${key}-cap-left`} color={bg} {...under} {...lit}>{CAPS.left}</Text> : null}
58 <Box key={`${key}-body`} flexDirection="row" backgroundColor={bg}>
59 {body}
60 </Box>
61 {nerd ? <Text key={`${key}-cap-right`} color={bg} {...under} {...lit}>{CAPS.right}</Text> : null}
62 </Box>
63 )
64}
65
66// mod-menu's `labels`: `always` icon and label, `never` the icon alone, `auto` the icon alone while the row overflows (Nerd only; safe keeps labels).
67export type LabelsMode = 'auto' | 'always' | 'never'
68
69// The value of `{ plugin: 'mod-menu', key: 'labels' }` as a mode; a missing or unknown value is `auto`, so a mod works without mod-menu.
70export const labelsOf = (value: unknown): LabelsMode => (value === 'always' || value === 'never' ? value : 'auto')
71
72type ClientNode = { type: 'Client'; props: { props: Record<string, unknown> } }
73const isClient = (node: unknown): node is ClientNode => (node as { type?: string } | null)?.type === 'Client'
74
75// The cells a drawn row takes with every label shown: a Client that may go compact says its own `cells`, or is an entry.tsx one.
76const cellsOf = (node: unknown): number => {
77 if (Array.isArray(node)) return node.reduce((sum: number, each) => sum + cellsOf(each), 0)
78 if (typeof node === 'string') return [...node].length
79 if (!node || typeof node !== 'object') return 0
80 if (isClient(node)) {
81 const { props } = node.props
82 return typeof props.cells === 'number' ? props.cells : entryCells({ ...(props as EntryProps), compact: false })
83 }
84 // A `menu-flash` word comes and goes with the pointer; counting it would flip the row under the pointer.
85 const kids = childrenOf(node).filter(each => each && !keyOf(each)?.startsWith('menu-flash'))
86 return kids.reduce((sum: number, each) => sum + cellsOf(each), Number((node as Node)?.props?.columnGap ?? 0) * Math.max(0, kids.length - 1))
87}
88
89const compacted = (node: unknown): unknown => {
90 if (Array.isArray(node)) return node.map(compacted)
91 if (!node || typeof node !== 'object') return node
92 if (isClient(node)) return node.props.props.compact === false ? { ...node, props: { ...node.props, props: { ...node.props.props, compact: true } } } : node
93 const kids = (node as Node)?.children
94 return kids ? { ...node, children: kids.map(compacted) } : node
95}
96
97// A panel's entry in the menu group, keyed `menu-pad-<id>` for the row's order; the open one is a pill of its own.
98export function menuEntry({ Box, Client, Text }: Elements & { Client: (props: any) => RenderElement }, nerd: boolean, id: string, { labels, ...props }: Omit<EntryProps, 'id' | 'bg' | 'tight'> & { labels?: LabelsMode }) {
99 // The open panel reads as selected, like an On switch: ok text on ok washed into the group fill.
100 const bg = props.isOpen ? mix(THEME.ok, PILL.primary, WASH) : PILL.primary
101 const glyph = (style.glyphs.nerd.icon as Record<string, string>)[`menu-${id}`]
102 const icon = nerd && !props.lead && glyph ? decodeAll(glyph) : undefined
103 // The icon sits tight against the cap, so the label that follows brings its own gap.
104 const shown = icon ? { lead: icon, label: labels === 'never' ? '' : ` ${props.label}`, ...(labels === 'auto' ? { compact: false } : {}) } : {}
105 // On Nerd the caps take the label's padding cells, so an entry is as wide as before the caps.
106 const entry = <Client key={`menu-entry-${id}`} module="./entry.tsx" height={1} props={{ id, bg, ...props, ...(props.isOpen ? { color: THEME.ok } : {}), ...shown, nerd, tight: nerd } satisfies EntryProps} />
107 return (
108 <Box key={`menu-pad-${id}`} flexDirection="row">
109 {/* A closed entry gets caps in the group's own color, so opening a panel never shifts its neighbours. */}
110 {pill({ Box, Text }, nerd, bg, entry, props.isOpen ? `menu-open-${id}` : `menu-closed-${id}`, PILL.primary)}
111 </Box>
112 )
113}
114
115// The band above the prompt: what else it holds (Claude Code's own effort line), then the pill row, so the pills sit on the input.
116export function pillRow(elements: Elements & { Client?: (props: any) => RenderElement }, nerd: boolean, group: unknown[], row: unknown[], rest: unknown[], tip: unknown = null, columns?: number) {
117 const { Box, Client, Text } = elements
118 // Load order changes with an install or a toggle, so entries sort by a fixed rank.
119 const rank = (entry: unknown) => {
120 const at = ENTRY_ORDER.indexOf(String(keyOf(entry) ?? '').replace(/^menu-pad-/, ''))
121 return at < 0 ? ENTRY_ORDER.length : at
122 }
123 // Like a choice bar: the entries side by side on one pill, the open panel's lifted into its own, a divider Client between neighbours.
124 const sorted = [...group].sort((a, b) => rank(a) - rank(b))
125 const entries = Client
126 ? sorted.flatMap((entry, at) => (at === 0 ? [entry] : [<Client key={`menu-group-sep-${at}`} module="./entry.tsx" height={1} props={{ id: `sep-${at}`, label: '', isOpen: false, bg: PILL.primary, rail: true } satisfies EntryProps} />, entry]))
127 : sorted
128 const pills = (
129 <Box key="menu-row" flexDirection="row" columnGap={1}>
130 {group.length > 0 ? pill(elements, nerd, PILL.primary, entries, 'menu-group') : null}
131 {row}
132 </Box>
133 )
134 // Measured with every label shown, so a row cut to icons never grows back into a row that does not fit.
135 const fits = columns === undefined || cellsOf(pills) <= columns
136 return (
137 <Box key="menu-rows" flexDirection="column">
138 {rest}
139 {/* Above the row, kept even when empty so nothing above jumps when a tip appears. */}
140 {tip ?? tipLine(elements, ' ')}
141 {fits ? pills : compacted(pills)}
142 </Box>
143 )
144}
145
146// One chip per view of a panel (its list, its settings), standing in for the title: id, label, icon glyph, an optional count and an optional hover tip.
147export type Tab = { id: string; label: string; icon: string; isOpen: boolean; count?: number; tip?: Tip }
148
149// A panel's title line: the open view on surface3 and bold, the others on surface2 and muted; a click swaps the view.
150export function tabRow({ Box, Client, Text }: Elements & { Client: (props: any) => RenderElement }, nerd: boolean, tabs: Tab[], still: boolean) {
151 return (
152 <Box key="tab-row" flexDirection="row" columnGap={1}>
153 {tabs.map(tab => {
154 const bg = tab.isOpen ? THEME.surface3 : THEME.surface2
155 // Client props refuse an undefined value, so a tab without a count or tip leaves the key out.
156 const countColor = tab.isOpen ? THEME.accent : style.colors.pills.readout
157 const badge = tab.count === undefined ? {} : { badge: { text: String(tab.count), color: countColor, bg: mix(countColor, bg, WASH) } }
158 const tip = tab.tip ? { tip: tab.tip } : {}
159 const entry = (
160 <Client key={tab.id} module="./entry.tsx" height={1} props={{ id: tab.id, label: tab.label, isOpen: tab.isOpen, bg, idle: THEME.muted, still, subLead: tab.icon, subLeadColor: THEME.accent, nerd, ...(tab.isOpen ? { color: THEME.secondary } : {}), ...badge, ...tip } satisfies EntryProps} />
161 )
162 return pill({ Box, Text }, nerd, bg, entry, `${tab.id}-pill`)
163 })}
164 </Box>
165 )
166}
167hooks/toasts.tsx 87 lines1// hooks/toasts.tsx, by v-amorim
2// Shared toast cards; every mod that toasts carries the same copy.
3import type { RenderElement } from 'claude-code'
4
5import type { Tip } from './entry'
6import style from './style'
7import { CAPS, MARK, mix, THEME } from './theme'
8
9type Elements = { Box: (props: any) => RenderElement; Client: (props: any) => RenderElement; Text: (props: any) => RenderElement }
10
11// The slice of a mod's Look the cards read, typed here because mod-menu has no look.tsx.
12type ToastLook = { nerd: boolean; glyph: { close: string } }
13
14export const TOAST_MS = 4000
15
16const DISMISS_TIP = { label: 'Dismiss', text: 'hides this message' }
17const MAX_TOASTS = 3
18
19// A mod's own toasts, newest last. A toast reaches only the hooks above its caller, so each mod catches its own.
20export class ToastList {
21 items: { id: number; text: string }[] = []
22 // The owning mod's animations setting is off, so the dismiss × does not fade.
23 isStill = false
24 private lastId = 0
25
26 // `prefix` keeps the dismiss Client keys apart from other mods' cards in the band.
27 constructor(readonly prefix: string) {}
28
29 add(text: string) {
30 const id = (this.lastId += 1)
31 this.items = [...this.items, { id, text }].slice(-MAX_TOASTS)
32 return id
33 }
34
35 drop(id: number) {
36 this.items = this.items.filter(toast => toast.id !== id)
37 }
38
39 // The toast a dismiss click names, or undefined when the click was not on one of these cards.
40 idOf(entryId: string) {
41 const head = `toast-${this.prefix}-`
42 return entryId.startsWith(head) ? Number(entryId.slice(head.length)) : undefined
43 }
44}
45
46// A pill already frames the glyph, so drop the brackets and padding of the standalone button (safe `[×]`).
47const bare = (glyph: string) => glyph.replace(/[[\]\s]/g, '')
48
49// Callers write `✓ Saved` or `! Failed`; the card draws the mark.
50function toneOf(text: string) {
51 for (const [mark, color, bold] of [[MARK.ok, THEME.good, false], [MARK.warn, THEME.alert, true]] as const) {
52 if (text.startsWith(mark)) return { mark, color, bold, text: text.slice(mark.length).trimStart() }
53 }
54 return { mark: MARK.bullet, color: THEME.accent, bold: false, text }
55}
56
57// The web toast as a band card: tinted icon square, "Title: detail" split bold and muted, an error edged in alert; closeTip is the × tip.
58export const toastCards = ({ Box, Client, Text }: Elements, list: ToastList, look?: ToastLook, closeTip: Pick<Tip, 'label' | 'text'> = DISMISS_TIP) =>
59 list.items.map(toast => {
60 const id = `toast-${list.prefix}-${toast.id}`
61 const tone = toneOf(toast.text)
62 const nerd = look?.nerd === true
63 const close = bare(look?.glyph.close ?? style.glyphs.safe.close)
64 const fill = mix(THEME.surface1, tone.color, 0.06)
65 const split = tone.text.indexOf(': ')
66 const [title, detail] = split > 0 ? [tone.text.slice(0, split), tone.text.slice(split + 2)] : [tone.text, '']
67 const edge = tone.color === THEME.alert ? mix(fill, THEME.alert, 0.33) : THEME.border
68 return (
69 <Box key={id} flexDirection="row" columnGap={1} paddingX={1} borderStyle="round" borderColor={edge} backgroundColor={fill}>
70 <Box key={`${id}-mark`} flexShrink={0}>
71 <Text color={tone.color} backgroundColor={mix(fill, tone.color, 0.15)} bold={tone.bold}>{` ${tone.mark} `}</Text>
72 </Box>
73 <Box key={`${id}-text`} flexGrow={1} flexShrink={1}>
74 <Text wrap="wrap">
75 <Text color={THEME.onSurface} bold>{title}</Text>
76 {detail ? <Text color={THEME.muted}>{` ${detail}`}</Text> : null}
77 </Text>
78 </Box>
79 <Box key={`${id}-close`} flexDirection="row" flexShrink={0}>
80 {nerd ? <Text key={`${id}-close-left`} color={THEME.surface3} backgroundColor={fill}>{CAPS.left}</Text> : null}
81 <Client key={id} module="./entry.tsx" height={1} props={{ id, label: close, isOpen: false, bg: THEME.surface3, still: list.isStill, tip: { glyph: close, ...closeTip } }} />
82 {nerd ? <Text key={`${id}-close-right`} color={THEME.surface3} backgroundColor={fill}>{CAPS.right}</Text> : null}
83 </Box>
84 </Box>
85 )
86 })
87hooks/settings.tsx 297 lines1// hooks/settings.tsx, by v-amorim
2// Shared settings body; every mod carries the same copy.
3import type { ConfigRow, ConfigValue, RenderElement } from 'claude-code'
4
5import type { EntryProps, Tip } from './entry'
6import { cardFill, cardGap, section, sepText, type Look, type Reveal } from './look'
7import style from './style'
8import { CAPS, fill, MARK, THEME } from './theme'
9
10// A button a mod offers beside its settings: runs `/<command> <args>`, declared in its showcase.json.
11// isLocal marks the drawing mod's own command, which it calls directly, so Claude working does not hold it.
12export type Action = { label: string; command: string; args?: string; isLocal?: boolean }
13
14type Elements = { Box: (props: any) => RenderElement; Button: (props: any) => RenderElement; Client: (props: any) => RenderElement; Text: (props: any) => RenderElement }
15
16export type SettingsProps = {
17 // The mod's own /config rows (`<plugin>.<field>`), as $.config.list() returns them.
18 rows: ConfigRow[]
19 actions: Action[]
20 // Another mod's command run while Claude works only queues as a prompt, so those actions go inert.
21 working: boolean
22 run: (action: Action) => unknown
23 // The animations setting is off: a clicked control does not shimmer.
24 still?: boolean
25 // Hover tips by setting field (the key after the dot); a setting without one shows its description's first sentence.
26 tips?: Record<string, string>
27 // The mod's example cards, from its showcase.json, so its own panel and mod-manager draw the same ones.
28 previews?: Previews
29 // The mod's plugin.json userConfig, read by its own panel and by mod-manager alike, for the min and max a number setting declares.
30 fields?: Record<string, Range>
31 // Text settings (by field) another mod edits in its own panel: the card shows a pill running that panel's command in place of "edit it in /config".
32 editors?: Record<string, Action>
33}
34
35export type Range = { min?: number; max?: number }
36
37// A title's trailing "(unit)" is what its number reads in; the head drops it and the value carries it.
38export function splitTitle(label: string) {
39 const match = /^(.*?)\s*\(([^()]+)\)$/.exec(label)
40 return match ? { name: match[1]!, unit: match[2]! } : { name: label, unit: '' }
41}
42
43// Large round counts read short, in thousands or millions.
44const short = (n: number) => (n >= 1_000_000 && n % 100_000 === 0 ? `${n / 1_000_000}M` : n >= 10_000 && n % 1000 === 0 ? `${n / 1000}K` : String(n))
45
46export function showNumber(value: number, unit: string) {
47 if (!unit) return String(value)
48 if (unit === '%') return `${value}%`
49 return `${short(value)} ${value === 1 ? unit.replace(/s$/, '') : unit}`
50}
51
52// About twenty clicks from one end of the range to the other, on a 1, 2 or 5 step; no range steps by one.
53export function stepOf({ min, max }: Range = {}) {
54 if (min === undefined || max === undefined || max <= min) return 1
55 const raw = (max - min) / 20
56 const power = 10 ** Math.floor(Math.log10(raw))
57 const nearest = [1, 2, 5, 10].map(factor => factor * power).reduce((best, step) => (Math.abs(step - raw) < Math.abs(best - raw) ? step : best))
58 return Math.max(1, nearest)
59}
60
61export function stepNumber(value: number, by: 1 | -1, range: Range = {}) {
62 return Math.min(range.max ?? Infinity, Math.max(range.min ?? -Infinity, value + by * stepOf(range)))
63}
64
65type ThemeColor = keyof typeof THEME
66
67// A literal run of an example line, as the mod's previews test drew it from its real tree; a color is a theme name or a #hex.
68export type PreviewSpan = { text: string; color?: string; bg?: string; bold?: boolean }
69export type PreviewLine = { spans: PreviewSpan[] }
70export type Preview = { frame?: { border: string; bg: string }; nerd: PreviewLine[]; safe: PreviewLine[] }
71
72// showcase.json's "previews", written by `just previews`: per setting field, per value as text ("true", "false", an option), the card in each icon mode.
73export type Previews = Record<string, Record<string, Preview>>
74
75const shade = (color: string | undefined) => (color === undefined ? undefined : color in THEME ? THEME[color as ThemeColor] : color)
76
77// What the row's current value looks like, as a card under it; null when the mod drew none for that value.
78function exampleOf({ Box, Text }: Elements, look: Look, previews: Previews | undefined, row: ConfigRow, index: number) {
79 const entry = previews?.[row.key.slice(row.key.indexOf('.') + 1)]?.[String(row.value)]
80 const lines = look.nerd ? entry?.nerd : entry?.safe
81 if (!entry || !lines?.length) return null
82 // The lines carry their own padding and strip, so the frame adds none. The example sinks to bg, so it never matches the card it sits in.
83 const sunk = (color: string | undefined) => (color !== undefined && color === shade(entry.frame?.bg) ? THEME.bg : color)
84 return (
85 <Box key={`setting-preview-${index}`} flexDirection="column" borderStyle="round" borderColor={shade(entry.frame?.border) ?? THEME.border} backgroundColor={THEME.bg} marginTop={1}>
86 {lines.map((line, index) => (
87 <Text key={`line-${index}`} wrap="truncate-end">
88 {line.spans.length === 0 ? ' ' : line.spans.map((span, at) => <Text key={`span-${at}`} color={shade(span.color) ?? THEME.onSurface} {...(span.bg ? { backgroundColor: sunk(shade(span.bg)) } : {})} {...(span.bold ? { bold: true } : {})}>{span.text}</Text>)}
89 </Text>
90 ))}
91 </Box>
92 )
93}
94
95// The value as the panel shows it: on/off as words, a list as its items.
96const shown = (row: ConfigRow) =>
97 row.kind === 'boolean' ? (row.value === true ? style.text.on : style.text.off) : Array.isArray(row.value) ? row.value.join(', ') : String(row.value)
98
99// Words that stay capitalized when a description follows a tip's label and colon.
100const PROPER = new Set(['Claude', 'Haiku', 'Opus', 'Sonnet', 'Windows', 'Nerd'])
101
102// A setting's description as the tip after "Label: ": its first sentence, led by a lowercase letter unless that word is a proper noun.
103export function descriptionTip(description: string) {
104 const sentence = description.split('. ')[0]!.replace(/\.$/, '')
105 const word = /^[A-Z][a-z]+\b/.exec(sentence)?.[0]
106 return word && !PROPER.has(word) ? sentence[0]!.toLowerCase() + sentence.slice(1) : sentence
107}
108
109// A control is an Entry pill posting `setting:<key>=<value as JSON>`; the mod's ui.message hook saves it with settingChoice.
110const SETTING = 'setting:'
111const settingId = (row: ConfigRow, value: ConfigValue) => `${SETTING}${row.key}=${JSON.stringify(value)}`
112
113// The change a clicked settings control asks for, or null when the message is something else.
114export function settingChoice(data: unknown): { key: string; value: ConfigValue } | null {
115 const message = data as { type?: string; id?: string } | undefined
116 if (message?.type !== 'menu-entry' || !message.id?.startsWith(SETTING)) return null
117 const rest = message.id.slice(SETTING.length)
118 const at = rest.indexOf('=')
119 if (at === -1) return null
120 try {
121 return { key: rest.slice(0, at), value: JSON.parse(rest.slice(at + 1)) as ConfigValue }
122 } catch {
123 return null
124 }
125}
126
127// A pill already frames the glyph, so drop the brackets and padding of the standalone button (safe `[↻]`).
128const bare = (glyph: string) => glyph.replace(/[[\]\s]/g, '')
129
130// A pill on the card's tint, capped where the font has the caps; `hover` lights the caps with a Button scoped to the pill's key.
131const pillOn = ({ Box, Text }: Elements, look: Look, key: string, bg: string, body: unknown, on = THEME.surface1, hover?: string) => {
132 const lit = hover ? { hover: { scope: key, color: hover } } : {}
133 return (
134 <Box key={key} flexDirection="row" flexShrink={0}>
135 {look.nerd ? <Text key={`${key}-left`} color={bg} backgroundColor={on} {...lit}>{CAPS.left}</Text> : null}
136 {body}
137 {look.nerd ? <Text key={`${key}-right`} color={bg} backgroundColor={on} {...lit}>{CAPS.right}</Text> : null}
138 </Box>
139 )
140}
141
142export type Choice = { key: string; isOn: boolean; props: Pick<EntryProps, 'id' | 'label' | 'still' | 'tip' | 'subLead' | 'subLeadColor'> }
143
144// A control track: a surface3 pill on `on`, padded by a space each side in safe mode, where it has no caps.
145function track(elements: Elements, look: Look, key: string, on: string, parts: unknown[]) {
146 const { Box, Text } = elements
147 const space = (side: string) => (look.nerd ? null : <Text key={`${key}-space-${side}`} backgroundColor={THEME.surface3}> </Text>)
148 return pillOn(elements, look, `${key}-bar`, THEME.surface3, <Box key={`${key}-options`} flexDirection="row" backgroundColor={THEME.surface3}>{[space('left'), ...parts, space('right')]}</Box>, on)
149}
150
151// A choice in a track: the pick a lifted (surface-4) subpill in primary-70 (`ok`), the rest muted; `on` is what the track sits on.
152export function choiceBar(elements: Elements, look: Look, key: string, on: string, choices: Choice[]) {
153 const { Client } = elements
154 const entry = (choice: Choice, shape: Partial<EntryProps>) => (
155 <Client key={choice.key} module="./entry.tsx" height={1} props={{ ...choice.props, isOpen: choice.isOn, bg: THEME.surface3, ...shape } satisfies EntryProps} />
156 )
157 const options = choices.map(choice =>
158 choice.isOn
159 ? // Tight, so the subpill is as wide as an idle option: the caps (Nerd) or a space (safe) stand where its padding was.
160 pillOn(elements, look, `${choice.key}-chosen`, THEME.lifted, entry(choice, { bg: THEME.lifted, color: THEME.ok, idle: THEME.muted, nerd: look.nerd, tight: true }), THEME.surface3)
161 : entry(choice, { idle: THEME.muted }),
162 )
163 return track(elements, look, key, on, options)
164}
165
166const actionScope = (index: number) => `action-row-${index}`
167
168// The tip each live action shows in the pane footer on hover; a Button cannot post one, so paneFooter reveals it by the pill's hover group.
169export const actionReveals = (actions: Action[], look: Look, working: boolean): Reveal[] =>
170 actions.flatMap((action, index) =>
171 working && !action.isLocal ? [] : [{ scope: actionScope(index), tip: { glyph: bare(look.glyph.go), label: action.label, text: fill(style.text.runsCommand, { command: [action.command, action.args].filter(Boolean).join(' ') }) } }],
172 )
173
174const editorScope = (index: number) => `editor-row-${index}`
175
176const fieldOf = (row: ConfigRow) => row.key.slice(row.key.indexOf('.') + 1)
177
178// The footer tip of each live editor pill, like actionReveals.
179export const editorReveals = (rows: ConfigRow[], editors: Record<string, Action>, look: Look, working: boolean): Reveal[] =>
180 rows.flatMap((row, index) => {
181 const action = row.kind === 'text' && !row.isLocked ? editors[fieldOf(row)] : undefined
182 return action && !working ? [{ scope: editorScope(index), tip: { glyph: MARK.open, label: action.label, text: fill(style.text.runsCommand, { command: [action.command, action.args].filter(Boolean).join(' ') }) } }] : []
183 })
184
185// A pill with a plain Button so the press is a real one; the glyph sits in the label so the whole pill lights.
186const buttonPill = (elements: Elements, look: Look, scope: string, key: string, glyph: string, label: string, onPress: () => unknown) => {
187 const { Button } = elements
188 return pillOn(
189 elements,
190 look,
191 scope,
192 THEME.surface3,
193 <Button key={key} label={look.nerd ? `${glyph} ${label}` : ` ${glyph} ${label} `} plain hover={{ scope, color: THEME.accent, inverse: true }} onPress={onPress} />,
194 THEME.surface1,
195 THEME.accent,
196 )
197}
198
199// Edits the row in place: on/off flips, a choice is a bar, a number steps; text is edited in /config.
200function control(elements: Elements, look: Look, row: ConfigRow, index: number, still: boolean, tips?: Record<string, string>, fields?: Record<string, Range>) {
201 const { Client, Text } = elements
202 const key = `setting-${index}`
203 const text = tips?.[row.key.slice(row.key.indexOf('.') + 1)] ?? (row.description ? descriptionTip(row.description) : '')
204 // Client props refuse an undefined value, so no tip text means no key.
205 const tipFor = (glyph: string) => (text ? { tip: { glyph: bare(glyph), label: splitTitle(row.label).name, text } satisfies Tip } : {})
206 const entry = (suffix: string, label: string, value: ConfigValue, bg: string, color: string, isOpen: boolean, glyph = look.glyph.cycle) => (
207 <Client key={`${key}-${suffix}`} module="./entry.tsx" height={1} props={{ id: settingId(row, value), label, isOpen, bg, color, idle: color, still, ...tipFor(glyph) } satisfies EntryProps} />
208 )
209 if (row.isLocked) return <Text color={THEME.muted}>{`${shown(row)} ${bare(look.glyph.locked)} ${style.text.locked}`}</Text>
210 if (row.kind === 'boolean') {
211 // The switch: `━━●` on, `●━━` off, the knob a bare glyph joined to the line.
212 const isOn = row.value === true
213 const shape = isOn
214 ? { lead: MARK.track, leadColor: THEME.accent, label: MARK.knob, idle: THEME.ok }
215 : { lead: MARK.knob, leadColor: style.colors.pills.readout, label: MARK.track, idle: THEME.trackOff }
216 const flip = <Client key={`${key}-flip`} module="./entry.tsx" height={1} props={{ id: settingId(row, !isOn), isOpen: false, bg: THEME.surface3, tight: true, still, ...shape, ...tipFor(look.glyph.cycle) } satisfies EntryProps} />
217 return track(elements, look, key, cardFill(), [flip])
218 }
219 if (row.kind === 'choice' && row.options?.length) {
220 const choices = row.options.map((option, at) => ({ key: `${key}-option-${at}`, isOn: option === String(row.value), props: { id: settingId(row, option), label: option, still, ...tipFor(look.glyph.cycle) } }))
221 return choiceBar(elements, look, key, cardFill(), choices)
222 }
223 if (row.kind === 'number' && typeof row.value === 'number') {
224 const range = fields?.[row.key.slice(row.key.indexOf('.') + 1)]
225 const value = <Text key={`${key}-value`} color={THEME.ok} backgroundColor={THEME.lifted} bold>{look.nerd ? showNumber(row.value, splitTitle(row.label).unit) : ` ${showNumber(row.value, splitTitle(row.label).unit)} `}</Text>
226 return track(elements, look, key, cardFill(), [
227 entry('down', bare(look.glyph.down), stepNumber(row.value, -1, range), THEME.surface3, THEME.muted, false, look.glyph.down),
228 pillOn(elements, look, `${key}-value-pill`, THEME.lifted, value, THEME.surface3),
229 entry('up', bare(look.glyph.up), stepNumber(row.value, 1, range), THEME.surface3, THEME.muted, false, look.glyph.up),
230 ])
231 }
232 return <Text color={THEME.secondary}>{shown(row)}</Text>
233}
234
235// A setting's first row: the label bold on the left, its control anchored right.
236export const settingHead = ({ Box, Text }: Pick<Elements, 'Box' | 'Text'>, key: string, label: string, control: unknown) => (
237 <Box key={key} flexDirection="row" columnGap={2} justifyContent="space-between">
238 <Text color={THEME.onSurface} bold>{label}</Text>
239 {control}
240 </Box>
241)
242
243// A hairline across its box, under a setting's head row; a Client, since only a Client learns the width it gets.
244export const hairline = ({ Client }: Pick<Elements, 'Client'>, key: string, bg: string) => (
245 <Client key={key} module="./entry.tsx" width="100%" height={1} props={{ id: key, label: '', isOpen: false, bg, rule: true } satisfies EntryProps} />
246)
247
248// The Settings and Actions sections of a mod's panel or page: one round-bordered card per setting.
249export function settingsBody(elements: Elements, look: Look, { rows, actions, working, run, previews, tips, fields, editors = {}, still = false }: SettingsProps) {
250 const { Box, Text } = elements
251 const settings = [
252 rows.length === 0 ? <Text key="settings-none" color={THEME.muted}>{style.text.noSettings}</Text> : null,
253 ...rows.map((row, index) => {
254 const example = exampleOf(elements, look, previews, row, index)
255 const editor = row.kind === 'text' && !row.isLocked ? editors[fieldOf(row)] : undefined
256 const head = editor && !working ? (
257 <Box key={`setting-control-${index}`} flexDirection="row" columnGap={1}>
258 {control(elements, look, row, index, still, tips, fields)}
259 {buttonPill(elements, look, editorScope(index), `setting-editor-${index}`, MARK.open, editor.label, () => run(editor))}
260 </Box>
261 ) : (
262 control(elements, look, row, index, still, tips, fields)
263 )
264 const hint = row.kind !== 'text' ? '' : editor ? (working ? style.text.idleOnly : '') : style.text.editInConfig
265 const note = [row.description, hint].filter(Boolean).join(` ${MARK.sep} `)
266 return (
267 <Box key={`setting-row-${index}`} flexDirection="column" borderStyle="round" borderColor={THEME.border} backgroundColor={cardFill()} paddingX={1} marginTop={index === 0 ? 0 : 1}>
268 {settingHead(elements, `setting-head-${index}`, splitTitle(row.label).name, head)}
269 {row.description || example ? hairline(elements, `setting-rule-${index}`, cardFill()) : null}
270 {row.description ? (
271 sepText(elements, note, THEME.muted, `setting-text-${index}`)
272 ) : null}
273 {example}
274 </Box>
275 )
276 }),
277 ]
278 const buttons = actions.map((action, index) =>
279 working && !action.isLocal ? (
280 sepText(elements, `${look.glyph.go} ${action.label} ${MARK.sep} ${style.text.idleOnly}`, THEME.muted, `action-${index}`)
281 ) : (
282 buttonPill(elements, look, actionScope(index), `action-${index}`, bare(look.glyph.go), action.label, () => run(action))
283 ),
284 )
285 return (
286 <Box key="settings" flexDirection="column">
287 {/* The cards carry their own border, and the panel's open Settings tab already names them. */}
288 <Box key="settings-cards" flexDirection="column">
289 {cardGap(elements, 'settings')}
290 {settings}
291 </Box>
292 {actions.length > 0 ? section(elements, look, 'actions', 'actions', style.text.actions, buttons) : null}
293 </Box>
294 )
295}
296
297hooks/text.ts 89 lines1// mod-file-tree / hooks/text.ts, by v-amorim
2// Every string this mod shows. Edit the values to reword or translate; keep the keys and the {slots}.
3export default {
4 "pill": "Files",
5 "working": "Working",
6 "pillTip": "Files: what Claude read and changed, with a replay of each edit",
7 "commandDescription": "Open the files sidebar: a tree of the files Claude touched and a replay of its last edits; `settings` opens its settings panel",
8 "settingsFrame": "mod-file-tree",
9 "settingsLabel": "Settings",
10 "rootFallback": "Files",
11 "paneWaiting": "Files panel waiting: {reason}",
12 "settingsWaiting": "Settings panel waiting: {reason}",
13 "cleared": "Cleared the file tree and the replay",
14 "onlyRead": "{path} was only read, there is no diff",
15 "replaying": "Replaying {path}",
16 "openedInCode": "Opened {path} in VS Code",
17 "opened": "Opened {path} in its default app",
18 "openedFolder": "Opened {path} in the file manager",
19 "openedRunnable": "{path} is a program or script, so its folder opened instead of running it",
20 "openFailed": "Could not open {path}: {reason}",
21 "sessionFolder": "Session folder",
22 "settingSaved": "{label} saved",
23 "settingsHint": "Click a setting to change it {sep} The mod reloads",
24 "hintTree": "Click a file to replay, a folder to fold",
25 "hintReadsHidden": " {sep} Reads hidden",
26 "hintReplay": "Prev and Next step through the edits {sep} a file jumps here",
27 "treeEmpty": "No files touched yet. Reads and edits show up here as Claude works.",
28 "unreadableWhy": "Can't read this session's transcript: {reason}",
29 "replayEmpty": "No edits yet. Each edit Claude makes in a turn can be stepped through here.",
30 "filesTabTip": "shows the touched files",
31 "settingsTabTip": "opens this mod's settings",
32 "tabTreeTip": "shows the files as a tree",
33 "tabReplayTip": "steps through the edits of a turn",
34 "scopeTurnTip": "files from the latest turn only",
35 "scopeRecentTip": "files from the last few turns",
36 "scopeSessionTip": "files from the whole session",
37 "prevTip": "goes to the previous edit",
38 "nextTip": "goes to the next edit",
39 "sessionFolderTip": "opens the session folder",
40 "foldAll": "Fold all",
41 "unfoldAll": "Unfold all",
42 "foldAllTip": "folds every folder in the tree",
43 "unfoldAllTip": "opens every folder in the tree",
44 "folderRowLabel": "Fold",
45 "folderRowTip": "folds or unfolds this folder",
46 "fileRowLabel": "Replay",
47 "fileRowTip": "replays the edits on this file",
48 "readRowLabel": "Read only",
49 "readRowTip": "only read, nothing to replay",
50 "openLabel": "Open",
51 "openFileTip": "opens this file in VS Code",
52 "openFolderTip": "opens it in the file manager",
53 "tabTree": "Tree",
54 "tabReplay": "Replay",
55 "scopeTurn": "This turn",
56 "scopeRecent": "Last {n} turns",
57 "scopeSession": "Session",
58 "prev": "Prev",
59 "next": "Next",
60 "stepOf": "{at} of {total}",
61 "noLineChanges": "(no line changes)",
62 "skipped": "...",
63 "moreLines": "... {n} more lines",
64 "replayHeading": "Turn {sep} {n} {unit}",
65 "editOne": "edit",
66 "editMany": "edits",
67 "words": {
68 "read": { "done": "read", "live": "reading" },
69 "edit": { "done": "edited", "live": "editing" },
70 "write": { "done": "written", "live": "writing" },
71 "new": { "done": "created", "live": "creating" }
72 },
73 "legend": {
74 "changed": "Modified",
75 "added": "Added",
76 "read": "Read"
77 },
78 "notes": {
79 "replaceAll": "replace all",
80 "cell": "{mode} cell",
81 "rewrite": "rewrite",
82 "newFile": "new file",
83 "deleted": "deleted",
84 "shell": "shell",
85 "shellNoDiff": "shell, no diff kept"
86 },
87 "tipToast": {"label":"Dismiss","text":"hides this message"}
88}
89hooks/theme.ts 46 lines1// hooks/theme.ts, by v-amorim
2// Shared theme; every mod carries the same copy. Colors, marks and glyphs live in style.ts.
3import style from './style'
4
5export const THEME = style.colors.theme
6
7// Shell command roles: what runs, its options, quoted text, variables, numbers and the glue between steps.
8export const SYNTAX = style.colors.syntax
9
10// One cell each and never an emoji: ⚠ switches to its emoji form in some fonts and draws two cells wide.
11export const MARK = style.marks
12
13// style.ts writes Nerd glyphs as {U+XXXX}, since private-use characters do not survive many editors and tools.
14const decode = (text: string) => text.replace(/\{U\+([0-9A-F]+)\}/gi, (_, hex: string) => String.fromCodePoint(parseInt(hex, 16)))
15
16export function decodeAll<T>(value: T): T {
17 if (typeof value === 'string') return decode(value) as T
18 if (Array.isArray(value)) return value.map(decodeAll) as T
19 return Object.fromEntries(Object.entries(value as object).map(([key, inner]) => [key, decodeAll(inner)])) as T
20}
21
22// A pill's rounded ends, drawn only where the font has them.
23export const CAPS = decodeAll(style.caps)
24
25export const statusColor = (note: string) =>
26 note.startsWith(MARK.ok) ? THEME.good : note.startsWith(MARK.warn) ? THEME.alert : THEME.muted
27
28const channels = (hex: string) => [1, 3, 5].map(at => parseInt(hex.slice(at, at + 2), 16))
29const isHex = (color: string) => /^#[0-9a-f]{6}$/i.test(color)
30
31// The color `t` of the way from `from` to `to`; a color that is not #RRGGBB switches halfway.
32export function mix(from: string, to: string, t: number) {
33 if (t <= 0) return from
34 if (t >= 1) return to
35 if (!isHex(from) || !isHex(to)) return t < 0.5 ? from : to
36 const end = channels(to)
37 return `#${channels(from).map((value, at) => Math.round(value + (end[at]! - value) * t).toString(16).padStart(2, '0')).join('')}`
38}
39
40// The share of a hue left in a subpill or count badge, washed into the fill it sits on.
41export const WASH = 0.72
42
43// A text.ts string with its {name} slots filled; a slot with no value stays as written, so a missing one shows.
44export const fill = (template: string, values: Record<string, string | number> = {}) =>
45 template.replace(/\{(\w+)\}/g, (slot, name: string) => (name in values ? String(values[name]) : slot))
46hooks/transcript.ts 135 lines1// mod-file-tree / hooks/transcript.ts, by v-amorim
2// Reduces tool calls, live or parsed from the transcript, into per-file change plans.
3import text from './text'
4import { fill } from './theme'
5import { commit, diffLines, emptyLog, relPath, touch, type Kind, type Line, type Log } from './tree'
6
7// What a call does to which file: the live hook and the transcript parse both reduce a call to this.
8export type Plan = { path: string; tool: string; kind: Kind; note: string; lines: Line[] }
9
10const WRAPPER = /^<(command-|local-command-|bash-|system-reminder|task-notification|user-prompt-submit-hook)/
11const INTERRUPTED = /^\[Request interrupted by user/
12
13const isPrompt = (text: string) => {
14 const trimmed = text.trim()
15 return Boolean(trimmed) && !WRAPPER.test(trimmed) && !INTERRUPTED.test(trimmed) && !trimmed.startsWith('/')
16}
17
18const userText = (content: unknown) =>
19 typeof content === 'string'
20 ? content
21 : Array.isArray(content)
22 ? content.filter((block: any) => block?.type === 'text' && typeof block.text === 'string').map((block: any) => block.text).join('\n')
23 : ''
24
25// Claude Code stores each edit's hunks; their lines carry the diff sign as the first character.
26export function patchLines(patch: unknown): Line[] | undefined {
27 if (!Array.isArray(patch) || patch.length === 0) return undefined
28 const out: Line[] = []
29 patch.forEach((hunk: any, at) => {
30 if (at > 0) out.push({ op: '~', t: text.skipped })
31 for (const line of Array.isArray(hunk?.lines) ? hunk.lines : []) {
32 if (typeof line !== 'string') continue
33 const sign = line[0]
34 out.push({ op: sign === '+' ? '+' : sign === '-' ? '-' : ' ', t: line.slice(1) })
35 }
36 })
37 return out
38}
39
40// The plan of one tool_use as the transcript recorded it, without the result yet.
41export function planOfUse(name: string, input: any, cwd: string): Plan | undefined {
42 const at = (path: unknown) => relPath(cwd, String(path))
43 if (name === 'Read' && input?.file_path) return { path: at(input.file_path), tool: 'Read', kind: 'read', note: '', lines: [] }
44 if ((name === 'Edit' || name === 'MultiEdit') && input?.file_path) {
45 return { path: at(input.file_path), tool: name, kind: 'edit', note: input.replace_all ? text.notes.replaceAll : '', lines: diffLines(String(input.old_string ?? ''), String(input.new_string ?? '')) }
46 }
47 if (name === 'NotebookEdit' && input?.notebook_path) {
48 return { path: at(input.notebook_path), tool: name, kind: 'edit', note: fill(text.notes.cell, { mode: String(input.edit_mode ?? 'replace') }), lines: diffLines('', String(input.new_source ?? '')) }
49 }
50 if (name === 'Write' && input?.file_path) return { path: at(input.file_path), tool: 'Write', kind: 'write', note: text.notes.rewrite, lines: diffLines('', String(input.content ?? '')) }
51 return undefined
52}
53
54// The files a shell command changed, as Claude Code diffed them: hunks when it kept them, else only the names.
55export function shellPlans(diff: any, cwd: string): Plan[] {
56 if (!diff || diff.unavailable || diff.skipped) return []
57 const at = (path: unknown) => relPath(cwd, String(path))
58 const shown: Plan[] = (Array.isArray(diff.files) ? diff.files : []).map((file: any) => ({
59 path: at(file.filePath),
60 tool: 'Bash',
61 kind: file.created ? 'new' : 'edit',
62 note: file.created ? text.notes.newFile : file.deleted ? text.notes.deleted : text.notes.shell,
63 lines: patchLines(file.hunks) ?? [],
64 }))
65 const named = (Array.isArray(diff.changedFiles) ? diff.changedFiles : []).map((path: unknown) => at(path)).filter((path: string) => !shown.some(plan => plan.path === path))
66 return [...shown, ...named.map((path: string): Plan => ({ path, tool: 'Bash', kind: 'edit', note: text.notes.shellNoDiff, lines: [] }))]
67}
68
69// Builds the tree and the replay from the transcript, a line at a time, so a re-read feeds only the new lines.
70export class TranscriptFiles {
71 log: Log = emptyLog()
72 private open = new Map<string, Plan>()
73 private shells = new Set<string>()
74
75 constructor(private cwd: string) {}
76
77 feed(line: string) {
78 let row: any
79 try {
80 row = JSON.parse(line)
81 } catch {
82 return
83 }
84 if (typeof row?.uuid !== 'string') return
85 const content = row.message?.content
86 // A subagent's edits count as they do live, but its prompts open no turn, as a subagent's turn.start does not live.
87 const isSide = row.isSidechain === true
88
89 // A prompt, or a subagent's report that Claude answers in a turn of its own, starts a turn.
90 const isPeer = row.type === 'user' && row.isMeta && row.origin?.kind === 'peer'
91 const isTyped = row.type === 'user' && !row.isMeta && !row.isCompactSummary && !(Array.isArray(content) && content.some((block: any) => block?.type === 'tool_result')) && isPrompt(userText(content))
92 // A prompt with an image holds content blocks, not a string.
93 const isQueued = row.type === 'attachment' && row.attachment?.type === 'queued_command' && !row.attachment.isMeta && isPrompt(userText(row.attachment.prompt))
94 if (!isSide && (isPeer || isTyped || isQueued)) {
95 this.log = { ...this.log, turn: this.log.turn + 1 }
96 return
97 }
98
99 if (row.type === 'assistant' && Array.isArray(content)) {
100 for (const block of content) {
101 if (block?.type !== 'tool_use' || typeof block.id !== 'string') continue
102 if (block.name === 'Bash') this.shells.add(block.id)
103 const plan = planOfUse(String(block.name), block.input, this.cwd)
104 if (!plan) continue
105 this.open.set(block.id, plan)
106 this.log = touch(this.log, plan.path, plan.kind)
107 }
108 return
109 }
110
111 if (row.type === 'user' && Array.isArray(content)) {
112 for (const block of content) {
113 if (block?.type === 'tool_result' && this.shells.delete(block.tool_use_id)) {
114 for (const shell of shellPlans(row.toolUseResult?.bashEditDiff, this.cwd)) {
115 this.log = touch(this.log, shell.path, shell.kind)
116 this.log = commit(this.log, shell.path, shell.tool, shell.kind, shell.note, shell.lines)
117 }
118 continue
119 }
120 const plan = block?.type === 'tool_result' ? this.open.get(block.tool_use_id) : undefined
121 if (!plan) continue
122 this.open.delete(block.tool_use_id)
123 if (block.is_error === true || plan.kind === 'read') continue
124 const result = row.toolUseResult
125 const isNew = plan.tool === 'Write' && result?.type === 'create'
126 const kind: Kind = isNew ? 'new' : plan.kind
127 if (isNew) this.log = touch(this.log, plan.path, 'new')
128 const note = isNew ? text.notes.newFile : plan.note
129 const lines = isNew ? diffLines('', String(result?.content ?? '')) : patchLines(result?.structuredPatch) ?? plan.lines
130 this.log = commit(this.log, plan.path, plan.tool, kind, note, lines)
131 }
132 }
133 }
134}
135hooks/cells.ts 68 lines1// hooks/cells.ts, by v-amorim
2// Terminal cells: East Asian wide and emoji code points take two, combining marks and joiners none, anything else one.
3const WIDE: [number, number][] = [
4 [0x1100, 0x115f],
5 [0x2e80, 0x303e],
6 [0x3041, 0x33ff],
7 [0x3400, 0x4dbf],
8 [0x4e00, 0x9fff],
9 [0xa000, 0xa4cf],
10 [0xac00, 0xd7a3],
11 [0xf900, 0xfaff],
12 [0xfe30, 0xfe4f],
13 [0xff00, 0xff60],
14 [0xffe0, 0xffe6],
15 [0x1f300, 0x1faff],
16 [0x20000, 0x3fffd],
17]
18
19const ZERO: [number, number][] = [
20 [0x0300, 0x036f],
21 [0x200b, 0x200d],
22 [0xfe00, 0xfe0f],
23]
24
25const within = (code: number, ranges: [number, number][]) => ranges.some(([from, to]) => code >= from && code <= to)
26
27export const cellOf = (char: string) => {
28 const code = char.codePointAt(0)!
29 if (within(code, ZERO)) return 0
30 return within(code, WIDE) ? 2 : 1
31}
32
33export const cells = (text: string) => {
34 let sum = 0
35 for (const char of text) sum += cellOf(char)
36 return sum
37}
38
39// The text cut to `width` cells, its last cell an ellipsis when it had to cut; spaces stay as written.
40export function cut(text: string, width: number) {
41 if (cells(text) <= width) return text
42 let out = ''
43 let used = 0
44 for (const char of text) {
45 const next = used + cellOf(char)
46 if (next > width - 1) break
47 out += char
48 used = next
49 }
50 return `${out}…`
51}
52
53// One line, runs of whitespace joined, cut to `width` cells.
54export const fit = (text: string, width: number) => cut(text.replace(/\s+/g, ' '), width)
55
56// A Text inside a card overflows instead of wrapping, so long lines break at word boundaries here.
57export function wrapWords(line: string, width: number) {
58 const rows: string[] = []
59 let row = ''
60 for (const word of line.split(' ')) {
61 if (row && cells(row) + 1 + cells(word) > width) {
62 rows.push(row)
63 row = word
64 } else row = row ? `${row} ${word}` : word
65 }
66 return [...rows, row]
67}
68