SLOPSHOPPER

mod-file-tree

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…

newpanebandguardcommandtoast
★ 2v1.1.4MITupdated 2026-10-26v-amorim/moonlight-claude-mods/mod-file-tree
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mod-file-tree
│ ┃ Files ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./entry.tsx │ ┃ Click a file to replay, a folder to fold ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ▣ client module ./entry.tsx▣ client module ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ╭────────────────────────────────────────╮ ⏺ Bash(bun test) │ ┃ │ ▣ client module ./list.tsx │ ⎿ 3 pass, 1 fail │ ┃ ╰────────────────────────────────────────╯ │ ┃ ─ src ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Modified │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ ─────────────────────────────────────────── › /mod-file-tree │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ⟨Claude Code's own drawing⟩ ▣ client module ./entry.tsx ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ▣ client module ./entry.tsx
Pane · Files
▣ client module ./entry.tsx Click a file to replay, a folder to fold ▣ client module ./entry.tsx▣ client module ./entry.tsx ╭────────────────────────────────────────────────────────╮ │ ▣ client module ./list.tsx │ ╰────────────────────────────────────────────────────────╯ ─ src Modified ────────────────────────────────────────────────────────
README

<!-- mod-file-tree / README.md, by v-amorim --> <!-- Generated from the root README, plugin.json and showcase.json; edit those. -->

mod-file-tree

Part of [moonlight-claude-mods][doc-root].

The files Claude read and changed, with a replay of each edit.

Install

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].

How to open

Files pill.

See it

The Files panelThe 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

Features

  • File tree: Only the files Claude read, edited, wrote or created this session, in folders you can fold, with +/- line counts for edits.
  • Live marker: The file Claude is on lights up and says what it is doing: reading, editing, writing or creating.
  • Replay: Prev and Next step through the last turn's edits, one diff at a time. Click a file in the tree to open its latest diff.
  • Command hooks: On SessionStart, a small shell hook appends the event's JSON as one line to ~/.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.

Settings

Change them with /mod-file-tree settings or the gear pill.

SettingDefaultWhat it does
AnimationsonEntries shimmer while they wait. Hover colors also fade. Off, everything draws still
Iconsautoauto 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 readonRead files join the changed ones. Off, it lists only changed files
Wrap long diff linesonReplay lines wrap onto the next row. Off, they are cut at the pane edge
Recent view (turns)5How many latest turns to cover. The middle view shows them, from 2 to 20

License

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

Source 15 files
hooks/register.tsx 1032 lines
1// 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}
1032
hooks/bridge.ts 107 lines
1// 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}
107
hooks/list.tsx 227 lines
1// 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
227
hooks/look.tsx 279 lines
1// 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
279
hooks/entry.tsx 241 lines
1// 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
241
hooks/pills.tsx 167 lines
1// 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}
167
hooks/toasts.tsx 87 lines
1// 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  })
87
hooks/settings.tsx 297 lines
1// 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
297
hooks/text.ts 89 lines
1// 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}
89
hooks/theme.ts 46 lines
1// 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))
46
hooks/transcript.ts 135 lines
1// 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}
135
hooks/cells.ts 68 lines
1// 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