A sidebar of every prompt in this session, with the full text in a card and a button that jumps the transcript to it. Opens from /mod-prompts or the Prompts…

<!-- mod-prompts / README.md, by v-amorim --> <!-- Generated from the root README, plugin.json and showcase.json; edit those. -->
Part of [moonlight-claude-mods][doc-root].
Every prompt of the session; jump back to any of them.
Inside Claude Code:
/plugin marketplace add v-amorim/moonlight-claude-mods
/plugin install mod-prompts@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].
Prompts pill.
| Pick a prompt to read it |
|---|
| https://github.com/user-attachments/assets/0cf0064f-0498-4f0a-85cc-18e5256cfcd3 |
~/.claude/mods-data/mod-prompts/bridge/<session id>.jsonl (under $CLAUDE_CONFIG_DIR when that is set), and the mod reads the file every 250 ms. A record can hold the session id, the working folder and the transcript path. SessionEnd deletes the session's file, and SessionStart deletes files older than 7 days. The hooks need a POSIX shell (Git Bash on Windows). Without one they write nothing, and the mod falls back to what it can find on its own. Some plans block plugin hooks on these events; the mod then falls back the same way.Change them with /mod-prompts settings or the gear pill.
| Setting | Default | What it does |
|---|---|---|
| Icons | auto | auto picks Nerd Font or safe glyphs. It uses Nerd Font icons when the terminal's font is a Nerd Font (read from Windows Terminal, iTerm2, VS Code, Ghostty, kitty, WezTerm, Alacritty, GNOME Terminal or Konsole) and safe Unicode glyphs elsewhere. nerd and safe force one |
| Animations | on | Entries shimmer while they wait. Hover colors also fade. Off, everything draws still |
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
hooks/register.tsx 764 lines1// mod-prompts / hooks/register.tsx, by v-amorim
2// Registers the mod: wires the prompts sidebar, timeline, settings, jumps and toasts.
3import type { ConfigValue, EngineInterface, Register } from 'claude-code'
4
5import { bridgeOn, endBridge, startBridge, type BridgeRecord } from './bridge'
6import { cardSpace, HEAD_ROWS, stickyCard } from './card'
7import { isTipMessage, type EntryMessage, type EntryProps, type Tip } from './entry'
8import { cardFill, detectNerd, FOOTER_ROWS, lookFor, paneFooter, section, sepText, titleEdge, withMark, type IconMode } from './look'
9import { labelsOf, menuEntry, pill, pillRow, splitRow, tabRow, tipLine } from './pills'
10import { settingChoice, settingsBody } from './settings'
11import { TOAST_MS, ToastList, toastCards } from './toasts'
12import { promptsOf, promptText, rowKey, targetOf, TranscriptRows, type Prompt } from './prompts'
13import { bytesOf, CHUNK_BYTES, chunkArgv, utf8Length } from './tail'
14import { GUTTER, timelineRows, type TimelineMessage, type TimelineProps } from './timeline'
15import { fill, MARK, statusColor, THEME } from './theme'
16import style from './style'
17import text from './text'
18
19
20// The icons setting (userConfig), fixed per activation: a change reloads the mod.
21let iconMode: IconMode = 'auto'
22let look = lookFor(false)
23
24async function detectLook($: EngineInterface) {
25 if (iconMode !== 'auto') return lookFor(iconMode === 'nerd')
26 const env = {
27 WT_SESSION: await $.env.get('WT_SESSION'),
28 TERM_PROGRAM: await $.env.get('TERM_PROGRAM'),
29 ITERM_PROFILE: await $.env.get('ITERM_PROFILE'),
30 KITTY_WINDOW_ID: await $.env.get('KITTY_WINDOW_ID'),
31 ALACRITTY_WINDOW_ID: await $.env.get('ALACRITTY_WINDOW_ID'),
32 GNOME_TERMINAL_SCREEN: await $.env.get('GNOME_TERMINAL_SCREEN'),
33 KONSOLE_VERSION: await $.env.get('KONSOLE_VERSION'),
34 }
35 const host = {
36 localAppData: ((await $.env.get('LOCALAPPDATA')) ?? '').replace(/\\/g, '/'),
37 home: ((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '').replace(/\\/g, '/'),
38 read: (path: string) => $.fs.read(path).then(String),
39 run: async (argv: string[]) => {
40 const result = await $.process.run(argv, { timeoutMs: 5000 })
41 if (result.exitCode !== 0) throw new Error(result.stderr)
42 return result.stdout
43 },
44 }
45 return lookFor(await detectNerd(env, host))
46}
47
48// Reading the terminal's settings takes a while, so on auto the last answer, kept in this mod's store, draws until it returns.
49async function savedLook($: EngineInterface) {
50 if (iconMode !== 'auto') return look
51 try {
52 const nerd = await $.store.get('look-nerd')
53 return typeof nerd === 'boolean' ? lookFor(nerd) : look
54 } catch {
55 return look
56 }
57}
58
59async function saveLook($: EngineInterface) {
60 try {
61 await $.store.set('look-nerd', look.nerd)
62 } catch {
63 // Without a store the mod still draws; it only starts from safe again next time.
64 }
65}
66
67// Hooks of one event run in turn, so the slow font check runs beside them instead of holding up every mod after this one.
68let lookLoad: Promise<void> | null = null
69
70function loadLook($: EngineInterface) {
71 lookLoad ??= (async () => {
72 look = await savedLook($)
73 void detectLook($).then(async found => {
74 const isChanged = found.nerd !== look.nerd
75 look = found
76 await saveLook($)
77 if (isChanged) $.ui.invalidate('ui.render')
78 }).catch(() => {
79 // A failed check keeps the icons drawn now.
80 })
81 })()
82 return lookLoad
83}
84
85const PANE = 'prompts'
86const SETTINGS = 'prompts-settings'
87// Claude Code draws a prompt under this id as it is sent, then under its uuid; only the uuid draw is listed.
88const PLACEHOLDER = 'placeholder'
89
90// Border, the number line and six lines of the prompt.
91const CARD_ROWS = 8
92
93// An entry click with this prefix flips the card of the prompt whose id follows.
94const MORE = 'prompts-more:'
95
96const STOPS_PER_CLIENT = 30
97
98const PAGE_SIZE = 100
99
100let transcript = ''
101let transcriptRows = new TranscriptRows()
102// The bytes of the file fed so far, and the unfinished line after them; a turn that added nothing reads nothing.
103let fedBytes = 0
104let carry = ''
105// A file that could not be read at this size is not read again until it grows.
106let failedSize = -1
107// The short reason the last failed read gave, for the empty state.
108let readError = ''
109const reasonOf = (error: unknown) => (error instanceof Error ? error.message : String(error)).replace(/\s+/g, ' ').trim().slice(0, 80)
110// Read in full once, then in part; prompts drawn in between come from the rows Claude Code draws.
111let isLoaded = false
112// A turn ended, so the next read takes what it added.
113let isStale = false
114let prompts: Prompt[] = []
115let selected = -1
116// The page the timeline shows; -1 follows the open prompt.
117let page = -1
118// How many pages the last drawing had, so a pager click knows its bounds.
119let pageCount = 1
120let note = ''
121// Row key -> the requestId Claude Code drew that prompt's row under, which a jump scrolls to.
122const drawn = new Map<string, string>()
123
124const isOpen = async ($: EngineInterface, id = PANE) => (await $.ui.panes()).some(pane => pane.id === id && pane.isPlaced)
125
126// The session the prompts belong to. A /resume or /clear keeps the process and changes the id, so the old list must never show for the new one.
127let listSession = ''
128
129function startList(session: string, path = '') {
130 listSession = session
131 transcript = path
132 transcriptRows = new TranscriptRows()
133 fedBytes = 0
134 carry = ''
135 failedSize = -1
136 readError = ''
137 isLoaded = false
138 isStale = false
139 prompts = []
140 selected = -1
141 page = -1
142 note = ''
143 drawn.clear()
144}
145
146// A transcript path is kept with the time it was named; an older bare string counts as a path with no age yet.
147const pathOf = (kept: unknown) => {
148 const path = typeof kept === 'object' && kept !== null ? (kept as { path?: unknown }).path : kept
149 return typeof path === 'string' ? path : ''
150}
151
152const TRANSCRIPT_KEEP_MS = 30 * 24 * 60 * 60 * 1000
153
154// Entries untouched for 30 days go; a bare string from an older version starts its clock now.
155async function pruneTranscripts($: EngineInterface) {
156 try {
157 const now = await $.clock.now()
158 for (const key of await $.store.keys()) {
159 if (!key.startsWith('transcript:')) continue
160 const kept = await $.store.get(key)
161 const at = typeof kept === 'object' && kept !== null ? Number((kept as { at?: unknown }).at) : NaN
162 if (Number.isFinite(at)) {
163 if (at < now - TRANSCRIPT_KEEP_MS) await $.store.delete(key)
164 } else if (typeof kept === 'string') await $.store.set(key, { path: kept, at: now })
165 }
166 } catch {
167 // Old entries stay until the next start.
168 }
169}
170
171// Only the SessionStart record names the transcript, and the bridge delivers it; kept per session so a reload finds it again.
172async function sessionStarted($: EngineInterface, record: BridgeRecord) {
173 const session = String(record.session_id ?? '')
174 const path = String(record.transcript_path ?? '')
175 if (!session || !path) return
176 // A compact keeps the same session and transcript, and the prompts learned since the read are already here.
177 if (session !== listSession || path !== transcript) startList(session, path)
178 await $.store.set(`transcript:${session}`, { path, at: await $.clock.now() }).catch(() => {})
179 $.ui.invalidate('ui.render')
180}
181
182// Asking the id on every row would cost a round trip each, so rows ask after a session.end, or once this long has passed.
183const SESSION_CHECK_MS = 500
184let sessionCheckedAt = 0
185
186async function followSession($: EngineInterface) {
187 const now = Date.now()
188 if (listSession && now - sessionCheckedAt < SESSION_CHECK_MS) return
189 sessionCheckedAt = now
190 const asked = listSession
191 const session = String(await $.session.id().catch(() => listSession))
192 // A SessionStart that switched the list while the id was on its way knows better than this answer.
193 if (session !== listSession && asked === listSession) startList(session)
194}
195
196// One read at a time: two at once would feed the same lines twice.
197let reading: Promise<void> = Promise.resolve()
198const load = ($: EngineInterface) => (reading = reading.then(() => readMore($)).catch(() => {}))
199
200// An open or a draw waits this long at most, so a long session's first read never holds up the panel; it redraws once the read lands.
201const LOAD_WAIT_MS = 300
202const loadAWhile = ($: EngineInterface) => Promise.race([load($), $.clock.sleep(LOAD_WAIT_MS).catch(() => {})])
203
204// One fs.read takes a file up to this size.
205const WHOLE_READ_BYTES = 4 * 1024 * 1024
206
207// The file's text from byte `from` on; past one fs.read, only the first read streams the whole file, later ones take the new bytes.
208async function readFrom($: EngineInterface, path: string, from: number, size: number) {
209 if (size <= WHOLE_READ_BYTES) {
210 const whole = String(await $.fs.read(path).catch(error => (readError ||= reasonOf(error), '')))
211 if (whole) return from ? new TextDecoder().decode(new TextEncoder().encode(whole).subarray(from)) : whole
212 }
213 if (from > 0) {
214 const parts: Uint8Array[] = []
215 for (let at = from; at < size; at += CHUNK_BYTES) parts.push(bytesOf((await $.process.run(chunkArgv(path, at, Math.min(CHUNK_BYTES, size - at)))).stdout))
216 const bytes = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0))
217 parts.reduce((at, part) => (bytes.set(part, at), at + part.length), 0)
218 return new TextDecoder().decode(bytes)
219 }
220 const isWindows = /^[A-Za-z]:|\\/.test(path)
221 // cmd's type rejects forward slashes ("The syntax of the command is incorrect").
222 const argv = isWindows ? ['cmd', '/c', 'type', path.replace(/\//g, '\\')] : ['cat', path]
223 let text = ''
224 for await (const chunk of $.process.spawn({ argv })) {
225 if (chunk.stream === 'stdout') text += chunk.text
226 }
227 return text
228}
229
230async function readMore($: EngineInterface) {
231 await followSession($)
232 if (isLoaded && !isStale) return
233 const session = listSession
234 isStale = false
235 const path = transcript || pathOf(await $.store.get(`transcript:${session}`).catch(() => '')) || (await findTranscript($))
236 if (!path || session !== listSession) return
237 transcript = path
238 const { size } = await $.fs.stat(path).catch(() => ({ size: -1 }))
239 if (size < 0 || size === failedSize || (isLoaded && size === fedBytes)) return
240 // A file shorter than what was fed was rewritten, so it is read again from its start.
241 if (size < fedBytes) {
242 transcriptRows = new TranscriptRows()
243 fedBytes = 0
244 carry = ''
245 }
246 const from = fedBytes
247 readError = ''
248 const fresh = await readFrom($, path, from, size).catch(error => (readError ||= reasonOf(error), ''))
249 if (session !== listSession || path !== transcript) return
250 // A failed read leaves the transcript unloaded until the file grows, so a draw does not read it again and again.
251 if (!fresh) {
252 failedSize = size
253 $.ui.invalidate('ui.render')
254 return
255 }
256 failedSize = -1
257 readError = ''
258 carry = transcriptRows.append(carry + fresh)
259 fedBytes = from + utf8Length(fresh)
260 isLoaded = true
261 $.ui.invalidate('ui.render')
262 // Prompts drawn since the read, not yet in the file, stay at the end.
263 const read = promptsOf(transcriptRows.rows)
264 // A queued prompt is drawn under another id than the file's, so a drawn one also matches it by its text.
265 const known = new Set([...read.map(prompt => rowKey(prompt.id)), ...read.filter(prompt => prompt.isQueued).map(prompt => sameText(prompt.text))])
266 prompts = [...read, ...prompts.filter(prompt => !known.has(rowKey(prompt.id)) && !known.has(sameText(prompt.text)))]
267 if (selected < 0 || selected >= prompts.length) selected = prompts.length - 1
268}
269
270const sameText = (text: string) => `text:${text.replace(/\[Image #\d+\]/g, '').replace(/\s+/g, ' ').trim()}`
271
272// A turn's length as the fold writes it: seconds, then minutes and seconds.
273const duration = (ms: number) => {
274 const seconds = Math.round(ms / 1000)
275 return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`
276}
277
278// A saved change reloads this mod, which redraws the panel with the new value.
279async function saveSetting($: EngineInterface, key: string, value: ConfigValue) {
280 const row = (await $.config.list()).find(each => each.key === key)
281 const result = await $.config.set({ key, value })
282 note = 'deny' in result && result.deny ? `${MARK.warn} ${result.deny}` : `${MARK.ok} ${fill(text.settingSaved, { label: row?.label ?? key })}`
283 $.ui.invalidate('ui.render')
284}
285
286// The panel's title line: the list and its settings as tabs, the shown one open.
287const tabs = (isSettings: boolean) => [
288 { id: 'prompts-tab', label: text.paneTitle, icon: look.glyph.icon.prompt ?? look.glyph.title, isOpen: !isSettings, count: prompts.length, tip: { glyph: look.glyph.icon.prompt ?? look.glyph.title, label: text.paneTitle, text: text.promptsTabTip } },
289 { id: 'prompts-settings', label: text.settingsTab, icon: look.glyph.gear, isOpen: isSettings, tip: { glyph: look.glyph.gear, label: text.settingsTab, text: text.settingsTabTip } },
290]
291
292async function configDir($: EngineInterface) {
293 const set = ((await $.env.get('CLAUDE_CONFIG_DIR')) ?? '').replace(/\\/g, '/').replace(/\/+$/, '')
294 if (set) return set
295 const home = ((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '').replace(/\\/g, '/')
296 return `${home}/.claude`
297}
298
299// When no SessionStart named the transcript, it is `<session id>.jsonl` in one of the config folder's projects folders.
300async function findTranscript($: EngineInterface) {
301 const projects = `${await configDir($)}/projects`
302 const file = `${await $.session.id()}.jsonl`
303 for (const entry of await $.fs.list(projects).catch(() => [])) {
304 const path = `${projects}/${entry.name}/${file}`
305 if (entry.kind === 'dir' && (await $.fs.exists(path).catch(() => false))) return path
306 }
307 return ''
308}
309
310// The command and the bar entry both toggle. An unplaced pane is waiting, not open.
311async function togglePane($: EngineInterface) {
312 if (await isOpen($)) {
313 await $.ui.close({ id: PANE })
314 await $.state.set({ plugin: 'mod-prompts', key: 'pane' }, false)
315 return
316 }
317 if (await isOpen($, SETTINGS)) await $.ui.close({ id: SETTINGS })
318 note = ''
319 selected = -1
320 page = -1
321 await loadAWhile($)
322 const opened = await $.ui.open({ id: PANE, title: text.paneTitle, closeOnEscape: true })
323 if (!opened.isPlaced) $.ui.toast(text.panelWaiting)
324 await $.state.set({ plugin: 'mod-prompts', key: 'pane' }, opened.isPlaced)
325}
326
327// `/mod-prompts settings` and the title row's pill toggle the settings panel, which takes the sidebar's place.
328async function toggleSettings($: EngineInterface) {
329 if (await isOpen($, SETTINGS)) {
330 await $.ui.close({ id: SETTINGS })
331 await $.state.set({ plugin: 'mod-prompts', key: 'pane' }, false)
332 return
333 }
334 if (await isOpen($)) await $.ui.close({ id: PANE })
335 note = ''
336 const opened = await $.ui.open({ id: SETTINGS, title: text.settingsPaneTitle, closeOnEscape: true })
337 if (!opened.isPlaced) $.ui.toast(text.settingsWaiting)
338 await $.state.set({ plugin: 'mod-prompts', key: 'pane' }, opened.isPlaced)
339}
340
341// Scrolls the transcript to the prompt's row; Claude Code allows it only with the person's press behind it.
342async function jump($: EngineInterface, index: number) {
343 const prompt = prompts[index]
344 if (!prompt) return
345 selected = index
346 const result = await $.ui.scroll({ to: { requestId: targetOf(drawn, prompt.id) }, block: 'start' }).catch((error: Error) => ({ deny: error.message }))
347 note = result.deny ? `${MARK.warn} ${result.deny}` : `${MARK.ok} ${fill(text.jumped, { n: index + 1 })}`
348 $.ui.invalidate('ui.render')
349}
350
351const oneLine = (text: string, width: number) => {
352 const flat = text.replace(/\s+/g, ' ')
353 return flat.length > width ? `${flat.slice(0, Math.max(0, width - 1))}…` : flat
354}
355
356// The rows a text takes at this width, wrapped at spaces, a word wider than the width breaking across rows.
357const wrapRows = (value: string, width: number) =>
358 value.split('\n').flatMap(line => {
359 const rows = ['']
360 let used = 0
361 for (const word of line.split(' ')) {
362 const chars = [...word]
363 if (used > 0 && used + 1 + chars.length <= width) {
364 rows[rows.length - 1] += ` ${word}`
365 used += 1 + chars.length
366 continue
367 }
368 if (used > 0) rows.push('')
369 rows[rows.length - 1] = chars.slice(0, width).join('')
370 used = Math.min(chars.length, width)
371 for (let at = width; at < chars.length; at += width) {
372 rows.push(chars.slice(at, at + width).join(''))
373 used = Math.min(chars.length - at, width)
374 }
375 }
376 return rows
377 })
378
379// The prompt whose card shows every line; a click on its control flips it.
380let fullPrompt = ''
381
382const clock = (at?: string) => {
383 const date = at ? new Date(at) : undefined
384 return date && !Number.isNaN(date.getTime()) ? `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}` : ''
385}
386
387// True while the pointer is on this mod's pill entry, which then shows its tip above the row.
388let tipShown = false
389// The control under the pointer in the pane, which the footer describes.
390let hoveredTip: Tip | null = null
391// The toast × under the pointer, shown in the band's tip row.
392let toastTip = ''
393
394// A band redraw can replace a hovered entry, whose leave and quiet timer then never come, so a band tip ends on its own.
395const BAND_TIP_MS = 5000
396let bandTipAt = 0
397
398function expireBandTip($: EngineInterface) {
399 const at = ++bandTipAt
400 $.clock.after(BAND_TIP_MS, () => {
401 if (at !== bandTipAt || (!tipShown && !toastTip)) return
402 tipShown = false
403 toastTip = ''
404 $.ui.invalidate('ui.render')
405 })
406}
407
408const toasts = new ToastList('prompts')
409// The animations setting, fixed per activation: off, every entry draws still.
410let still = false
411
412// The entry shimmers from the click until the pane draws, so a slow open never looks ignored.
413const OPEN_WAIT_MS = 8000
414let isOpening = false
415
416function endOpening($: EngineInterface) {
417 if (!isOpening) return
418 isOpening = false
419 $.ui.invalidate('ui.render')
420}
421
422async function openingPane($: EngineInterface) {
423 if (!(await isOpen($))) {
424 isOpening = true
425 $.ui.invalidate('ui.render')
426 $.clock.after(OPEN_WAIT_MS, () => endOpening($))
427 }
428 await togglePane($)
429 if (!(await isOpen($))) endOpening($)
430}
431
432export const register: Register = (on, options) => {
433 still = options.animations === false
434 iconMode = (options.icons as IconMode | undefined) ?? 'auto'
435 // A forced mode needs no detection; auto loads the saved answer, then checks the terminal font beside the hooks.
436 look = lookFor(iconMode === 'nerd')
437 toasts.isStill = still
438 // This mod's toasts draw as cards in the band; Claude Code's own box is narrow and cuts them.
439 on('ui.toast', async ($, e) => {
440 const id = toasts.add(e.text)
441 $.ui.invalidate('ui.render')
442 $.clock.after(e.timeoutMs ?? TOAST_MS, () => {
443 toasts.drop(id)
444 $.ui.invalidate('ui.render')
445 })
446 return { value: undefined }
447 })
448
449 on('session.start', async ($, e, next) => {
450 try {
451 bridgeOn('SessionStart', record => sessionStarted($, record))
452 await startBridge($, 'mod-prompts')
453 await loadLook($)
454 await $.command.register({ name: 'mod-prompts', description: text.commandDescription, argumentHint: '[settings]' })
455 await pruneTranscripts($)
456 // The bar entry reads this; an unload drops the pane without raising ui.close here.
457 await $.state.set({ plugin: 'mod-prompts', key: 'pane' }, await isOpen($))
458 // A hot reload leaves an open pane on its old drawing until something redraws it.
459 $.ui.invalidate('ui.render')
460 } catch {
461 // The pane stays as it was.
462 }
463
464 return next(e)
465 })
466
467 // A /resume or /clear ends this session; the next row asks for the id at once.
468 on('session.end', async ($, e, next) => {
469 sessionCheckedAt = 0
470 await endBridge($, e.sessionId)
471
472 return next(e)
473 })
474
475 on('turn.complete', async ($, e, next) => {
476 if (!e.agentId) {
477 // A closed pane reads the file when it next opens.
478 isStale = true
479 if (await isOpen($)) {
480 $.clock.after(0, async () => {
481 await load($)
482 $.ui.invalidate('ui.render')
483 })
484 }
485 }
486
487 return next(e)
488 })
489
490 // Each prompt row as Claude Code draws it, so a jump scrolls to the id the row really has.
491 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
492 await loadLook($)
493 await followSession($)
494 drawn.set(rowKey(e.requestId), e.requestId)
495 // A prompt sent after the read joins the list as its row draws.
496 const text = e.props.origin?.kind === 'composer' ? promptText(String(e.props.text ?? '')) : undefined
497 // A row redrawn under its id is already listed, and so is a queued prompt drawn again under another id.
498 if (isLoaded && text && e.requestId !== PLACEHOLDER && !prompts.some(prompt => rowKey(prompt.id) === rowKey(e.requestId) || (prompt.isQueued && sameText(prompt.text) === sameText(text)))) {
499 prompts = [...prompts, { id: e.requestId, text, at: new Date().toISOString(), tools: 0, added: 0, removed: 0 }]
500 $.clock.after(0, () => $.ui.invalidate('ui.render'))
501 }
502
503 return next(e)
504 })
505
506 on('command.run', { command: 'mod-prompts' }, async ($, e) => {
507 if ((e.args ?? '').trim() === 'settings') await toggleSettings($)
508 else await togglePane($)
509
510 return { text: '' }
511 })
512
513 on('ui.close', async ($, e, next) => {
514 try {
515 if (e.id === PANE || e.id === SETTINGS) await $.state.set({ plugin: 'mod-prompts', key: 'pane' }, await isOpen($, e.id === PANE ? SETTINGS : PANE))
516 } catch {
517 // The pane stays as it was.
518 }
519
520 return next(e)
521 })
522
523 // Deferred past this dispatch, so this mod's own state write is not nested in another plugin's.
524 on('state.set', async ($, e, next) => {
525 // The declared state names no other mod's pane, so the types narrow this event to never.
526 if (e.key === 'pane' && e.plugin !== 'mod-prompts' && (e as { value?: unknown }).value === true) {
527 $.clock.after(0, async () => {
528 const wasOpen = (await isOpen($)) || (await isOpen($, SETTINGS))
529 if (!wasOpen) return
530 await $.ui.close({ id: PANE })
531 await $.ui.close({ id: SETTINGS })
532 await $.state.set({ plugin: 'mod-prompts', key: 'pane' }, false)
533 })
534 }
535
536 return next(e)
537 })
538
539 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
540 await loadLook($)
541 const hidden = await $.state.get({ plugin: 'mod-menu', key: 'hidden' })
542 if (e.surface !== 'terminal' || e.props.hasSurvey) return next(e)
543 if (hidden.value === true) {
544 if (toasts.items.length === 0) return next(e)
545 const { Box, Client, Text } = $.ui.resolve(e)
546 return <Box key="toasts-prompts" flexDirection="column">{toastCards({ Box, Client, Text }, toasts, look, text.tipToast)}{await next(e)}</Box>
547 }
548 const nerd = look.nerd
549 const labels = labelsOf((await $.state.get({ plugin: 'mod-menu', key: 'labels' })).value)
550
551 const { Box, Client, Text } = $.ui.resolve(e)
552 // Reading the value subscribes the bar to redraws; the look trusts the pane list itself.
553 await $.state.get({ plugin: 'mod-prompts', key: 'pane' })
554 const shown = (await isOpen($)) || (await isOpen($, SETTINGS))
555 const { group, row, rest, tip } = splitRow(await next(e))
556 // Opening the pane from here queues nothing, so the entry stays live mid-turn.
557 const entry = menuEntry({ Box, Client, Text }, nerd, 'prompts', { label: text.pillLabel, labels, isOpen: shown, idle: THEME.pill, isBusy: isOpening, still })
558
559 return pillRow({ Box, Client, Text }, nerd, [entry, ...group], row, [...toastCards({ Box, Client, Text }, toasts, look, text.tipToast), ...rest], toastTip ? tipLine({ Box, Text }, toastTip) : tipShown ? tipLine({ Box, Text }, text.tip) : tip, e.props.bodyColumns)
560 })
561
562 on('ui.message', async ($, e, next) => {
563 const clicked = e.data as EntryMessage
564 const dismissed = clicked?.type === 'menu-entry' ? toasts.idOf(clicked.id) : undefined
565 if (dismissed !== undefined) {
566 toasts.drop(dismissed)
567 $.ui.invalidate('ui.render')
568 return next(e)
569 }
570 if (isTipMessage(e.data)) {
571 const { id, glyph, label, text: tipText } = e.data
572 const isToast = Boolean(id?.startsWith('toast-prompts-'))
573 toastTip = isToast ? `${label}: ${tipText}` : ''
574 if (toastTip) expireBandTip($)
575 hoveredTip = !isToast && id && glyph !== undefined && label && tipText ? { glyph, label, text: tipText } : null
576 $.ui.invalidate('ui.render')
577 return next(e)
578 }
579 const hover = e.data as { type?: string; id?: string; isOver?: boolean }
580 if (hover?.type === 'menu-hover' && hover.id === 'prompts') {
581 tipShown = hover.isOver === true
582 if (tipShown) expireBandTip($)
583 $.ui.invalidate('ui.render')
584 }
585 try {
586 const message = e.data as EntryMessage
587 if (message?.type === 'menu-entry' && message.id === 'prompts') await openingPane($)
588 if (message?.type === 'menu-entry' && message.id === 'prompts-settings' && !(await isOpen($, SETTINGS))) await toggleSettings($)
589 if (message?.type === 'menu-entry' && message.id === 'prompts-tab' && !(await isOpen($))) await togglePane($)
590 if (message?.type === 'menu-entry' && (message.id === 'prompts-page-prev' || message.id === 'prompts-page-next')) {
591 const shown = page < 0 ? Math.floor(Math.max(0, selected) / PAGE_SIZE) : page
592 page = Math.max(0, Math.min(pageCount - 1, shown + (message.id === 'prompts-page-next' ? 1 : -1)))
593 $.ui.invalidate('ui.render')
594 }
595 if (message?.type === 'menu-entry' && message.id.startsWith(MORE)) {
596 const id = message.id.slice(MORE.length)
597 fullPrompt = fullPrompt === id ? '' : id
598 $.ui.invalidate('ui.render')
599 }
600 const change = settingChoice(e.data)
601 if (change) await saveSetting($, change.key, change.value)
602 } catch {
603 // The pane stays as it was.
604 }
605
606 return next(e)
607 })
608
609 on('ui.message', { requestId: 'prompts' }, async ($, e, next) => {
610 const message = e.data as TimelineMessage
611 if (message?.type === 'select' && message.index !== selected) {
612 selected = message.index
613 $.ui.invalidate('ui.render')
614 }
615
616 return next(e)
617 })
618
619 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
620 await loadLook($)
621 if (e.requestId !== PANE && e.requestId !== SETTINGS) return next(e)
622 if (isOpening) $.clock.after(0, () => endOpening($))
623 // @ts-expect-error The host's types omit Client from a Pane's elements, yet a Pane draws it.
624 const { Box, Button, Client, Text } = $.ui.resolve(e)
625
626 if (e.requestId === SETTINGS) {
627 const rows = (await $.config.list()).filter(row => row.key.startsWith('mod-prompts.'))
628 return (
629 <Box flexDirection="column" paddingX={1} minHeight={e.props.scroll.bodyRows} backgroundColor={THEME.bg}>
630 {tabRow({ Box, Client, Text }, look.nerd, tabs(true), still)}
631 {sepText({ Text }, withMark(look, note) || fill(text.settingsHint, { sep: MARK.sep }), statusColor(note), 'status-line', { wrap: 'truncate-end' })}
632 <Box marginTop={0}>{settingsBody({ Box, Button, Client, Text }, look, { rows, actions: [], working: false, run: async () => {}, still })}</Box>
633 <Box key="footer-space" height={FOOTER_ROWS} flexShrink={0} />
634 {paneFooter({ Box, Text }, hoveredTip, e.props.bodyColumns, e.props.scroll)}
635 </Box>
636 )
637 }
638
639 // A reload resets module state while the pane stays open, so read the transcript again.
640 await followSession($)
641 if (prompts.length === 0) await loadAWhile($)
642
643 // Pane padding, the section's border and padding, the gutter and the dot.
644 const width = Math.max(10, e.props.bodyColumns - 2 - 4 - GUTTER - 2)
645 const current = prompts[selected]
646 const stops = prompts.map((prompt, index) => ({ index, time: clock(prompt.at), text: oneLine(prompt.text, width), label: fill(text.stopLabel, { n: index + 1 }), tip: text.stopTip }))
647 const marks = { dot: MARK.stop, openDot: MARK.stopOpen }
648 pageCount = Math.max(1, Math.ceil(stops.length / PAGE_SIZE))
649 const shownPage = Math.min(pageCount - 1, page < 0 ? Math.floor(Math.max(0, selected) / PAGE_SIZE) : page)
650 const first = shownPage * PAGE_SIZE
651 const onPage = stops.slice(first, first + PAGE_SIZE)
652 const hasOpen = selected >= first && selected < first + PAGE_SIZE
653 const before = hasOpen ? onPage.filter(stop => stop.index <= selected) : onPage
654 const after = hasOpen ? onPage.filter(stop => stop.index > selected) : []
655 // At the first or last page the button stays in its place, dimmed; a press there changes nothing.
656 const pager = (id: string, label: string, tip: string, isEdge: boolean) => (
657 <Client key={id} module="./entry.tsx" height={1} props={{ id, label, isOpen: false, bg: THEME.surface3, idle: isEdge ? THEME.muted : THEME.secondary, still, tip: { glyph: look.glyph.icon.prompt ?? look.glyph.title, label, text: tip } } satisfies EntryProps} />
658 )
659 // The engine caps how large one Client's tree may serialize, so a long session's stops spread over several Clients.
660 const timeline = (key: string, list: typeof stops) =>
661 Array.from({ length: Math.ceil(list.length / STOPS_PER_CLIENT) }, (_, n) => {
662 const chunk = list.slice(n * STOPS_PER_CLIENT, (n + 1) * STOPS_PER_CLIENT)
663 return <Client key={n === 0 ? key : `${key}-${n}`} module="./timeline.tsx" height={timelineRows(chunk.length)} props={{ stops: chunk, selected, glyph: look.glyph.icon.prompt ?? look.glyph.title, still, ...marks } satisfies TimelineProps} />
664 })
665 const stat = (key: string, body: unknown) => pill({ Box, Text }, look.nerd, THEME.surface3, body, key, THEME.surface1)
666 const stats = current
667 ? [
668 current.durationMs !== undefined ? stat('open-time', <Text color={THEME.secondary}>{duration(current.durationMs)}</Text>) : null,
669 current.added || current.removed
670 ? stat('open-counts', (
671 <Text>
672 <Text color={THEME.good}>{current.added ? `+${current.added}` : ''}</Text>
673 <Text color={THEME.alert}>{current.added && current.removed ? ' ' : ''}{current.removed ? `−${current.removed}` : ''}</Text>
674 </Text>
675 ))
676 : null,
677 current.tools ? stat('open-tools', <Text color={THEME.secondary}>{fill(text.tools, { count: current.tools })}</Text>) : null,
678 ].filter(Boolean)
679 : []
680
681 // The card's text is the pane's width less its border and padding; a prompt past its rows gives the last one to the count.
682 const pagerRows = pageCount > 1 ? 1 : 0
683 const textRows = CARD_ROWS - 2 - pagerRows
684 const wrapped = current ? wrapRows(current.text, Math.max(10, e.props.bodyColumns - 6)) : []
685 const rowsNeeded = wrapped.length
686 const isClipped = rowsNeeded > textRows
687 // Opened, the card grows to every row, as far as the pane has room for it.
688 const isFull = isClipped && current !== undefined && fullPrompt === current.id
689 const roomRows = Math.max(textRows, e.props.scroll.bodyRows - HEAD_ROWS - FOOTER_ROWS - 4 - pagerRows)
690 const textShown = isFull ? Math.min(rowsNeeded, roomRows) : textRows
691 const cardRows = CARD_ROWS + (textShown - textRows)
692 const shownRows = wrapped.slice(0, textShown)
693 const moreId = `${MORE}${current?.id ?? ''}`
694 const moreLabel = isFull ? text.showLess : fill(text.cardMore, { n: rowsNeeded - textRows })
695 const moreArrow = isFull ? look.glyph.less : look.glyph.more
696 const moreControl = (
697 <Client key={moreId} module="./entry.tsx" width={[...moreLabel].length + 4} height={1} props={{ id: moreId, label: moreLabel, lead: moreArrow, leadColor: THEME.accent, isOpen: false, bg: cardFill(), idle: THEME.muted, still, nerd: look.nerd, tip: { glyph: moreArrow, label: moreLabel, text: isFull ? text.tipShowLess : text.tipShowAll } } satisfies EntryProps} />
698 )
699
700 // Mouse only: the jump is a Button, since a scroll needs the person's press behind it; a click on a stop opens it.
701 return (
702 <Box flexDirection="column" paddingX={1} minHeight={e.props.scroll.bodyRows} backgroundColor={THEME.bg}>
703 {tabRow({ Box, Client, Text }, look.nerd, tabs(false), still)}
704 {sepText({ Text }, note || fill(text.hint, { sep: MARK.sep }), statusColor(note), 'status-line', { wrap: 'truncate-end' })}
705 {prompts.length === 0 ? <Text color={THEME.muted}>{failedSize > 0 ? (readError ? fill(text.unreadableWhy, { reason: readError }) : text.unreadable) : text.empty}</Text> : null}
706 {current ? cardSpace({ Box }, cardRows) : null}
707 {section({ Box, Text }, look, 'timeline', '', text.thisSession, [
708 before.length > 0 ? (
709 timeline('timeline-before', before)
710 ) : null,
711 current && hasOpen ? (
712 <Box key="open" flexDirection="row" paddingLeft={GUTTER} columnGap={1}>
713 <Text color={style.colors.pills.separator}>{after.length > 0 ? MARK.rail : ' '}</Text>
714 <Box key="open-stats" flexDirection="row" columnGap={1} flexGrow={1}>
715 {stats}
716 </Box>
717 {/* A Button, not an Entry: a transcript scroll needs the person's press, and a Client click is refused as not person-initiated. */}
718 {/* A Button takes no color prop; its scoped hover inverts it, so the whole pill lights with its caps. */}
719 <Box key="open-jump-pill" flexShrink={0}>
720 {pill({ Box, Text }, look.nerd, THEME.surface3, (
721 <Button key="open-jump" label={look.nerd ? MARK.open : ` ${MARK.open} `} plain hover={{ scope: 'open-jump-box', color: THEME.accent, inverse: true }} onPress={() => jump($, selected)} />
722 ), 'open-jump-box', THEME.surface1, THEME.accent)}
723 </Box>
724 </Box>
725 ) : null,
726 after.length > 0 ? (
727 timeline('timeline-after', after)
728 ) : null,
729 ])}
730 {current
731 ? [stickyCard({ Box, Text }, e.props.scroll.offset, e.props.bodyColumns, cardRows, [
732 <Box key="card-text" height={shownRows.length} flexDirection="column" overflow="hidden" flexShrink={0}>
733 {shownRows.map((row, at) =>
734 isClipped && at === shownRows.length - 1 ? (
735 <Box key={`card-row-${at}`} flexDirection="row">
736 <Box key="card-row-text" flexShrink={1}><Text color={THEME.muted} wrap="truncate-end">{row || ' '}</Text></Box>
737 <Box key="card-row-gap" flexGrow={1} />
738 <Box key="card-row-more" flexShrink={0}>{moreControl}</Box>
739 </Box>
740 ) : (
741 <Text key={`card-row-${at}`} color={THEME.muted} wrap="truncate-end">{row || ' '}</Text>
742 ),
743 )}
744 </Box>,
745 // The pager rides in the pinned card, so it stays in view however far the timeline scrolls.
746 pageCount > 1 ? (
747 <Box key="pager" flexDirection="row" columnGap={1} flexShrink={0}>
748 {pill({ Box, Text }, look.nerd, THEME.surface3, pager('prompts-page-prev', text.pagePrev, text.pagePrevTip, shownPage === 0), 'pager-prev', cardFill())}
749 {pill({ Box, Text }, look.nerd, THEME.surface3, pager('prompts-page-next', text.pageNext, text.pageNextTip, shownPage === pageCount - 1), 'pager-next', cardFill())}
750 {sepText({ Text }, fill(text.page, { page: shownPage + 1, pages: pageCount, from: first + 1, to: first + onPage.length, sep: MARK.sep }), THEME.muted, 'page-count')}
751 </Box>
752 ) : null,
753 ]),
754 <Box key="card-edge-wrap" position="absolute" top={Math.max(HEAD_ROWS, e.props.scroll.offset)} left={1} width={Math.max(10, e.props.bodyColumns - 2)} height={1}>
755 {titleEdge({ Box, Text }, look, 'card', 'prompt', fill(text.cardNumber, { n: selected + 1 }), clock(current.at), 0)}
756 </Box>]
757 : null}
758 <Box key="footer-space" height={FOOTER_ROWS} flexShrink={0} />
759 {paneFooter({ Box, Text }, hoveredTip, e.props.bodyColumns, e.props.scroll, current && hasOpen ? [{ scope: 'open-jump-box', tip: { glyph: MARK.open, label: text.jumpLabel, text: text.jumpTip } }] : [])}
760 </Box>
761 )
762 })
763}
764hooks/bridge.ts 107 lines1// hooks/bridge.ts, by v-amorim: a poll hands the lines a command hook appended to <mods-data>/<mod>/bridge/<session>.jsonl to this mod.
2import type { EngineInterface } from 'claude-code'
3
4export type BridgeRecord = Record<string, unknown>
5export type BridgeHandler = (record: BridgeRecord) => void | Promise<void>
6
7const POLL_MS = 250
8
9// One handler per event; registering again replaces it, so a mod that registers on every session.start holds one.
10const handlers = new Map<string, BridgeHandler>()
11// Characters of each session's file already delivered.
12const cursors = new Map<string, number>()
13let isStarted = false
14let isTicking = false
15let folder = ''
16
17export function bridgeOn(event: string, handler: BridgeHandler) {
18 handlers.set(event, handler)
19}
20
21async function folderOf($: EngineInterface, mod: string) {
22 const set = ((await $.env.get('CLAUDE_CONFIG_DIR')) ?? '').replace(/\\/g, '/').replace(/\/+$/, '')
23 if (set) return `${set}/mods-data/${mod}/bridge`
24 // The command hooks write under $HOME, so it comes before USERPROFILE; a Git Bash `/c/Users/x` becomes `C:/Users/x`, which $.fs can open.
25 const raw = ((await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || '').replace(/\\/g, '/').replace(/\/+$/, '')
26 const home = raw.replace(/^\/([A-Za-z])(?=\/[^/])/, (_, drive: string) => `${drive.toUpperCase()}:`)
27 return home ? `${home}/.claude/mods-data/${mod}/bridge` : ''
28}
29
30async function deliver(record: BridgeRecord) {
31 try {
32 await handlers.get(String(record.hook_event_name ?? ''))?.(record)
33 } catch {
34 // A handler's failure never stops the poll.
35 }
36}
37
38// The complete lines this session's file gained since the last read, and whether this is the first read since the module started.
39async function readNew($: EngineInterface, session: string) {
40 if (!folder || !session) return { lines: [] as string[], isFirst: false }
41 const text = String(await $.fs.read(`${folder}/${session}.jsonl`).catch(() => ''))
42 const isFirst = !cursors.has(session)
43 let cursor = cursors.get(session) ?? 0
44 // A file shorter than what was read was deleted or emptied, so it starts over.
45 if (text.length < cursor) cursor = 0
46 const end = text.lastIndexOf('\n') + 1
47 cursors.set(session, Math.max(end, cursor))
48 return { lines: end > cursor ? text.slice(cursor, end).split('\n').filter(Boolean) : [], isFirst }
49}
50
51async function poll($: EngineInterface, session: string) {
52 const { lines, isFirst } = await readNew($, session)
53 const records: BridgeRecord[] = []
54 for (const line of lines) {
55 try {
56 const parsed: unknown = JSON.parse(line)
57 if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) records.push(parsed as BridgeRecord)
58 } catch {
59 // A corrupt line is skipped.
60 }
61 }
62 for (const record of isFirst ? lastOfEach(records) : records) await deliver(record)
63}
64
65// After a reload the file holds rows delivered before it, so only the last of each event is state worth replaying.
66export function lastOfEach(records: BridgeRecord[]) {
67 const last = new Map(records.map(record => [String(record.hook_event_name ?? ''), record]))
68 return records.filter(record => last.get(String(record.hook_event_name ?? '')) === record)
69}
70
71async function tick($: EngineInterface) {
72 if (isTicking) return
73 isTicking = true
74 try {
75 // Asked every tick, so a /clear or a resume reads the new session's file.
76 await poll($, String(await $.session.id()))
77 } catch {
78 // The next tick tries again.
79 } finally {
80 isTicking = false
81 }
82}
83
84// Starts the poll once; `mod` names the mod's folder under mods-data.
85export async function startBridge($: EngineInterface, mod: string) {
86 if (isStarted) return
87 isStarted = true
88 try {
89 folder = await folderOf($, mod)
90 if (!folder) return
91 $.clock.every(POLL_MS, () => void tick($))
92 await tick($)
93 } catch {
94 // Without the bridge the mod keeps to what it finds on its own.
95 }
96}
97
98// A session ended: what its file still holds is delivered; the SessionEnd command hook deletes the file.
99export async function endBridge($: EngineInterface, session: string) {
100 if (!folder || !session) return
101 try {
102 await poll($, session)
103 } catch {
104 // The next read of the file delivers it.
105 }
106}
107hooks/card.tsx 37 lines1// hooks/card.tsx, by v-amorim
2// Shared sticky card; every mod whose pane has a card carries the same copy.
3import type { RenderElement } from 'claude-code'
4
5import { cardFill } from './look'
6import { THEME } from './theme'
7
8// Rows above the card: the heading, the status line and the gap under them.
9export const HEAD_ROWS = 3
10
11type Elements = { Box: (props: any) => RenderElement; Text: (props: any) => RenderElement }
12
13// The card's space in the pane's flow; the section under it brings its own blank row, so the list starts one row clear of the border.
14export const cardSpace = ({ Box }: Pick<Elements, 'Box'>, rows: number) => <Box key="card-space" height={rows} marginTop={1} flexShrink={0} />
15
16// Drawn last over the list at the top of the view; the painted row under it keeps a scrolled row off the border.
17export function stickyCard(elements: Elements, offset: number, columns: number, rows: number, body: unknown, hue: string = THEME.accent) {
18 const { Box } = elements
19 return (
20 <Box key="card-wrap" position="absolute" top={Math.max(HEAD_ROWS, offset)} left={1} width={Math.max(10, columns - 2)} height={rows + 1} flexDirection="column">
21 <Box
22 key="card"
23 height={rows}
24 flexDirection="column"
25 paddingX={1}
26 overflow="hidden"
27 borderStyle="round"
28 borderColor={THEME.border}
29 backgroundColor={cardFill(hue)}
30 >
31 {body}
32 </Box>
33 <Box key="card-gap" height={1} backgroundColor={THEME.bg} />
34 </Box>
35 )
36}
37hooks/entry.tsx 241 lines1// hooks/entry.tsx, by v-amorim
2// Shared pill entry; every mod that draws a menu entry carries the same copy.
3import type { ClientModule, ClientSurface } from 'claude-code'
4
5import style from './style'
6import { CAPS, mix, THEME, WASH } from './theme'
7
8// color: open text (a red Run); idle: closed text, muted unless set; isBusy shimmers the label until the click's result lands.
9// still: the mod's animations setting is off, so nothing shimmers and the hover color switches at once.
10// lead: a bare glyph before the label in leadColor (after a space, none when tight: the cap is the gap); subLead: an icon before it as a rounded subpill in subLeadColor;
11// badge: a count after it in its own pill. Subpills are capped where `nerd` has the caps.
12// tight: no label padding; tip: posts `tip` instead of `menu-hover`; rail: a divider (a Text between Clients squeezes them); rule: a full-width hairline.
13// spinner: the braille dots lead the label in accent while work runs (write the label without its …); still holds the first frame.
14export type EntryProps = {
15 id: string
16 label: string
17 isOpen: boolean
18 bg: string
19 color?: string
20 idle?: string
21 isBusy?: boolean
22 still?: boolean
23 lead?: string
24 leadColor?: string
25 subLead?: string
26 subLeadColor?: string
27 badge?: { text: string; color: string; bg: string }
28 nerd?: boolean
29 tight?: boolean
30 tip?: Tip
31 rail?: boolean
32 rule?: boolean
33 spinner?: boolean
34 compact?: boolean // false: the pill row may cut the entry to its lead when the row is wider than the band
35}
36
37const entryText = (props: EntryProps) => {
38 const pad = props.tight ? (props.isOpen && !props.nerd ? ' ' : '') : ' '
39 return `${pad}${props.compact && props.lead ? '' : props.label}${props.tight && props.badge ? ' ' : pad}`
40}
41
42// The cells an entry takes; subpills add their two caps, or their two padding spaces in safe mode.
43export const entryCells = (props: EntryProps) => {
44 if (props.rail) return 1
45 const cells = (text: string) => [...text].length
46 const leadText = props.lead ?? (props.spinner ? SPIN_FRAMES[0]! : '')
47 const lead = leadText ? cells(leadText) + (props.tight ? 0 : 1) : 0
48 return lead + (props.subLead ? cells(props.subLead) + 2 : 0) + cells(entryText(props)) + (props.badge ? cells(props.badge.text) + 2 : 0)
49}
50
51// A hovered control's glyph, label and what it does; the pane footer and the band's tip row both show it.
52export type Tip = { glyph: string; label: string; text: string }
53
54// Posted to the owning mod's ui.message when the pointer enters a control (id set) or leaves it (id null).
55export type TipMessage = { type: 'tip'; id: string | null; glyph?: string; label?: string; text?: string }
56
57export const isTipMessage = (data: unknown): data is TipMessage => (data as TipMessage | undefined)?.type === 'tip'
58
59// The band's tip row text for a tip message: "Label: what it does", or '' once the pointer left.
60export const tipText = (message: TipMessage | null | undefined) => (message?.id && message.label ? `${message.label}: ${message.text ?? ''}` : '')
61
62// A control calls this from onPointer, with its tip on enter and null on leave; never also `menu-hover`, one post per frame lands.
63export const postTip = (surface: { post: (data: any) => void }, id: string, tip: Tip | null) => {
64 surface.post((tip ? { type: 'tip', id, glyph: tip.glyph, label: tip.label, text: tip.text } : { type: 'tip', id: null }) satisfies TipMessage)
65}
66
67// menu-hover tells the owning mod the pointer arrived or left, so it can show its tip under the row.
68export type EntryMessage = { type: 'menu-entry'; id: string } | { type: 'menu-hover'; id: string; isOver: boolean }
69
70// A dialog opening under the pointer can swallow the leave event, so a hover with no pointer event for this long ends on its own.
71const HOVER_QUIET_MS = 5000
72
73export const TICK_MS = 140
74
75// The loading shimmer: a soft primary-70 band sweeps the dim text, then a pause.
76const BAND = style.colors.shimmer
77const SWEEP_PAUSE = 8
78
79export const shimmerColor = (at: number, phase: number, length: number) => {
80 const period = length + BAND.length * 2 + SWEEP_PAUSE
81 const head = (phase % period) - BAND.length
82 return BAND[Math.abs(at - head)] ?? style.colors.pills.readout
83}
84
85// The braille dots spinner, one frame every SPIN_MS, in accent.
86export const SPIN_FRAMES = [...style.marks.spinner]
87export const SPIN_MS = 80
88
89// The pointer lights a control at once and its color fades out over this many steps, one every HOVER_TICK_MS.
90export const HOVER_STEPS = 2
91
92export const HOVER_TICK_MS = 60
93
94export { mix }
95
96// One step of a hover fade: full at once while the pointer is on, one step toward 0 once it left.
97export const stepToward = (level: number, isOver: boolean) => (isOver ? HOVER_STEPS : Math.max(0, level - 1))
98
99// Even steps: an eased last step barely moves the color, so the fade seemed to hang at its end.
100export const hoverMix = (from: string, to: string, level: number) => mix(from, to, level / HOVER_STEPS)
101
102// A click shimmers the entry until the mod answers with new props, or this long at most.
103const PRESS_MS = 1200
104
105// pressed: the props a click saw and when it came, cleared once the mod answers.
106type State = { isOver: boolean; at: number; phase: number; level?: number; spin?: number; pressed?: { props: string; at: number } }
107
108const isPressed = (state: State | undefined, props: EntryProps | undefined) =>
109 Boolean(state?.pressed && Date.now() - state.pressed.at < PRESS_MS && state.pressed.props === JSON.stringify(props))
110
111// Each instance's running fade-out timer, started by the leave event and stopped at 0 or when the pointer returns.
112const fades = new WeakMap<object, () => void>()
113
114const startFade = (surface: ClientSurface<State>) => {
115 if (fades.has(surface)) return
116 const stop = surface.every(HOVER_TICK_MS, () => {
117 const { state } = surface
118 if (!state) return
119 const level = stepToward(state.level ?? 0, state.isOver)
120 if (level !== (state.level ?? 0)) surface.setState({ ...state, level })
121 if (state.isOver || level === 0) {
122 stop()
123 fades.delete(surface)
124 }
125 })
126 fades.set(surface, stop)
127}
128
129// The ticker reads the latest props from here, since its closure keeps the first ones.
130const latest = new WeakMap<object, EntryProps>()
131
132// Each instance's running tick: it runs only while a shimmer moves or a hover is watched, so idle pills cost no wakeups.
133const ticks = new WeakMap<object, () => void>()
134
135const startTick = (surface: ClientSurface<State>) => {
136 if (ticks.has(surface)) return
137 const stop = surface.every(TICK_MS, () => {
138 const { state } = surface
139 if (!state) return
140 const now = latest.get(surface)
141 if (!now?.still && (now?.isBusy || isPressed(state, now))) surface.setState({ ...state, phase: state.phase + 1 })
142 else if (state.pressed) surface.setState({ ...state, pressed: undefined })
143 else if (state.isOver && Date.now() - state.at >= HOVER_QUIET_MS) {
144 surface.setState({ ...state, isOver: false, at: Date.now() })
145 if (!now?.still) startFade(surface)
146 const id = now?.id ?? ''
147 if (now?.tip) postTip(surface, id, null)
148 else surface.post({ type: 'menu-hover', id, isOver: false } satisfies EntryMessage)
149 } else if (!state.isOver) {
150 stop()
151 ticks.delete(surface)
152 }
153 })
154 ticks.set(surface, stop)
155}
156
157// Each spinning instance's frame clock, stopped once the entry stops spinning.
158const spins = new WeakMap<object, () => void>()
159
160const startSpin = (surface: ClientSurface<State>) => {
161 if (spins.has(surface)) return
162 const stop = surface.every(SPIN_MS, () => {
163 const { state } = surface
164 const now = latest.get(surface)
165 if (!state || !now?.spinner || now.still) {
166 stop()
167 spins.delete(surface)
168 return
169 }
170 surface.setState({ ...state, spin: (state.spin ?? 0) + 1 })
171 })
172 spins.set(surface, stop)
173}
174
175// A Button inverts under the pointer, so the entry is plain text that lights up like a sidebar row.
176const Entry: ClientModule<EntryProps, State> = (props, surface) => {
177 const { Box, Text } = surface.elements
178 if (props.rail) return <Text color={style.colors.pills.separator} backgroundColor={props.bg}>{style.marks.rail}</Text>
179 if (props.rule) return <Text color={THEME.border} backgroundColor={props.bg}>{style.marks.rule.repeat(Math.max(1, surface.columns))}</Text>
180 const hovered = surface.state?.isOver === true
181 latest.set(surface, props)
182
183 if (!surface.state) surface.setState({ isOver: false, at: 0, phase: 0 })
184 if (props.isBusy && !props.still) startTick(surface)
185 if (props.spinner && !props.still) startSpin(surface)
186
187 surface.onPointer(event => {
188 startTick(surface)
189 const over = event.type !== 'leave'
190 if (over !== hovered) {
191 if (props.tip) postTip(surface, props.id, over ? props.tip : null)
192 else surface.post({ type: 'menu-hover', id: props.id, isOver: over } satisfies EntryMessage)
193 }
194 const isClick = event.type === 'down' && event.button === 'left'
195 const base = surface.state ?? { phase: 0, isOver: false, at: 0 }
196 const level = over === base.isOver ? base.level : over ? HOVER_STEPS : props.still ? 0 : base.level
197 if (!over && !props.still) startFade(surface)
198 surface.setState({ ...base, isOver: over, at: Date.now(), level, pressed: isClick ? { props: JSON.stringify(props), at: Date.now() } : base.pressed })
199 if (isClick) surface.post({ type: 'menu-entry', id: props.id } satisfies EntryMessage)
200 })
201
202 const text = entryText(props)
203 const isShimmering = !props.still && (props.isBusy || isPressed(surface.state, props))
204 const phase = surface.state?.phase ?? 0
205 const label = isShimmering ? (
206 <Box key="label" flexDirection="row">
207 {[...text].map((char, at) => (
208 <Text key={`c${at}`} color={shimmerColor(at, phase, text.length)} backgroundColor={props.bg}>
209 {char}
210 </Text>
211 ))}
212 </Box>
213 ) : (
214 <Text key="label" color={hoverMix(props.isOpen ? (props.color ?? THEME.onSurface) : (props.idle ?? THEME.muted), THEME.accent, surface.state?.level ?? 0)} bold={props.isOpen && Boolean(props.idle)} backgroundColor={props.bg}>
215 {text.split(style.marks.sep).flatMap((part, at) => (at === 0 ? [part] : [<Text key={`sep-${at}`} color={style.colors.pills.separator}>{style.marks.sep}</Text>, part]))}
216 </Text>
217 )
218 const lead = props.spinner ? SPIN_FRAMES[props.still ? 0 : (surface.state?.spin ?? 0) % SPIN_FRAMES.length] : props.lead
219 if (!lead && !props.subLead && !props.badge) return isShimmering ? <Box flexDirection="row">{label}</Box> : label
220
221 const sub = (key: string, text: string, color: string, bg: string) => (
222 <Box key={key} flexDirection="row">
223 {props.nerd ? <Text key={`${key}-left`} color={bg} backgroundColor={props.bg}>{CAPS.left}</Text> : null}
224 <Text key={`${key}-text`} color={color} backgroundColor={bg} bold>{props.nerd ? text : ` ${text} `}</Text>
225 {props.nerd ? <Text key={`${key}-right`} color={bg} backgroundColor={props.bg}>{CAPS.right}</Text> : null}
226 </Box>
227 )
228 const subHue = props.subLeadColor ?? THEME.accent
229 const { badge } = props
230 return (
231 <Box flexDirection="row">
232 {lead ? <Text key="lead" color={props.spinner ? THEME.accent : (props.leadColor ?? THEME.accent)} backgroundColor={props.bg}>{props.tight ? lead : ` ${lead}`}</Text> : null}
233 {props.subLead ? sub('sub-lead', props.subLead, subHue, mix(subHue, props.bg, WASH)) : null}
234 {label}
235 {badge ? sub('badge', badge.text, badge.color, badge.bg) : null}
236 </Box>
237 )
238}
239
240export default Entry
241hooks/look.tsx 279 lines1// hooks/look.tsx, by v-amorim
2// Shared look; every mod carries the same copy.
3import type { RenderElement } from 'claude-code'
4
5import style from './style'
6import type { Tip } from './entry'
7import { CAPS, decodeAll, MARK, mix, THEME, WASH } from './theme'
8
9type Glyphs = Omit<typeof style.glyphs.nerd, 'icon' | 'mark'> & { icon: Record<string, string>; mark: Record<string, string> }
10
11// Safe mode: common Unicode that every modern terminal font draws. Headings and rows carry no icon.
12const SAFE: Glyphs = decodeAll(style.glyphs.safe)
13
14// Nerd mode: Font Awesome, Material and VS Code glyphs from any Nerd Font, controls padded to the same three cells.
15const NERD: Glyphs = decodeAll(style.glyphs.nerd)
16
17export type Look = { nerd: boolean; glyph: typeof SAFE }
18
19export const lookFor = (nerd: boolean): Look => ({ nerd, glyph: nerd ? NERD : SAFE })
20
21// The icons setting every drawing mod declares in its userConfig.
22export type IconMode = 'auto' | 'nerd' | 'safe'
23
24export const terminalSettings = (localAppData: string) => [
25 `${localAppData}/Packages/Microsoft.WindowsTerminal_8wekyb3d8bbwe/LocalState/settings.json`,
26 `${localAppData}/Microsoft/Windows Terminal/settings.json`,
27]
28
29// Matches "Fira Code Nerd Font Mono" and the patched suffixes NF, NFM and NFP, as in "FiraCodeNFM-Reg".
30export const isNerdName = (face: string) => /nerd|\bNFM?\b/i.test(face) || /NF[MP]?(?![A-Za-z])/.test(face)
31
32export const isNerdFace = (settingsText: string) => [...settingsText.matchAll(/"face"\s*:\s*"([^"]*)"/g)].some(match => isNerdName(match[1]!))
33
34// The variables that name the terminal: WT_SESSION from Windows Terminal, TERM_PROGRAM from programs such as mintty and iTerm2.
35export type TerminalEnv = Partial<
36 Record<'WT_SESSION' | 'TERM_PROGRAM' | 'ITERM_PROFILE' | 'KITTY_WINDOW_ID' | 'ALACRITTY_WINDOW_ID' | 'GNOME_TERMINAL_SCREEN' | 'KONSOLE_VERSION', string | undefined>
37>
38
39// What detection reads: files by path, and the stdout of a command that exits 0 (it throws otherwise).
40export type Host = { localAppData: string; home: string; read: (path: string) => Promise<string>; run: (argv: string[]) => Promise<string> }
41
42// mintty inherits WT_SESSION from a Windows Terminal tab, so only an unset or Windows Terminal TERM_PROGRAM counts.
43export const isWindowsTerminal = ({ WT_SESSION, TERM_PROGRAM }: TerminalEnv) =>
44 Boolean(WT_SESSION) && (!TERM_PROGRAM || /windows\s*terminal/i.test(TERM_PROGRAM))
45
46// Terminals whose font sits in a text config: the first file that names a font wins, and within a file the first pattern.
47const CONFIG_TERMINALS: { is: (env: TerminalEnv) => boolean; files: (host: Host) => string[]; faces: RegExp[] }[] = [
48 {
49 is: env => env.TERM_PROGRAM === 'vscode',
50 files: ({ home, localAppData }) => [
51 `${home}/Library/Application Support/Code/User/settings.json`,
52 `${home}/.config/Code/User/settings.json`,
53 `${localAppData.replace(/\/Local$/, '/Roaming')}/Code/User/settings.json`,
54 ],
55 faces: [/"terminal\.integrated\.fontFamily"\s*:\s*"([^"]*)"/, /"editor\.fontFamily"\s*:\s*"([^"]*)"/],
56 },
57 {
58 is: env => env.TERM_PROGRAM === 'ghostty',
59 files: ({ home }) => [`${home}/.config/ghostty/config`, `${home}/Library/Application Support/com.mitchellh.ghostty/config`],
60 faces: [/^\s*font-family\s*=\s*"?([^"\n#]+)/m],
61 },
62 {
63 is: env => Boolean(env.KITTY_WINDOW_ID),
64 files: ({ home }) => [`${home}/.config/kitty/kitty.conf`],
65 faces: [/^\s*font_family\s+(.+)$/m],
66 },
67 {
68 is: env => env.TERM_PROGRAM === 'WezTerm',
69 files: ({ home }) => [`${home}/.wezterm.lua`, `${home}/.config/wezterm/wezterm.lua`],
70 faces: [/wezterm\.font(?:_with_fallback)?\(\s*\{?\s*["']([^"']+)["']/],
71 },
72 {
73 is: env => Boolean(env.ALACRITTY_WINDOW_ID),
74 files: ({ home }) => [`${home}/.config/alacritty/alacritty.toml`],
75 faces: [/\[font\.normal\][^[]*?family\s*=\s*"([^"]+)"/],
76 },
77]
78
79// iTerm2 keeps its profiles in a plist; plutil prints the part asked for. The profile is the one this window runs, else the default.
80async function itermFace(env: TerminalEnv, host: Host) {
81 const plist = `${host.home}/Library/Preferences/com.googlecode.iterm2.plist`
82 const profiles: { Name?: string; Guid?: string; 'Normal Font'?: string }[] = JSON.parse(await host.run(['plutil', '-extract', 'New Bookmarks', 'json', '-o', '-', plist]))
83 const named = profiles.find(profile => profile.Name === env.ITERM_PROFILE)
84 if (named) return named['Normal Font']
85 const guid = (await host.run(['plutil', '-extract', 'Default Bookmark Guid', 'raw', '-o', '-', plist])).trim()
86 return profiles.find(profile => profile.Guid === guid)?.['Normal Font']
87}
88
89// GNOME Terminal keeps the default profile's font in dconf, and ignores it while the profile uses the system font.
90async function gnomeFace(host: Host) {
91 const unquote = (text: string) => text.trim().replace(/^'|'$/g, '')
92 const schema = `org.gnome.Terminal.Legacy.Profile:/org/gnome/terminal/legacy/profiles:/:${unquote(await host.run(['gsettings', 'get', 'org.gnome.Terminal.ProfilesList', 'default']))}/`
93 if (unquote(await host.run(['gsettings', 'get', schema, 'use-system-font'])) === 'true') return undefined
94 return unquote(await host.run(['gsettings', 'get', schema, 'font']))
95}
96
97// Konsole names its profile in konsolerc, and the profile file holds `Font=Name,size,...`.
98async function konsoleFace(host: Host) {
99 const profile = /^DefaultProfile=(.+)$/m.exec(await host.read(`${host.home}/.config/konsolerc`))?.[1]?.trim()
100 return profile ? /^Font=([^,\n]+)/m.exec(await host.read(`${host.home}/.local/share/konsole/${profile}`))?.[1] : undefined
101}
102
103async function configFace(terminal: (typeof CONFIG_TERMINALS)[number], host: Host) {
104 for (const path of terminal.files(host)) {
105 const text = await host.read(path).catch(() => '')
106 for (const pattern of terminal.faces) {
107 const face = pattern.exec(text)?.[1]
108 if (face) return face
109 }
110 }
111 return undefined
112}
113
114function terminalFace(env: TerminalEnv, host: Host) {
115 if (env.TERM_PROGRAM === 'iTerm.app') return itermFace(env, host)
116 if (env.GNOME_TERMINAL_SCREEN) return gnomeFace(host)
117 if (env.KONSOLE_VERSION) return konsoleFace(host)
118 const terminal = CONFIG_TERMINALS.find(candidate => candidate.is(env))
119 return terminal ? configFace(terminal, host) : Promise.resolve(undefined)
120}
121
122// Every mod looks up the font on start at once, so a lookup can fail by chance; a third failure throws and the caller keeps its saved answer.
123async function retried<T>(attempt: () => Promise<T>, tries = 3): Promise<T> {
124 for (let left = tries - 1; ; left--) {
125 try {
126 return await attempt()
127 } catch (error) {
128 if (left === 0) throw error
129 }
130 }
131}
132
133// Nerd glyphs only when the terminal this window runs in names a Nerd font; an unknown terminal stays safe, and a lookup that keeps failing throws.
134export async function detectNerd(env: TerminalEnv, host: Host) {
135 if (isWindowsTerminal(env)) {
136 for (const path of terminalSettings(host.localAppData)) {
137 if (isNerdFace(await host.read(path).catch(() => ''))) return true
138 }
139 return false
140 }
141 const face = await retried(() => terminalFace(env, host))
142 return face !== undefined && isNerdName(face)
143}
144
145// `text` led by its icon in Nerd mode; the text alone in safe mode or when the name has no icon.
146export const withIcon = (look: Look, name: string, text: string) => (look.glyph.icon[name] ? `${look.glyph.icon[name]} ${text}` : text)
147
148// A status note with its leading mark (MARK.warn or MARK.ok from theme.ts) drawn as this look's glyph.
149export function withMark(look: Look, note: string) {
150 if (note.startsWith('! ')) return `${look.glyph.warn} ${note.slice(2)}`
151 if (note.startsWith('✓ ')) return `${look.glyph.ok} ${note.slice(2)}`
152 return note
153}
154
155type Elements = { Box: (props: any) => RenderElement; Text: (props: any) => RenderElement }
156
157// A pane's title line: the name's icon in Nerd mode, the safe mark otherwise.
158export const paneTitle = ({ Text }: Elements, look: Look, icon: string, text: string) => (
159 <Text key="pane-title" color={THEME.accent} bold>{`${look.glyph.icon[icon] ?? look.glyph.title} ${text}`}</Text>
160)
161
162// One line of text in `color` with every separator dot in the separator colour, since a dot in the text's colour reads as part of the words.
163export function sepText({ Text }: Pick<Elements, 'Text'>, text: string, color: string, key: string, props: Record<string, unknown> = {}) {
164 return (
165 <Text key={key} {...props}>
166 {text.split(MARK.sep).flatMap((part, at) => [
167 ...(at > 0 ? [<Text key={`${key}-sep-${at}`} color={style.colors.pills.separator}>{MARK.sep}</Text>] : []),
168 <Text key={`${key}-part-${at}`} color={color}>{part}</Text>,
169 ])}
170 </Text>
171 )
172}
173
174// The footer rows every pane reserves at its bottom: a rule, then the hovered control's glyph, label and what it does.
175export const FOOTER_ROWS = 2
176
177// A Button cannot post a tip, so its tip is drawn hidden and shown by the hover group the Button's pill shares.
178export type Reveal = { scope: string; tip: Tip }
179
180const tipRow = ({ Box, Text }: Elements, key: string, tip: Tip | null, columns: number, extra: Record<string, unknown> = {}) => (
181 <Box key={key} flexDirection="row" paddingX={1} width={columns} height={1} backgroundColor={THEME.bg} {...extra}>
182 {tip ? <Text color={THEME.accent} backgroundColor={THEME.bg}>{tip.glyph}</Text> : null}
183 {tip ? <Box flexShrink={0}><Text color={THEME.onSurface} backgroundColor={THEME.bg}>{` ${tip.label}`}</Text></Box> : null}
184 {tip ? sepText({ Text }, ` ${MARK.sep} ${tip.text}`, THEME.muted, `${key}-text`, { backgroundColor: THEME.bg, wrap: 'truncate-end' }) : <Text backgroundColor={THEME.bg}> </Text>}
185 </Box>
186)
187
188// Pinned at the pane bottom (scroll offset plus body rows) on its own background, so no row shows through; the row under the rule stays blank with no tip, so nothing jumps.
189export function paneFooter(elements: Elements, tip: Tip | null, columns: number, scroll: { offset: number; bodyRows: number }, reveals: Reveal[] = []) {
190 const { Box, Text } = elements
191 return (
192 <Box key="pane-footer" position="absolute" top={Math.max(0, scroll.offset + scroll.bodyRows - FOOTER_ROWS)} left={0} width={columns} height={FOOTER_ROWS} flexDirection="column" backgroundColor={THEME.bg}>
193 <Text key="footer-rule" color={THEME.border} backgroundColor={THEME.bg}>{MARK.rule.repeat(Math.max(1, columns))}</Text>
194 {tipRow(elements, 'footer-tip', tip, columns)}
195 {reveals.map((reveal, at) =>
196 tipRow(elements, `footer-reveal-${at}`, reveal.tip, columns, { position: 'absolute', top: 1, left: 0, display: 'none', hover: { scope: reveal.scope, display: 'flex' } }),
197 )}
198 </Box>
199 )
200}
201
202// A pill's leading icon, mark or digit, or its count: a rounded subpill washed from `hue` into the `fill` it sits on.
203export function subpill({ Box, Text }: Elements, nerd: boolean, fill: string, text: string, key: string, hue: string = THEME.accent) {
204 const bg = mix(hue, fill, WASH)
205 return (
206 <Box key={key} flexDirection="row" flexShrink={0}>
207 {nerd ? <Text key={`${key}-left`} color={bg} backgroundColor={fill}>{CAPS.left}</Text> : null}
208 <Text key={`${key}-text`} color={hue} backgroundColor={bg} bold>{nerd ? text : ` ${text} `}</Text>
209 {nerd ? <Text key={`${key}-right`} color={bg} backgroundColor={fill}>{CAPS.right}</Text> : null}
210 </Box>
211 )
212}
213
214// A section's heading: a surface3 pill led by its icon as a subpill in Nerd mode, bold accent text in safe mode.
215export function heading(elements: Elements, look: Look, key: string, icon: string, title: string) {
216 const { Box, Text } = elements
217 if (!look.nerd) {
218 return (
219 <Box key={`${key}-head`}>
220 <Text color={THEME.accent} bold>{title}</Text>
221 </Box>
222 )
223 }
224 const glyph = look.glyph.icon[icon]
225 return (
226 <Box key={`${key}-head`} flexDirection="row">
227 <Text color={THEME.surface3}>{CAPS.left}</Text>
228 <Box key={`${key}-head-body`} flexDirection="row" backgroundColor={THEME.surface3}>
229 {glyph ? subpill(elements, true, THEME.surface3, glyph, `${key}-head-icon`) : null}
230 <Text color={THEME.onSurface} bold>{glyph ? ` ${title}` : title}</Text>
231 </Box>
232 <Text color={THEME.surface3}>{CAPS.right}</Text>
233 </Box>
234 )
235}
236
237// A card's fill: surface1 washed lightly toward its hue, under a neutral border. Pills on the card sit on it.
238export const cardFill = (hue: string = THEME.accent) => mix(THEME.surface1, hue, 0.06)
239
240// The row above a card, painted on bg so a scrolled card above never shows through the gap.
241export function cardGap({ Box }: Elements, key: string, bg: string = THEME.bg) {
242 return <Box key={`${key}-gap`} height={1} flexShrink={0} backgroundColor={bg} />
243}
244
245// A card's title laid over its top border after the corner (`top` is the border's row); Pane refuses borderTop, so the border stays whole underneath.
246export function titleEdge(elements: Elements, look: Look, key: string, icon: string, title: string, hint = '', top = 1, edge: string = THEME.border) {
247 const { Box, Text } = elements
248 const glyph = look.nerd ? look.glyph.icon[icon] : undefined
249 return (
250 <Box key={`${key}-edge`} position="absolute" top={top} left={1} flexDirection="row" height={1} backgroundColor={THEME.bg}>
251 <Text color={edge}>{`${MARK.rule} `}</Text>
252 {glyph ? <Text color={THEME.accent}>{`${glyph} `}</Text> : null}
253 {sepText(elements, title, THEME.onSurface, `${key}-title`, { bold: true })}
254 {hint ? sepText(elements, ` ${hint}`, THEME.muted, `${key}-hint`) : null}
255 <Text> </Text>
256 </Box>
257 )
258}
259
260// A round card tinted toward `hue`, its title (and muted `hint`) set into the top edge, `right` (such as Fold all) at that edge's end.
261export function section(elements: Elements, look: Look, key: string, icon: string, title: string, body: unknown, hue: string = THEME.accent, { hint = '', right }: { hint?: string; right?: unknown } = {}) {
262 const { Box } = elements
263 return (
264 <Box key={`${key}-block`} flexDirection="column">
265 {cardGap(elements, key)}
266 <Box key={key} flexDirection="column" borderStyle="round" borderColor={THEME.border} backgroundColor={cardFill(hue)} paddingX={1}>
267 {body}
268 </Box>
269 {title ? titleEdge(elements, look, key, icon, title, hint) : null}
270 {right ? (
271 <Box key={`${key}-edge-right`} position="absolute" top={1} right={1} flexDirection="row" height={1} backgroundColor={THEME.bg}>
272 {right}
273 </Box>
274 ) : null}
275 </Box>
276 )
277}
278
279hooks/pills.tsx 167 lines1// hooks/pills.tsx, by v-amorim
2// Shared pill row; every mod that draws in the row carries the same copy.
3import type { RenderElement } from 'claude-code'
4
5import { entryCells, mix, type EntryProps, type Tip } from './entry'
6import style from './style'
7import { CAPS, decodeAll, THEME, WASH } from './theme'
8
9// The menu group on Moonlight surface-3, readouts on surface-2.
10export const PILL = { primary: style.colors.pills.primary, secondary: style.colors.pills.secondary }
11
12// Menu entries left to right, by the id in their `menu-pad-<id>` Box key; an unknown id goes last.
13const ENTRY_ORDER = ['manager', 'skills', 'prompts', 'filetree', 'progress', 'headsup']
14
15type Node = { props?: Record<string, unknown>; children?: unknown[] } | null | undefined
16type Elements = { Box: (props: any) => RenderElement; Text: (props: any) => RenderElement }
17
18const keyOf = (node: unknown) => (node as Node)?.props?.key as string | undefined
19const childrenOf = (node: unknown) => (node as Node)?.children ?? []
20
21// Splits next(e) into the menu entries other mods drew, the rest of the row, what else the band holds, and the hover tip under the row.
22export function splitRow(others: unknown) {
23 if (keyOf(others) !== 'menu-rows') return { group: [] as unknown[], row: [] as unknown[], rest: [others], tip: null as unknown }
24 const children = childrenOf(others)
25 const rest = children.filter(child => keyOf(child) !== 'menu-row' && keyOf(child) !== 'menu-tip')
26 const pills = childrenOf(children.find(child => keyOf(child) === 'menu-row'))
27 const groupPill = pills.find(child => keyOf(child) === 'menu-group')
28 const body = childrenOf(groupPill).find(child => keyOf(child) === 'menu-group-body')
29 return {
30 // A mod still running an older copy may draw `menu-group-sep-*` separators between entries; the group drops them.
31 group: childrenOf(body).filter(child => !keyOf(child)?.startsWith('menu-group-sep')),
32 row: pills.filter(child => keyOf(child) !== 'menu-group'),
33 rest,
34 tip: children.find(child => keyOf(child) === 'menu-tip') ?? null,
35 }
36}
37
38// The one-line tip above the pill row, filled while the pointer is on an entry: its "Name:" in bold accent, the rest muted.
39export const tipLine = ({ Box, Text }: Elements, text: string) => {
40 const at = text.indexOf(': ')
41 return (
42 <Box key="menu-tip" paddingLeft={2} flexDirection="row">
43 {at < 0 ? null : <Text color={THEME.accent} bold>{text.slice(0, at + 1)}</Text>}
44 <Text color={THEME.muted}>
45 {(at < 0 ? text : text.slice(at + 1)).split(style.marks.sep).flatMap((part, index) => (index === 0 ? [part] : [<Text key={`sep-${index}`} color={style.colors.pills.separator}>{style.marks.sep}</Text>, part]))}
46 </Text>
47 </Box>
48 )
49}
50
51// One pill with its own caps where the font has them; `on` is what it sits on, `hover` lights the caps with a Button scoped `key`.
52export function pill({ Box, Text }: Elements, nerd: boolean, bg: string, body: unknown, key = 'menu-pill', on?: string, hover?: string) {
53 const under = on ? { backgroundColor: on } : {}
54 const lit = hover ? { hover: { scope: key, color: hover } } : {}
55 return (
56 <Box key={key} flexDirection="row">
57 {nerd ? <Text key={`${key}-cap-left`} color={bg} {...under} {...lit}>{CAPS.left}</Text> : null}
58 <Box key={`${key}-body`} flexDirection="row" backgroundColor={bg}>
59 {body}
60 </Box>
61 {nerd ? <Text key={`${key}-cap-right`} color={bg} {...under} {...lit}>{CAPS.right}</Text> : null}
62 </Box>
63 )
64}
65
66// mod-menu's `labels`: `always` icon and label, `never` the icon alone, `auto` the icon alone while the row overflows (Nerd only; safe keeps labels).
67export type LabelsMode = 'auto' | 'always' | 'never'
68
69// The value of `{ plugin: 'mod-menu', key: 'labels' }` as a mode; a missing or unknown value is `auto`, so a mod works without mod-menu.
70export const labelsOf = (value: unknown): LabelsMode => (value === 'always' || value === 'never' ? value : 'auto')
71
72type ClientNode = { type: 'Client'; props: { props: Record<string, unknown> } }
73const isClient = (node: unknown): node is ClientNode => (node as { type?: string } | null)?.type === 'Client'
74
75// The cells a drawn row takes with every label shown: a Client that may go compact says its own `cells`, or is an entry.tsx one.
76const cellsOf = (node: unknown): number => {
77 if (Array.isArray(node)) return node.reduce((sum: number, each) => sum + cellsOf(each), 0)
78 if (typeof node === 'string') return [...node].length
79 if (!node || typeof node !== 'object') return 0
80 if (isClient(node)) {
81 const { props } = node.props
82 return typeof props.cells === 'number' ? props.cells : entryCells({ ...(props as EntryProps), compact: false })
83 }
84 // A `menu-flash` word comes and goes with the pointer; counting it would flip the row under the pointer.
85 const kids = childrenOf(node).filter(each => each && !keyOf(each)?.startsWith('menu-flash'))
86 return kids.reduce((sum: number, each) => sum + cellsOf(each), Number((node as Node)?.props?.columnGap ?? 0) * Math.max(0, kids.length - 1))
87}
88
89const compacted = (node: unknown): unknown => {
90 if (Array.isArray(node)) return node.map(compacted)
91 if (!node || typeof node !== 'object') return node
92 if (isClient(node)) return node.props.props.compact === false ? { ...node, props: { ...node.props, props: { ...node.props.props, compact: true } } } : node
93 const kids = (node as Node)?.children
94 return kids ? { ...node, children: kids.map(compacted) } : node
95}
96
97// A panel's entry in the menu group, keyed `menu-pad-<id>` for the row's order; the open one is a pill of its own.
98export function menuEntry({ Box, Client, Text }: Elements & { Client: (props: any) => RenderElement }, nerd: boolean, id: string, { labels, ...props }: Omit<EntryProps, 'id' | 'bg' | 'tight'> & { labels?: LabelsMode }) {
99 // The open panel reads as selected, like an On switch: ok text on ok washed into the group fill.
100 const bg = props.isOpen ? mix(THEME.ok, PILL.primary, WASH) : PILL.primary
101 const glyph = (style.glyphs.nerd.icon as Record<string, string>)[`menu-${id}`]
102 const icon = nerd && !props.lead && glyph ? decodeAll(glyph) : undefined
103 // The icon sits tight against the cap, so the label that follows brings its own gap.
104 const shown = icon ? { lead: icon, label: labels === 'never' ? '' : ` ${props.label}`, ...(labels === 'auto' ? { compact: false } : {}) } : {}
105 // On Nerd the caps take the label's padding cells, so an entry is as wide as before the caps.
106 const entry = <Client key={`menu-entry-${id}`} module="./entry.tsx" height={1} props={{ id, bg, ...props, ...(props.isOpen ? { color: THEME.ok } : {}), ...shown, nerd, tight: nerd } satisfies EntryProps} />
107 return (
108 <Box key={`menu-pad-${id}`} flexDirection="row">
109 {/* A closed entry gets caps in the group's own color, so opening a panel never shifts its neighbours. */}
110 {pill({ Box, Text }, nerd, bg, entry, props.isOpen ? `menu-open-${id}` : `menu-closed-${id}`, PILL.primary)}
111 </Box>
112 )
113}
114
115// The band above the prompt: what else it holds (Claude Code's own effort line), then the pill row, so the pills sit on the input.
116export function pillRow(elements: Elements & { Client?: (props: any) => RenderElement }, nerd: boolean, group: unknown[], row: unknown[], rest: unknown[], tip: unknown = null, columns?: number) {
117 const { Box, Client, Text } = elements
118 // Load order changes with an install or a toggle, so entries sort by a fixed rank.
119 const rank = (entry: unknown) => {
120 const at = ENTRY_ORDER.indexOf(String(keyOf(entry) ?? '').replace(/^menu-pad-/, ''))
121 return at < 0 ? ENTRY_ORDER.length : at
122 }
123 // Like a choice bar: the entries side by side on one pill, the open panel's lifted into its own, a divider Client between neighbours.
124 const sorted = [...group].sort((a, b) => rank(a) - rank(b))
125 const entries = Client
126 ? sorted.flatMap((entry, at) => (at === 0 ? [entry] : [<Client key={`menu-group-sep-${at}`} module="./entry.tsx" height={1} props={{ id: `sep-${at}`, label: '', isOpen: false, bg: PILL.primary, rail: true } satisfies EntryProps} />, entry]))
127 : sorted
128 const pills = (
129 <Box key="menu-row" flexDirection="row" columnGap={1}>
130 {group.length > 0 ? pill(elements, nerd, PILL.primary, entries, 'menu-group') : null}
131 {row}
132 </Box>
133 )
134 // Measured with every label shown, so a row cut to icons never grows back into a row that does not fit.
135 const fits = columns === undefined || cellsOf(pills) <= columns
136 return (
137 <Box key="menu-rows" flexDirection="column">
138 {rest}
139 {/* Above the row, kept even when empty so nothing above jumps when a tip appears. */}
140 {tip ?? tipLine(elements, ' ')}
141 {fits ? pills : compacted(pills)}
142 </Box>
143 )
144}
145
146// One chip per view of a panel (its list, its settings), standing in for the title: id, label, icon glyph, an optional count and an optional hover tip.
147export type Tab = { id: string; label: string; icon: string; isOpen: boolean; count?: number; tip?: Tip }
148
149// A panel's title line: the open view on surface3 and bold, the others on surface2 and muted; a click swaps the view.
150export function tabRow({ Box, Client, Text }: Elements & { Client: (props: any) => RenderElement }, nerd: boolean, tabs: Tab[], still: boolean) {
151 return (
152 <Box key="tab-row" flexDirection="row" columnGap={1}>
153 {tabs.map(tab => {
154 const bg = tab.isOpen ? THEME.surface3 : THEME.surface2
155 // Client props refuse an undefined value, so a tab without a count or tip leaves the key out.
156 const countColor = tab.isOpen ? THEME.accent : style.colors.pills.readout
157 const badge = tab.count === undefined ? {} : { badge: { text: String(tab.count), color: countColor, bg: mix(countColor, bg, WASH) } }
158 const tip = tab.tip ? { tip: tab.tip } : {}
159 const entry = (
160 <Client key={tab.id} module="./entry.tsx" height={1} props={{ id: tab.id, label: tab.label, isOpen: tab.isOpen, bg, idle: THEME.muted, still, subLead: tab.icon, subLeadColor: THEME.accent, nerd, ...(tab.isOpen ? { color: THEME.secondary } : {}), ...badge, ...tip } satisfies EntryProps} />
161 )
162 return pill({ Box, Text }, nerd, bg, entry, `${tab.id}-pill`)
163 })}
164 </Box>
165 )
166}
167hooks/settings.tsx 297 lines1// hooks/settings.tsx, by v-amorim
2// Shared settings body; every mod carries the same copy.
3import type { ConfigRow, ConfigValue, RenderElement } from 'claude-code'
4
5import type { EntryProps, Tip } from './entry'
6import { cardFill, cardGap, section, sepText, type Look, type Reveal } from './look'
7import style from './style'
8import { CAPS, fill, MARK, THEME } from './theme'
9
10// A button a mod offers beside its settings: runs `/<command> <args>`, declared in its showcase.json.
11// isLocal marks the drawing mod's own command, which it calls directly, so Claude working does not hold it.
12export type Action = { label: string; command: string; args?: string; isLocal?: boolean }
13
14type Elements = { Box: (props: any) => RenderElement; Button: (props: any) => RenderElement; Client: (props: any) => RenderElement; Text: (props: any) => RenderElement }
15
16export type SettingsProps = {
17 // The mod's own /config rows (`<plugin>.<field>`), as $.config.list() returns them.
18 rows: ConfigRow[]
19 actions: Action[]
20 // Another mod's command run while Claude works only queues as a prompt, so those actions go inert.
21 working: boolean
22 run: (action: Action) => unknown
23 // The animations setting is off: a clicked control does not shimmer.
24 still?: boolean
25 // Hover tips by setting field (the key after the dot); a setting without one shows its description's first sentence.
26 tips?: Record<string, string>
27 // The mod's example cards, from its showcase.json, so its own panel and mod-manager draw the same ones.
28 previews?: Previews
29 // The mod's plugin.json userConfig, read by its own panel and by mod-manager alike, for the min and max a number setting declares.
30 fields?: Record<string, Range>
31 // Text settings (by field) another mod edits in its own panel: the card shows a pill running that panel's command in place of "edit it in /config".
32 editors?: Record<string, Action>
33}
34
35export type Range = { min?: number; max?: number }
36
37// A title's trailing "(unit)" is what its number reads in; the head drops it and the value carries it.
38export function splitTitle(label: string) {
39 const match = /^(.*?)\s*\(([^()]+)\)$/.exec(label)
40 return match ? { name: match[1]!, unit: match[2]! } : { name: label, unit: '' }
41}
42
43// Large round counts read short, in thousands or millions.
44const short = (n: number) => (n >= 1_000_000 && n % 100_000 === 0 ? `${n / 1_000_000}M` : n >= 10_000 && n % 1000 === 0 ? `${n / 1000}K` : String(n))
45
46export function showNumber(value: number, unit: string) {
47 if (!unit) return String(value)
48 if (unit === '%') return `${value}%`
49 return `${short(value)} ${value === 1 ? unit.replace(/s$/, '') : unit}`
50}
51
52// About twenty clicks from one end of the range to the other, on a 1, 2 or 5 step; no range steps by one.
53export function stepOf({ min, max }: Range = {}) {
54 if (min === undefined || max === undefined || max <= min) return 1
55 const raw = (max - min) / 20
56 const power = 10 ** Math.floor(Math.log10(raw))
57 const nearest = [1, 2, 5, 10].map(factor => factor * power).reduce((best, step) => (Math.abs(step - raw) < Math.abs(best - raw) ? step : best))
58 return Math.max(1, nearest)
59}
60
61export function stepNumber(value: number, by: 1 | -1, range: Range = {}) {
62 return Math.min(range.max ?? Infinity, Math.max(range.min ?? -Infinity, value + by * stepOf(range)))
63}
64
65type ThemeColor = keyof typeof THEME
66
67// A literal run of an example line, as the mod's previews test drew it from its real tree; a color is a theme name or a #hex.
68export type PreviewSpan = { text: string; color?: string; bg?: string; bold?: boolean }
69export type PreviewLine = { spans: PreviewSpan[] }
70export type Preview = { frame?: { border: string; bg: string }; nerd: PreviewLine[]; safe: PreviewLine[] }
71
72// showcase.json's "previews", written by `just previews`: per setting field, per value as text ("true", "false", an option), the card in each icon mode.
73export type Previews = Record<string, Record<string, Preview>>
74
75const shade = (color: string | undefined) => (color === undefined ? undefined : color in THEME ? THEME[color as ThemeColor] : color)
76
77// What the row's current value looks like, as a card under it; null when the mod drew none for that value.
78function exampleOf({ Box, Text }: Elements, look: Look, previews: Previews | undefined, row: ConfigRow, index: number) {
79 const entry = previews?.[row.key.slice(row.key.indexOf('.') + 1)]?.[String(row.value)]
80 const lines = look.nerd ? entry?.nerd : entry?.safe
81 if (!entry || !lines?.length) return null
82 // The lines carry their own padding and strip, so the frame adds none. The example sinks to bg, so it never matches the card it sits in.
83 const sunk = (color: string | undefined) => (color !== undefined && color === shade(entry.frame?.bg) ? THEME.bg : color)
84 return (
85 <Box key={`setting-preview-${index}`} flexDirection="column" borderStyle="round" borderColor={shade(entry.frame?.border) ?? THEME.border} backgroundColor={THEME.bg} marginTop={1}>
86 {lines.map((line, index) => (
87 <Text key={`line-${index}`} wrap="truncate-end">
88 {line.spans.length === 0 ? ' ' : line.spans.map((span, at) => <Text key={`span-${at}`} color={shade(span.color) ?? THEME.onSurface} {...(span.bg ? { backgroundColor: sunk(shade(span.bg)) } : {})} {...(span.bold ? { bold: true } : {})}>{span.text}</Text>)}
89 </Text>
90 ))}
91 </Box>
92 )
93}
94
95// The value as the panel shows it: on/off as words, a list as its items.
96const shown = (row: ConfigRow) =>
97 row.kind === 'boolean' ? (row.value === true ? style.text.on : style.text.off) : Array.isArray(row.value) ? row.value.join(', ') : String(row.value)
98
99// Words that stay capitalized when a description follows a tip's label and colon.
100const PROPER = new Set(['Claude', 'Haiku', 'Opus', 'Sonnet', 'Windows', 'Nerd'])
101
102// A setting's description as the tip after "Label: ": its first sentence, led by a lowercase letter unless that word is a proper noun.
103export function descriptionTip(description: string) {
104 const sentence = description.split('. ')[0]!.replace(/\.$/, '')
105 const word = /^[A-Z][a-z]+\b/.exec(sentence)?.[0]
106 return word && !PROPER.has(word) ? sentence[0]!.toLowerCase() + sentence.slice(1) : sentence
107}
108
109// A control is an Entry pill posting `setting:<key>=<value as JSON>`; the mod's ui.message hook saves it with settingChoice.
110const SETTING = 'setting:'
111const settingId = (row: ConfigRow, value: ConfigValue) => `${SETTING}${row.key}=${JSON.stringify(value)}`
112
113// The change a clicked settings control asks for, or null when the message is something else.
114export function settingChoice(data: unknown): { key: string; value: ConfigValue } | null {
115 const message = data as { type?: string; id?: string } | undefined
116 if (message?.type !== 'menu-entry' || !message.id?.startsWith(SETTING)) return null
117 const rest = message.id.slice(SETTING.length)
118 const at = rest.indexOf('=')
119 if (at === -1) return null
120 try {
121 return { key: rest.slice(0, at), value: JSON.parse(rest.slice(at + 1)) as ConfigValue }
122 } catch {
123 return null
124 }
125}
126
127// A pill already frames the glyph, so drop the brackets and padding of the standalone button (safe `[↻]`).
128const bare = (glyph: string) => glyph.replace(/[[\]\s]/g, '')
129
130// A pill on the card's tint, capped where the font has the caps; `hover` lights the caps with a Button scoped to the pill's key.
131const pillOn = ({ Box, Text }: Elements, look: Look, key: string, bg: string, body: unknown, on = THEME.surface1, hover?: string) => {
132 const lit = hover ? { hover: { scope: key, color: hover } } : {}
133 return (
134 <Box key={key} flexDirection="row" flexShrink={0}>
135 {look.nerd ? <Text key={`${key}-left`} color={bg} backgroundColor={on} {...lit}>{CAPS.left}</Text> : null}
136 {body}
137 {look.nerd ? <Text key={`${key}-right`} color={bg} backgroundColor={on} {...lit}>{CAPS.right}</Text> : null}
138 </Box>
139 )
140}
141
142export type Choice = { key: string; isOn: boolean; props: Pick<EntryProps, 'id' | 'label' | 'still' | 'tip' | 'subLead' | 'subLeadColor'> }
143
144// A control track: a surface3 pill on `on`, padded by a space each side in safe mode, where it has no caps.
145function track(elements: Elements, look: Look, key: string, on: string, parts: unknown[]) {
146 const { Box, Text } = elements
147 const space = (side: string) => (look.nerd ? null : <Text key={`${key}-space-${side}`} backgroundColor={THEME.surface3}> </Text>)
148 return pillOn(elements, look, `${key}-bar`, THEME.surface3, <Box key={`${key}-options`} flexDirection="row" backgroundColor={THEME.surface3}>{[space('left'), ...parts, space('right')]}</Box>, on)
149}
150
151// A choice in a track: the pick a lifted (surface-4) subpill in primary-70 (`ok`), the rest muted; `on` is what the track sits on.
152export function choiceBar(elements: Elements, look: Look, key: string, on: string, choices: Choice[]) {
153 const { Client } = elements
154 const entry = (choice: Choice, shape: Partial<EntryProps>) => (
155 <Client key={choice.key} module="./entry.tsx" height={1} props={{ ...choice.props, isOpen: choice.isOn, bg: THEME.surface3, ...shape } satisfies EntryProps} />
156 )
157 const options = choices.map(choice =>
158 choice.isOn
159 ? // Tight, so the subpill is as wide as an idle option: the caps (Nerd) or a space (safe) stand where its padding was.
160 pillOn(elements, look, `${choice.key}-chosen`, THEME.lifted, entry(choice, { bg: THEME.lifted, color: THEME.ok, idle: THEME.muted, nerd: look.nerd, tight: true }), THEME.surface3)
161 : entry(choice, { idle: THEME.muted }),
162 )
163 return track(elements, look, key, on, options)
164}
165
166const actionScope = (index: number) => `action-row-${index}`
167
168// The tip each live action shows in the pane footer on hover; a Button cannot post one, so paneFooter reveals it by the pill's hover group.
169export const actionReveals = (actions: Action[], look: Look, working: boolean): Reveal[] =>
170 actions.flatMap((action, index) =>
171 working && !action.isLocal ? [] : [{ scope: actionScope(index), tip: { glyph: bare(look.glyph.go), label: action.label, text: fill(style.text.runsCommand, { command: [action.command, action.args].filter(Boolean).join(' ') }) } }],
172 )
173
174const editorScope = (index: number) => `editor-row-${index}`
175
176const fieldOf = (row: ConfigRow) => row.key.slice(row.key.indexOf('.') + 1)
177
178// The footer tip of each live editor pill, like actionReveals.
179export const editorReveals = (rows: ConfigRow[], editors: Record<string, Action>, look: Look, working: boolean): Reveal[] =>
180 rows.flatMap((row, index) => {
181 const action = row.kind === 'text' && !row.isLocked ? editors[fieldOf(row)] : undefined
182 return action && !working ? [{ scope: editorScope(index), tip: { glyph: MARK.open, label: action.label, text: fill(style.text.runsCommand, { command: [action.command, action.args].filter(Boolean).join(' ') }) } }] : []
183 })
184
185// A pill with a plain Button so the press is a real one; the glyph sits in the label so the whole pill lights.
186const buttonPill = (elements: Elements, look: Look, scope: string, key: string, glyph: string, label: string, onPress: () => unknown) => {
187 const { Button } = elements
188 return pillOn(
189 elements,
190 look,
191 scope,
192 THEME.surface3,
193 <Button key={key} label={look.nerd ? `${glyph} ${label}` : ` ${glyph} ${label} `} plain hover={{ scope, color: THEME.accent, inverse: true }} onPress={onPress} />,
194 THEME.surface1,
195 THEME.accent,
196 )
197}
198
199// Edits the row in place: on/off flips, a choice is a bar, a number steps; text is edited in /config.
200function control(elements: Elements, look: Look, row: ConfigRow, index: number, still: boolean, tips?: Record<string, string>, fields?: Record<string, Range>) {
201 const { Client, Text } = elements
202 const key = `setting-${index}`
203 const text = tips?.[row.key.slice(row.key.indexOf('.') + 1)] ?? (row.description ? descriptionTip(row.description) : '')
204 // Client props refuse an undefined value, so no tip text means no key.
205 const tipFor = (glyph: string) => (text ? { tip: { glyph: bare(glyph), label: splitTitle(row.label).name, text } satisfies Tip } : {})
206 const entry = (suffix: string, label: string, value: ConfigValue, bg: string, color: string, isOpen: boolean, glyph = look.glyph.cycle) => (
207 <Client key={`${key}-${suffix}`} module="./entry.tsx" height={1} props={{ id: settingId(row, value), label, isOpen, bg, color, idle: color, still, ...tipFor(glyph) } satisfies EntryProps} />
208 )
209 if (row.isLocked) return <Text color={THEME.muted}>{`${shown(row)} ${bare(look.glyph.locked)} ${style.text.locked}`}</Text>
210 if (row.kind === 'boolean') {
211 // The switch: `━━●` on, `●━━` off, the knob a bare glyph joined to the line.
212 const isOn = row.value === true
213 const shape = isOn
214 ? { lead: MARK.track, leadColor: THEME.accent, label: MARK.knob, idle: THEME.ok }
215 : { lead: MARK.knob, leadColor: style.colors.pills.readout, label: MARK.track, idle: THEME.trackOff }
216 const flip = <Client key={`${key}-flip`} module="./entry.tsx" height={1} props={{ id: settingId(row, !isOn), isOpen: false, bg: THEME.surface3, tight: true, still, ...shape, ...tipFor(look.glyph.cycle) } satisfies EntryProps} />
217 return track(elements, look, key, cardFill(), [flip])
218 }
219 if (row.kind === 'choice' && row.options?.length) {
220 const choices = row.options.map((option, at) => ({ key: `${key}-option-${at}`, isOn: option === String(row.value), props: { id: settingId(row, option), label: option, still, ...tipFor(look.glyph.cycle) } }))
221 return choiceBar(elements, look, key, cardFill(), choices)
222 }
223 if (row.kind === 'number' && typeof row.value === 'number') {
224 const range = fields?.[row.key.slice(row.key.indexOf('.') + 1)]
225 const value = <Text key={`${key}-value`} color={THEME.ok} backgroundColor={THEME.lifted} bold>{look.nerd ? showNumber(row.value, splitTitle(row.label).unit) : ` ${showNumber(row.value, splitTitle(row.label).unit)} `}</Text>
226 return track(elements, look, key, cardFill(), [
227 entry('down', bare(look.glyph.down), stepNumber(row.value, -1, range), THEME.surface3, THEME.muted, false, look.glyph.down),
228 pillOn(elements, look, `${key}-value-pill`, THEME.lifted, value, THEME.surface3),
229 entry('up', bare(look.glyph.up), stepNumber(row.value, 1, range), THEME.surface3, THEME.muted, false, look.glyph.up),
230 ])
231 }
232 return <Text color={THEME.secondary}>{shown(row)}</Text>
233}
234
235// A setting's first row: the label bold on the left, its control anchored right.
236export const settingHead = ({ Box, Text }: Pick<Elements, 'Box' | 'Text'>, key: string, label: string, control: unknown) => (
237 <Box key={key} flexDirection="row" columnGap={2} justifyContent="space-between">
238 <Text color={THEME.onSurface} bold>{label}</Text>
239 {control}
240 </Box>
241)
242
243// A hairline across its box, under a setting's head row; a Client, since only a Client learns the width it gets.
244export const hairline = ({ Client }: Pick<Elements, 'Client'>, key: string, bg: string) => (
245 <Client key={key} module="./entry.tsx" width="100%" height={1} props={{ id: key, label: '', isOpen: false, bg, rule: true } satisfies EntryProps} />
246)
247
248// The Settings and Actions sections of a mod's panel or page: one round-bordered card per setting.
249export function settingsBody(elements: Elements, look: Look, { rows, actions, working, run, previews, tips, fields, editors = {}, still = false }: SettingsProps) {
250 const { Box, Text } = elements
251 const settings = [
252 rows.length === 0 ? <Text key="settings-none" color={THEME.muted}>{style.text.noSettings}</Text> : null,
253 ...rows.map((row, index) => {
254 const example = exampleOf(elements, look, previews, row, index)
255 const editor = row.kind === 'text' && !row.isLocked ? editors[fieldOf(row)] : undefined
256 const head = editor && !working ? (
257 <Box key={`setting-control-${index}`} flexDirection="row" columnGap={1}>
258 {control(elements, look, row, index, still, tips, fields)}
259 {buttonPill(elements, look, editorScope(index), `setting-editor-${index}`, MARK.open, editor.label, () => run(editor))}
260 </Box>
261 ) : (
262 control(elements, look, row, index, still, tips, fields)
263 )
264 const hint = row.kind !== 'text' ? '' : editor ? (working ? style.text.idleOnly : '') : style.text.editInConfig
265 const note = [row.description, hint].filter(Boolean).join(` ${MARK.sep} `)
266 return (
267 <Box key={`setting-row-${index}`} flexDirection="column" borderStyle="round" borderColor={THEME.border} backgroundColor={cardFill()} paddingX={1} marginTop={index === 0 ? 0 : 1}>
268 {settingHead(elements, `setting-head-${index}`, splitTitle(row.label).name, head)}
269 {row.description || example ? hairline(elements, `setting-rule-${index}`, cardFill()) : null}
270 {row.description ? (
271 sepText(elements, note, THEME.muted, `setting-text-${index}`)
272 ) : null}
273 {example}
274 </Box>
275 )
276 }),
277 ]
278 const buttons = actions.map((action, index) =>
279 working && !action.isLocal ? (
280 sepText(elements, `${look.glyph.go} ${action.label} ${MARK.sep} ${style.text.idleOnly}`, THEME.muted, `action-${index}`)
281 ) : (
282 buttonPill(elements, look, actionScope(index), `action-${index}`, bare(look.glyph.go), action.label, () => run(action))
283 ),
284 )
285 return (
286 <Box key="settings" flexDirection="column">
287 {/* The cards carry their own border, and the panel's open Settings tab already names them. */}
288 <Box key="settings-cards" flexDirection="column">
289 {cardGap(elements, 'settings')}
290 {settings}
291 </Box>
292 {actions.length > 0 ? section(elements, look, 'actions', 'actions', style.text.actions, buttons) : null}
293 </Box>
294 )
295}
296
297hooks/toasts.tsx 87 lines1// hooks/toasts.tsx, by v-amorim
2// Shared toast cards; every mod that toasts carries the same copy.
3import type { RenderElement } from 'claude-code'
4
5import type { Tip } from './entry'
6import style from './style'
7import { CAPS, MARK, mix, THEME } from './theme'
8
9type Elements = { Box: (props: any) => RenderElement; Client: (props: any) => RenderElement; Text: (props: any) => RenderElement }
10
11// The slice of a mod's Look the cards read, typed here because mod-menu has no look.tsx.
12type ToastLook = { nerd: boolean; glyph: { close: string } }
13
14export const TOAST_MS = 4000
15
16const DISMISS_TIP = { label: 'Dismiss', text: 'hides this message' }
17const MAX_TOASTS = 3
18
19// A mod's own toasts, newest last. A toast reaches only the hooks above its caller, so each mod catches its own.
20export class ToastList {
21 items: { id: number; text: string }[] = []
22 // The owning mod's animations setting is off, so the dismiss × does not fade.
23 isStill = false
24 private lastId = 0
25
26 // `prefix` keeps the dismiss Client keys apart from other mods' cards in the band.
27 constructor(readonly prefix: string) {}
28
29 add(text: string) {
30 const id = (this.lastId += 1)
31 this.items = [...this.items, { id, text }].slice(-MAX_TOASTS)
32 return id
33 }
34
35 drop(id: number) {
36 this.items = this.items.filter(toast => toast.id !== id)
37 }
38
39 // The toast a dismiss click names, or undefined when the click was not on one of these cards.
40 idOf(entryId: string) {
41 const head = `toast-${this.prefix}-`
42 return entryId.startsWith(head) ? Number(entryId.slice(head.length)) : undefined
43 }
44}
45
46// A pill already frames the glyph, so drop the brackets and padding of the standalone button (safe `[×]`).
47const bare = (glyph: string) => glyph.replace(/[[\]\s]/g, '')
48
49// Callers write `✓ Saved` or `! Failed`; the card draws the mark.
50function toneOf(text: string) {
51 for (const [mark, color, bold] of [[MARK.ok, THEME.good, false], [MARK.warn, THEME.alert, true]] as const) {
52 if (text.startsWith(mark)) return { mark, color, bold, text: text.slice(mark.length).trimStart() }
53 }
54 return { mark: MARK.bullet, color: THEME.accent, bold: false, text }
55}
56
57// The web toast as a band card: tinted icon square, "Title: detail" split bold and muted, an error edged in alert; closeTip is the × tip.
58export const toastCards = ({ Box, Client, Text }: Elements, list: ToastList, look?: ToastLook, closeTip: Pick<Tip, 'label' | 'text'> = DISMISS_TIP) =>
59 list.items.map(toast => {
60 const id = `toast-${list.prefix}-${toast.id}`
61 const tone = toneOf(toast.text)
62 const nerd = look?.nerd === true
63 const close = bare(look?.glyph.close ?? style.glyphs.safe.close)
64 const fill = mix(THEME.surface1, tone.color, 0.06)
65 const split = tone.text.indexOf(': ')
66 const [title, detail] = split > 0 ? [tone.text.slice(0, split), tone.text.slice(split + 2)] : [tone.text, '']
67 const edge = tone.color === THEME.alert ? mix(fill, THEME.alert, 0.33) : THEME.border
68 return (
69 <Box key={id} flexDirection="row" columnGap={1} paddingX={1} borderStyle="round" borderColor={edge} backgroundColor={fill}>
70 <Box key={`${id}-mark`} flexShrink={0}>
71 <Text color={tone.color} backgroundColor={mix(fill, tone.color, 0.15)} bold={tone.bold}>{` ${tone.mark} `}</Text>
72 </Box>
73 <Box key={`${id}-text`} flexGrow={1} flexShrink={1}>
74 <Text wrap="wrap">
75 <Text color={THEME.onSurface} bold>{title}</Text>
76 {detail ? <Text color={THEME.muted}>{` ${detail}`}</Text> : null}
77 </Text>
78 </Box>
79 <Box key={`${id}-close`} flexDirection="row" flexShrink={0}>
80 {nerd ? <Text key={`${id}-close-left`} color={THEME.surface3} backgroundColor={fill}>{CAPS.left}</Text> : null}
81 <Client key={id} module="./entry.tsx" height={1} props={{ id, label: close, isOpen: false, bg: THEME.surface3, still: list.isStill, tip: { glyph: close, ...closeTip } }} />
82 {nerd ? <Text key={`${id}-close-right`} color={THEME.surface3} backgroundColor={fill}>{CAPS.right}</Text> : null}
83 </Box>
84 </Box>
85 )
86 })
87hooks/prompts.ts 171 lines1// mod-prompts / hooks/prompts.ts, by v-amorim
2// What the sidebar keeps of a transcript row; tool results and replies are most of the bytes and are dropped here.
3export type Row = {
4 uuid: string
5 parent?: string
6 isSidechain: boolean
7 // The prompt's text when the row is one, else undefined.
8 prompt?: string
9 // A prompt typed while a turn ran, which the file keeps as a queued command.
10 isQueued?: boolean
11 // A subagent's report: a row that opens a turn of its own, which is no prompt.
12 isReport?: boolean
13 at?: string
14 // What the row adds to its turn: tool calls a reply makes, lines a tool result changed, the turn's length.
15 tools?: number
16 added?: number
17 removed?: number
18 durationMs?: number
19}
20
21export type Prompt = { id: string; text: string; at?: string; tools: number; added: number; removed: number; durationMs?: number; isQueued?: boolean }
22
23// Lines a successful edit or new file changed; a failed tool result changed nothing, so it counts nothing.
24function changedLines(row: any) {
25 const content = row.message?.content
26 if (Array.isArray(content) && content.some((block: any) => block?.type === 'tool_result' && block.is_error === true)) return {}
27 const result = row.toolUseResult
28 let added = 0
29 let removed = 0
30 for (const hunk of Array.isArray(result?.structuredPatch) ? result.structuredPatch : []) {
31 for (const line of Array.isArray(hunk?.lines) ? hunk.lines : []) {
32 if (typeof line !== 'string') continue
33 if (line.startsWith('+')) added++
34 else if (line.startsWith('-')) removed++
35 }
36 }
37 // A new file has no patch, only its content.
38 if (result?.type === 'create' && added === 0 && typeof result.content === 'string') added = result.content.split('\n').length
39 return added || removed ? { added, removed } : {}
40}
41
42// Rows Claude Code wraps around commands, hooks and notices; none is a prompt.
43const WRAPPER = /^<(command-|local-command-|bash-|system-reminder|task-notification|user-prompt-submit-hook)/
44const INTERRUPTED = /^\[Request interrupted by user/
45const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
46
47export const promptText = (text: string) => {
48 const trimmed = text.trim()
49 return trimmed && !WRAPPER.test(trimmed) && !INTERRUPTED.test(trimmed) && !trimmed.startsWith('/') ? trimmed : undefined
50}
51
52// A skill command the user typed, shown as typed; a local command (/clear, /config) carries no promptId, so it stays out.
53const COMMAND = /^<command-message>[\s\S]*?<\/command-message>\s*<command-name>\s*(\/[^<\s]+)\s*<\/command-name>\s*(?:<command-args>([\s\S]*?)<\/command-args>)?/
54export const commandPrompt = (row: any, text: string) => {
55 if (typeof row?.promptId !== 'string' || !row.promptId) return undefined
56 const match = COMMAND.exec(text.trim())
57 return match ? `${match[1]} ${(match[2] ?? '').trim()}`.trim() : undefined
58}
59
60// A message's text: the string itself, or its text blocks joined.
61const textOf = (content: unknown) =>
62 typeof content === 'string'
63 ? content
64 : Array.isArray(content)
65 ? content.filter((block: any) => block?.type === 'text' && typeof block.text === 'string').map((block: any) => block.text).join('\n')
66 : ''
67
68export function parseRow(line: string): Row | undefined {
69 let row: any
70 try {
71 row = JSON.parse(line)
72 } catch {
73 return undefined
74 }
75 if (typeof row?.uuid !== 'string') return undefined
76 const base = {
77 uuid: row.uuid,
78 parent: row.parentUuid ?? row.logicalParentUuid ?? undefined,
79 isSidechain: row.isSidechain === true,
80 at: typeof row.timestamp === 'string' ? row.timestamp : undefined,
81 }
82 // A prompt typed while a turn ran is a queued_command attachment (content blocks when it has an image); a meta one is a note the harness queued.
83 if (row.type === 'attachment' && row.attachment?.type === 'queued_command') {
84 if (row.attachment.isMeta === true) return base
85 return { ...base, prompt: promptText(textOf(row.attachment.prompt)), isQueued: true }
86 }
87 if (row.type === 'system' && row.subtype === 'turn_duration' && typeof row.durationMs === 'number') return { ...base, durationMs: row.durationMs }
88 if (row.type === 'user' && row.isMeta === true && row.origin?.kind === 'peer') return { ...base, isReport: true }
89 if (row.type === 'assistant' && Array.isArray(row.message?.content)) {
90 const tools = row.message.content.filter((block: any) => block?.type === 'tool_use').length
91 return tools ? { ...base, tools } : base
92 }
93 if (row.type !== 'user' || row.isMeta || row.isCompactSummary || row.message?.role !== 'user') return base
94 const { content } = row.message
95 if (Array.isArray(content) && content.some((block: any) => block?.type === 'tool_result')) return { ...base, ...changedLines(row) }
96 const typed = textOf(content)
97 return { ...base, prompt: commandPrompt(row, typed) ?? promptText(typed) }
98}
99
100// The prompts on the live branch, oldest first; a report or an abandoned prompt opens a turn that lists nothing, as turns.ts does.
101export function promptsOf(rows: Row[]): Prompt[] {
102 const main = rows.filter(row => !row.isSidechain)
103 const byId = new Map(main.map(row => [row.uuid, row]))
104 // Each row's previous row in file order, the way past a parent Claude Code never wrote.
105 const before = new Map(main.map((row, n) => [row.uuid, main[n - 1]]))
106 const live = new Set<string>()
107 let row = main[main.length - 1]
108 while (row && !live.has(row.uuid)) {
109 live.add(row.uuid)
110 row = row.parent ? (byId.get(row.parent) ?? before.get(row.uuid)) : undefined
111 }
112 const prompts: Prompt[] = []
113 // The prompt the rows after it belong to, in file order; undefined after a report or an abandoned prompt.
114 let turn: Prompt | undefined
115 for (const candidate of rows) {
116 if (candidate.isSidechain) continue
117 if (candidate.isReport) {
118 turn = undefined
119 continue
120 }
121 if (candidate.prompt) {
122 turn = live.has(candidate.uuid) ? { id: candidate.uuid, text: candidate.prompt, at: candidate.at, tools: 0, added: 0, removed: 0, isQueued: candidate.isQueued === true } : undefined
123 if (turn) prompts.push(turn)
124 continue
125 }
126 if (!turn) continue
127 turn.tools += candidate.tools ?? 0
128 turn.added += candidate.added ?? 0
129 turn.removed += candidate.removed ?? 0
130 if (candidate.durationMs !== undefined) turn.durationMs = (turn.durationMs ?? 0) + candidate.durationMs
131 }
132 return prompts
133}
134
135// Parses a growing transcript a feed at a time; a torn last line waits for the next feed.
136export class TranscriptRows {
137 rows: Row[] = []
138 private parsed = 0
139
140 feed(text: string) {
141 const end = text.lastIndexOf('\n') + 1
142 if (end < this.parsed) {
143 this.rows = []
144 this.parsed = 0
145 }
146 const lines = text.slice(this.parsed, end).split('\n').filter(line => line.trim())
147 this.parsed = end
148 for (const line of lines) {
149 const row = parseRow(line)
150 if (row) this.rows.push(row)
151 }
152 return lines.length
153 }
154
155 // Feeds text that follows what was fed, for a read that took only the new bytes; returns the unfinished last line.
156 append(text: string) {
157 const end = text.lastIndexOf('\n') + 1
158 for (const line of text.slice(0, end).split('\n')) {
159 const row = line.trim() ? parseRow(line) : undefined
160 if (row) this.rows.push(row)
161 }
162 return text.slice(end)
163 }
164}
165
166// A message drawn as several rows gets ids that keep its uuid's first four groups, so rows match on those.
167export const rowKey = (id: string) => (UUID.test(id) ? id.slice(0, 24) : id)
168
169// The id a jump scrolls to: the row as Claude Code last drew it, else the message's own uuid.
170export const targetOf = (drawn: Map<string, string>, id: string) => drawn.get(rowKey(id)) ?? id
171hooks/tail.ts 50 lines1// mod-prompts / hooks/tail.ts, by v-amorim
2// Reads a byte range of a file through a process, for files past what one fs.read takes. Copied from mod-transcript.
3
4// Bytes per process read: base64 grows a third, so the output stays under the 4 MiB the process result keeps.
5export const CHUNK_BYTES = 2 * 1024 * 1024
6
7const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
8
9// The command that prints `length` bytes of `path` from byte `from`, as base64 so no byte is lost in decoding. Windows reads through PowerShell, elsewhere through tail and head.
10export function chunkArgv(path: string, from: number, length: number): string[] {
11 if (/^[A-Za-z]:|\\/.test(path)) {
12 const quoted = path.replace(/'/g, "''")
13 return ['powershell', '-NoProfile', '-NonInteractive', '-Command', `$s = [IO.File]::OpenRead('${quoted}'); try { [void]$s.Seek(${from}, 'Begin'); $b = [byte[]]::new(${length}); $n = $s.Read($b, 0, $b.Length); [Convert]::ToBase64String($b, 0, $n) } finally { $s.Dispose() }`]
14 }
15 return ['sh', '-c', 'tail -c +"$2" "$1" | head -c "$3" | base64 | tr -d "\\n"', 'sh', path, String(from + 1), String(length)]
16}
17
18// Decodes base64 by hand: the mod runtime does not promise atob.
19export function bytesOf(base64: string): Uint8Array {
20 const clean = base64.replace(/[^A-Za-z0-9+/]/g, '')
21 const out = new Uint8Array(Math.floor((clean.length * 3) / 4))
22 let bits = 0
23 let value = 0
24 let at = 0
25 for (const char of clean) {
26 value = (value << 6) | BASE64.indexOf(char)
27 bits += 6
28 if (bits >= 8) {
29 bits -= 8
30 out[at++] = (value >> bits) & 0xff
31 }
32 }
33 return out
34}
35
36// How many bytes the text takes in UTF-8, without building them.
37export function utf8Length(text: string) {
38 let bytes = 0
39 for (let at = 0; at < text.length; at++) {
40 const code = text.charCodeAt(at)
41 if (code < 0x80) bytes += 1
42 else if (code < 0x800) bytes += 2
43 else if (code >= 0xd800 && code <= 0xdbff) {
44 bytes += 4
45 at++
46 } else bytes += 3
47 }
48 return bytes
49}
50hooks/timeline.tsx 85 lines1// mod-prompts / hooks/timeline.tsx, by v-amorim
2// Draws the timeline rail of prompt stops with dots, times and click handling.
3import type { ClientModule, ClientSurface } from 'claude-code'
4
5import { HOVER_STEPS, HOVER_TICK_MS, postTip, stepToward } from './entry'
6import { cardFill } from './look'
7import { mix, THEME } from './theme'
8
9// time is the clock time the prompt was sent, empty when the transcript has none; index is its place in the whole list; label and tip fill the pane footer.
10export type Stop = { index: number; time: string; text: string; label: string; tip: string }
11
12export type TimelineProps = { stops: Stop[]; selected: number; glyph: string; dot: string; openDot: string; still: boolean }
13
14export type TimelineMessage = { type: 'select'; index: number }
15
16// Cells before the dot: the clock time and a space, or the same width blank.
17export const GUTTER = 6
18
19// The rows a run of stops takes: one each and no rail line between, so only the open prompt spreads over two rows.
20export const timelineRows = (count: number) => count
21
22// at: the stop under the pointer; fading: the stop it just left, and level: how much of its light is left.
23type State = { at: number; fading: number; level: number }
24
25const fades = new WeakMap<object, () => void>()
26
27// A stop the pointer left dims over HOVER_STEPS ticks instead of switching off.
28const startFade = (surface: ClientSurface<State>) => {
29 if (fades.has(surface)) return
30 const stop = surface.every(HOVER_TICK_MS, () => {
31 const { state } = surface
32 if (!state || state.fading < 0) return
33 const level = stepToward(state.level, false)
34 surface.setState(level === 0 ? { ...state, fading: -1, level: 0 } : { ...state, level })
35 if (level === 0) {
36 stop()
37 fades.delete(surface)
38 }
39 })
40 fades.set(surface, stop)
41}
42
43// Prompts as stops: time, a dot, the text. A click opens one; the stop under the pointer lifts and turns accent.
44const Timeline: ClientModule<TimelineProps, State> = (props, surface) => {
45 const { Box, Text } = surface.elements
46 const { stops, selected, glyph, dot, openDot, still } = props
47 const state = surface.state ?? { at: -1, fading: -1, level: 0 }
48 const hovered = state.at
49 const at = (y: number) => stops[y]
50
51 surface.onPointer(event => {
52 const stop = event.type === 'leave' ? undefined : at(event.y)
53 const index = stop?.index ?? -1
54 const isClick = event.type === 'down' && event.button === 'left' && index >= 0
55 if (index !== hovered) {
56 const isDimming = hovered >= 0 && !still
57 surface.setState({ at: index, fading: isDimming ? hovered : -1, level: isDimming ? HOVER_STEPS : 0 })
58 if (isDimming) startFade(surface)
59 if (!isClick) postTip(surface, 'stop', stop ? { glyph, label: stop.label, text: stop.tip } : null)
60 }
61 if (isClick) surface.post({ type: 'select', index } satisfies TimelineMessage)
62 })
63
64 return (
65 <Box flexDirection="column">
66 {stops.map(stop => {
67 const isOpen = stop.index === selected
68 const lit = stop.index === hovered ? HOVER_STEPS : stop.index === state.fading ? state.level : 0
69 const on = mix(isOpen ? THEME.surface3 : cardFill(), THEME.lifted, lit / HOVER_STEPS)
70 const ink = isOpen ? mix(THEME.ok, THEME.accent, lit / HOVER_STEPS) : mix(THEME.muted, THEME.accent, lit / HOVER_STEPS)
71 return (
72 <Box key={`stop-${stop.index}`} flexDirection="row" backgroundColor={on}>
73 <Text color={THEME.muted}>{(stop.time || '').padEnd(GUTTER)}</Text>
74 {/* The open prompt sits on surface-3 in ok and bold, the rest muted, the dot too; the pointer lifts any of them. */}
75 <Text color={ink}>{isOpen ? openDot : dot}</Text>
76 <Text color={ink} bold={isOpen}>{` ${stop.text}`}</Text>
77 </Box>
78 )
79 })}
80 </Box>
81 )
82}
83
84export default Timeline
85hooks/theme.ts 46 lines1// hooks/theme.ts, by v-amorim
2// Shared theme; every mod carries the same copy. Colors, marks and glyphs live in style.ts.
3import style from './style'
4
5export const THEME = style.colors.theme
6
7// Shell command roles: what runs, its options, quoted text, variables, numbers and the glue between steps.
8export const SYNTAX = style.colors.syntax
9
10// One cell each and never an emoji: ⚠ switches to its emoji form in some fonts and draws two cells wide.
11export const MARK = style.marks
12
13// style.ts writes Nerd glyphs as {U+XXXX}, since private-use characters do not survive many editors and tools.
14const decode = (text: string) => text.replace(/\{U\+([0-9A-F]+)\}/gi, (_, hex: string) => String.fromCodePoint(parseInt(hex, 16)))
15
16export function decodeAll<T>(value: T): T {
17 if (typeof value === 'string') return decode(value) as T
18 if (Array.isArray(value)) return value.map(decodeAll) as T
19 return Object.fromEntries(Object.entries(value as object).map(([key, inner]) => [key, decodeAll(inner)])) as T
20}
21
22// A pill's rounded ends, drawn only where the font has them.
23export const CAPS = decodeAll(style.caps)
24
25export const statusColor = (note: string) =>
26 note.startsWith(MARK.ok) ? THEME.good : note.startsWith(MARK.warn) ? THEME.alert : THEME.muted
27
28const channels = (hex: string) => [1, 3, 5].map(at => parseInt(hex.slice(at, at + 2), 16))
29const isHex = (color: string) => /^#[0-9a-f]{6}$/i.test(color)
30
31// The color `t` of the way from `from` to `to`; a color that is not #RRGGBB switches halfway.
32export function mix(from: string, to: string, t: number) {
33 if (t <= 0) return from
34 if (t >= 1) return to
35 if (!isHex(from) || !isHex(to)) return t < 0.5 ? from : to
36 const end = channels(to)
37 return `#${channels(from).map((value, at) => Math.round(value + (end[at]! - value) * t).toString(16).padStart(2, '0')).join('')}`
38}
39
40// The share of a hue left in a subpill or count badge, washed into the fill it sits on.
41export const WASH = 0.72
42
43// A text.ts string with its {name} slots filled; a slot with no value stays as written, so a missing one shows.
44export const fill = (template: string, values: Record<string, string | number> = {}) =>
45 template.replace(/\{(\w+)\}/g, (slot, name: string) => (name in values ? String(values[name]) : slot))
46