SLOPSHOPPER

mod-prompts

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…

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

Draws

Band
⟨Claude Code's own drawing⟩ ▣ client module ./entry.tsx
Pane · Prompts
▣ client module ./entry.tsx Click a prompt to open it · ↗ scrolls to it No prompts yet. ╭╮ ╰╯ ─ This session ────────────────────────────────────────────────────────
README

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

mod-prompts

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

Every prompt of the session; jump back to any of them.

Install

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

How to open

Prompts pill.

See it

Pick a prompt to read it
https://github.com/user-attachments/assets/0cf0064f-0498-4f0a-85cc-18e5256cfcd3

Features

  • Every prompt of the session: A sidebar lists each prompt you sent, with the full text in a card. A button jumps the transcript back to it. A long prompt is cut short, and Show less cuts it back after Show all.
  • Command hooks: On SessionStart, a small shell hook appends the event's JSON as one line to ~/.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.

Settings

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

SettingDefaultWhat it does
Iconsautoauto picks Nerd Font or safe glyphs. It uses Nerd Font icons when the terminal's font is a Nerd Font (read from Windows Terminal, iTerm2, VS Code, Ghostty, kitty, WezTerm, Alacritty, GNOME Terminal or Konsole) and safe Unicode glyphs elsewhere. nerd and safe force one
AnimationsonEntries shimmer while they wait. Hover colors also fade. Off, everything draws still

License

Made by [Vinicius Amorim][doc-author], [MIT license][doc-license].

[doc-author]: https://github.com/v-amorim [doc-icons]: https://github.com/v-amorim/moonlight-claude-mods/blob/main/README.md#icons-and-nerd-fonts [doc-install]: https://github.com/v-amorim/moonlight-claude-mods/blob/main/README.md#install [doc-license]: https://github.com/v-amorim/moonlight-claude-mods/blob/main/LICENSE [doc-root]: https://github.com/v-amorim/moonlight-claude-mods/blob/main/README.md

Source 15 files
hooks/register.tsx 764 lines
1// 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}
764
hooks/bridge.ts 107 lines
1// hooks/bridge.ts, by v-amorim: a poll hands the lines a command hook appended to <mods-data>/<mod>/bridge/<session>.jsonl to this mod.
2import type { EngineInterface } from 'claude-code'
3
4export type BridgeRecord = Record<string, unknown>
5export type BridgeHandler = (record: BridgeRecord) => void | Promise<void>
6
7const POLL_MS = 250
8
9// One handler per event; registering again replaces it, so a mod that registers on every session.start holds one.
10const handlers = new Map<string, BridgeHandler>()
11// Characters of each session's file already delivered.
12const cursors = new Map<string, number>()
13let isStarted = false
14let isTicking = false
15let folder = ''
16
17export function bridgeOn(event: string, handler: BridgeHandler) {
18  handlers.set(event, handler)
19}
20
21async function folderOf($: EngineInterface, mod: string) {
22  const set = ((await $.env.get('CLAUDE_CONFIG_DIR')) ?? '').replace(/\\/g, '/').replace(/\/+$/, '')
23  if (set) return `${set}/mods-data/${mod}/bridge`
24  // The command hooks write under $HOME, so it comes before USERPROFILE; a Git Bash `/c/Users/x` becomes `C:/Users/x`, which $.fs can open.
25  const raw = ((await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || '').replace(/\\/g, '/').replace(/\/+$/, '')
26  const home = raw.replace(/^\/([A-Za-z])(?=\/[^/])/, (_, drive: string) => `${drive.toUpperCase()}:`)
27  return home ? `${home}/.claude/mods-data/${mod}/bridge` : ''
28}
29
30async function deliver(record: BridgeRecord) {
31  try {
32    await handlers.get(String(record.hook_event_name ?? ''))?.(record)
33  } catch {
34    // A handler's failure never stops the poll.
35  }
36}
37
38// The complete lines this session's file gained since the last read, and whether this is the first read since the module started.
39async function readNew($: EngineInterface, session: string) {
40  if (!folder || !session) return { lines: [] as string[], isFirst: false }
41  const text = String(await $.fs.read(`${folder}/${session}.jsonl`).catch(() => ''))
42  const isFirst = !cursors.has(session)
43  let cursor = cursors.get(session) ?? 0
44  // A file shorter than what was read was deleted or emptied, so it starts over.
45  if (text.length < cursor) cursor = 0
46  const end = text.lastIndexOf('\n') + 1
47  cursors.set(session, Math.max(end, cursor))
48  return { lines: end > cursor ? text.slice(cursor, end).split('\n').filter(Boolean) : [], isFirst }
49}
50
51async function poll($: EngineInterface, session: string) {
52  const { lines, isFirst } = await readNew($, session)
53  const records: BridgeRecord[] = []
54  for (const line of lines) {
55    try {
56      const parsed: unknown = JSON.parse(line)
57      if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) records.push(parsed as BridgeRecord)
58    } catch {
59      // A corrupt line is skipped.
60    }
61  }
62  for (const record of isFirst ? lastOfEach(records) : records) await deliver(record)
63}
64
65// After a reload the file holds rows delivered before it, so only the last of each event is state worth replaying.
66export function lastOfEach(records: BridgeRecord[]) {
67  const last = new Map(records.map(record => [String(record.hook_event_name ?? ''), record]))
68  return records.filter(record => last.get(String(record.hook_event_name ?? '')) === record)
69}
70
71async function tick($: EngineInterface) {
72  if (isTicking) return
73  isTicking = true
74  try {
75    // Asked every tick, so a /clear or a resume reads the new session's file.
76    await poll($, String(await $.session.id()))
77  } catch {
78    // The next tick tries again.
79  } finally {
80    isTicking = false
81  }
82}
83
84// Starts the poll once; `mod` names the mod's folder under mods-data.
85export async function startBridge($: EngineInterface, mod: string) {
86  if (isStarted) return
87  isStarted = true
88  try {
89    folder = await folderOf($, mod)
90    if (!folder) return
91    $.clock.every(POLL_MS, () => void tick($))
92    await tick($)
93  } catch {
94    // Without the bridge the mod keeps to what it finds on its own.
95  }
96}
97
98// A session ended: what its file still holds is delivered; the SessionEnd command hook deletes the file.
99export async function endBridge($: EngineInterface, session: string) {
100  if (!folder || !session) return
101  try {
102    await poll($, session)
103  } catch {
104    // The next read of the file delivers it.
105  }
106}
107
hooks/card.tsx 37 lines
1// 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}
37
hooks/entry.tsx 241 lines
1// hooks/entry.tsx, by v-amorim
2// Shared pill entry; every mod that draws a menu entry carries the same copy.
3import type { ClientModule, ClientSurface } from 'claude-code'
4
5import style from './style'
6import { CAPS, mix, THEME, WASH } from './theme'
7
8// color: open text (a red Run); idle: closed text, muted unless set; isBusy shimmers the label until the click's result lands.
9// still: the mod's animations setting is off, so nothing shimmers and the hover color switches at once.
10// lead: a bare glyph before the label in leadColor (after a space, none when tight: the cap is the gap); subLead: an icon before it as a rounded subpill in subLeadColor;
11// badge: a count after it in its own pill. Subpills are capped where `nerd` has the caps.
12// tight: no label padding; tip: posts `tip` instead of `menu-hover`; rail: a divider (a Text between Clients squeezes them); rule: a full-width hairline.
13// spinner: the braille dots lead the label in accent while work runs (write the label without its …); still holds the first frame.
14export type EntryProps = {
15  id: string
16  label: string
17  isOpen: boolean
18  bg: string
19  color?: string
20  idle?: string
21  isBusy?: boolean
22  still?: boolean
23  lead?: string
24  leadColor?: string
25  subLead?: string
26  subLeadColor?: string
27  badge?: { text: string; color: string; bg: string }
28  nerd?: boolean
29  tight?: boolean
30  tip?: Tip
31  rail?: boolean
32  rule?: boolean
33  spinner?: boolean
34  compact?: boolean // false: the pill row may cut the entry to its lead when the row is wider than the band
35}
36
37const entryText = (props: EntryProps) => {
38  const pad = props.tight ? (props.isOpen && !props.nerd ? ' ' : '') : ' '
39  return `${pad}${props.compact && props.lead ? '' : props.label}${props.tight && props.badge ? ' ' : pad}`
40}
41
42// The cells an entry takes; subpills add their two caps, or their two padding spaces in safe mode.
43export const entryCells = (props: EntryProps) => {
44  if (props.rail) return 1
45  const cells = (text: string) => [...text].length
46  const leadText = props.lead ?? (props.spinner ? SPIN_FRAMES[0]! : '')
47  const lead = leadText ? cells(leadText) + (props.tight ? 0 : 1) : 0
48  return lead + (props.subLead ? cells(props.subLead) + 2 : 0) + cells(entryText(props)) + (props.badge ? cells(props.badge.text) + 2 : 0)
49}
50
51// A hovered control's glyph, label and what it does; the pane footer and the band's tip row both show it.
52export type Tip = { glyph: string; label: string; text: string }
53
54// Posted to the owning mod's ui.message when the pointer enters a control (id set) or leaves it (id null).
55export type TipMessage = { type: 'tip'; id: string | null; glyph?: string; label?: string; text?: string }
56
57export const isTipMessage = (data: unknown): data is TipMessage => (data as TipMessage | undefined)?.type === 'tip'
58
59// The band's tip row text for a tip message: "Label: what it does", or '' once the pointer left.
60export const tipText = (message: TipMessage | null | undefined) => (message?.id && message.label ? `${message.label}: ${message.text ?? ''}` : '')
61
62// A control calls this from onPointer, with its tip on enter and null on leave; never also `menu-hover`, one post per frame lands.
63export const postTip = (surface: { post: (data: any) => void }, id: string, tip: Tip | null) => {
64  surface.post((tip ? { type: 'tip', id, glyph: tip.glyph, label: tip.label, text: tip.text } : { type: 'tip', id: null }) satisfies TipMessage)
65}
66
67// menu-hover tells the owning mod the pointer arrived or left, so it can show its tip under the row.
68export type EntryMessage = { type: 'menu-entry'; id: string } | { type: 'menu-hover'; id: string; isOver: boolean }
69
70// A dialog opening under the pointer can swallow the leave event, so a hover with no pointer event for this long ends on its own.
71const HOVER_QUIET_MS = 5000
72
73export const TICK_MS = 140
74
75// The loading shimmer: a soft primary-70 band sweeps the dim text, then a pause.
76const BAND = style.colors.shimmer
77const SWEEP_PAUSE = 8
78
79export const shimmerColor = (at: number, phase: number, length: number) => {
80  const period = length + BAND.length * 2 + SWEEP_PAUSE
81  const head = (phase % period) - BAND.length
82  return BAND[Math.abs(at - head)] ?? style.colors.pills.readout
83}
84
85// The braille dots spinner, one frame every SPIN_MS, in accent.
86export const SPIN_FRAMES = [...style.marks.spinner]
87export const SPIN_MS = 80
88
89// The pointer lights a control at once and its color fades out over this many steps, one every HOVER_TICK_MS.
90export const HOVER_STEPS = 2
91
92export const HOVER_TICK_MS = 60
93
94export { mix }
95
96// One step of a hover fade: full at once while the pointer is on, one step toward 0 once it left.
97export const stepToward = (level: number, isOver: boolean) => (isOver ? HOVER_STEPS : Math.max(0, level - 1))
98
99// Even steps: an eased last step barely moves the color, so the fade seemed to hang at its end.
100export const hoverMix = (from: string, to: string, level: number) => mix(from, to, level / HOVER_STEPS)
101
102// A click shimmers the entry until the mod answers with new props, or this long at most.
103const PRESS_MS = 1200
104
105// pressed: the props a click saw and when it came, cleared once the mod answers.
106type State = { isOver: boolean; at: number; phase: number; level?: number; spin?: number; pressed?: { props: string; at: number } }
107
108const isPressed = (state: State | undefined, props: EntryProps | undefined) =>
109  Boolean(state?.pressed && Date.now() - state.pressed.at < PRESS_MS && state.pressed.props === JSON.stringify(props))
110
111// Each instance's running fade-out timer, started by the leave event and stopped at 0 or when the pointer returns.
112const fades = new WeakMap<object, () => void>()
113
114const startFade = (surface: ClientSurface<State>) => {
115  if (fades.has(surface)) return
116  const stop = surface.every(HOVER_TICK_MS, () => {
117    const { state } = surface
118    if (!state) return
119    const level = stepToward(state.level ?? 0, state.isOver)
120    if (level !== (state.level ?? 0)) surface.setState({ ...state, level })
121    if (state.isOver || level === 0) {
122      stop()
123      fades.delete(surface)
124    }
125  })
126  fades.set(surface, stop)
127}
128
129// The ticker reads the latest props from here, since its closure keeps the first ones.
130const latest = new WeakMap<object, EntryProps>()
131
132// Each instance's running tick: it runs only while a shimmer moves or a hover is watched, so idle pills cost no wakeups.
133const ticks = new WeakMap<object, () => void>()
134
135const startTick = (surface: ClientSurface<State>) => {
136  if (ticks.has(surface)) return
137  const stop = surface.every(TICK_MS, () => {
138    const { state } = surface
139    if (!state) return
140    const now = latest.get(surface)
141    if (!now?.still && (now?.isBusy || isPressed(state, now))) surface.setState({ ...state, phase: state.phase + 1 })
142    else if (state.pressed) surface.setState({ ...state, pressed: undefined })
143    else if (state.isOver && Date.now() - state.at >= HOVER_QUIET_MS) {
144      surface.setState({ ...state, isOver: false, at: Date.now() })
145      if (!now?.still) startFade(surface)
146      const id = now?.id ?? ''
147      if (now?.tip) postTip(surface, id, null)
148      else surface.post({ type: 'menu-hover', id, isOver: false } satisfies EntryMessage)
149    } else if (!state.isOver) {
150      stop()
151      ticks.delete(surface)
152    }
153  })
154  ticks.set(surface, stop)
155}
156
157// Each spinning instance's frame clock, stopped once the entry stops spinning.
158const spins = new WeakMap<object, () => void>()
159
160const startSpin = (surface: ClientSurface<State>) => {
161  if (spins.has(surface)) return
162  const stop = surface.every(SPIN_MS, () => {
163    const { state } = surface
164    const now = latest.get(surface)
165    if (!state || !now?.spinner || now.still) {
166      stop()
167      spins.delete(surface)
168      return
169    }
170    surface.setState({ ...state, spin: (state.spin ?? 0) + 1 })
171  })
172  spins.set(surface, stop)
173}
174
175// A Button inverts under the pointer, so the entry is plain text that lights up like a sidebar row.
176const Entry: ClientModule<EntryProps, State> = (props, surface) => {
177  const { Box, Text } = surface.elements
178  if (props.rail) return <Text color={style.colors.pills.separator} backgroundColor={props.bg}>{style.marks.rail}</Text>
179  if (props.rule) return <Text color={THEME.border} backgroundColor={props.bg}>{style.marks.rule.repeat(Math.max(1, surface.columns))}</Text>
180  const hovered = surface.state?.isOver === true
181  latest.set(surface, props)
182
183  if (!surface.state) surface.setState({ isOver: false, at: 0, phase: 0 })
184  if (props.isBusy && !props.still) startTick(surface)
185  if (props.spinner && !props.still) startSpin(surface)
186
187  surface.onPointer(event => {
188    startTick(surface)
189    const over = event.type !== 'leave'
190    if (over !== hovered) {
191      if (props.tip) postTip(surface, props.id, over ? props.tip : null)
192      else surface.post({ type: 'menu-hover', id: props.id, isOver: over } satisfies EntryMessage)
193    }
194    const isClick = event.type === 'down' && event.button === 'left'
195    const base = surface.state ?? { phase: 0, isOver: false, at: 0 }
196    const level = over === base.isOver ? base.level : over ? HOVER_STEPS : props.still ? 0 : base.level
197    if (!over && !props.still) startFade(surface)
198    surface.setState({ ...base, isOver: over, at: Date.now(), level, pressed: isClick ? { props: JSON.stringify(props), at: Date.now() } : base.pressed })
199    if (isClick) surface.post({ type: 'menu-entry', id: props.id } satisfies EntryMessage)
200  })
201
202  const text = entryText(props)
203  const isShimmering = !props.still && (props.isBusy || isPressed(surface.state, props))
204  const phase = surface.state?.phase ?? 0
205  const label = isShimmering ? (
206    <Box key="label" flexDirection="row">
207      {[...text].map((char, at) => (
208        <Text key={`c${at}`} color={shimmerColor(at, phase, text.length)} backgroundColor={props.bg}>
209          {char}
210        </Text>
211      ))}
212    </Box>
213  ) : (
214    <Text key="label" color={hoverMix(props.isOpen ? (props.color ?? THEME.onSurface) : (props.idle ?? THEME.muted), THEME.accent, surface.state?.level ?? 0)} bold={props.isOpen && Boolean(props.idle)} backgroundColor={props.bg}>
215      {text.split(style.marks.sep).flatMap((part, at) => (at === 0 ? [part] : [<Text key={`sep-${at}`} color={style.colors.pills.separator}>{style.marks.sep}</Text>, part]))}
216    </Text>
217  )
218  const lead = props.spinner ? SPIN_FRAMES[props.still ? 0 : (surface.state?.spin ?? 0) % SPIN_FRAMES.length] : props.lead
219  if (!lead && !props.subLead && !props.badge) return isShimmering ? <Box flexDirection="row">{label}</Box> : label
220
221  const sub = (key: string, text: string, color: string, bg: string) => (
222    <Box key={key} flexDirection="row">
223      {props.nerd ? <Text key={`${key}-left`} color={bg} backgroundColor={props.bg}>{CAPS.left}</Text> : null}
224      <Text key={`${key}-text`} color={color} backgroundColor={bg} bold>{props.nerd ? text : ` ${text} `}</Text>
225      {props.nerd ? <Text key={`${key}-right`} color={bg} backgroundColor={props.bg}>{CAPS.right}</Text> : null}
226    </Box>
227  )
228  const subHue = props.subLeadColor ?? THEME.accent
229  const { badge } = props
230  return (
231    <Box flexDirection="row">
232      {lead ? <Text key="lead" color={props.spinner ? THEME.accent : (props.leadColor ?? THEME.accent)} backgroundColor={props.bg}>{props.tight ? lead : ` ${lead}`}</Text> : null}
233      {props.subLead ? sub('sub-lead', props.subLead, subHue, mix(subHue, props.bg, WASH)) : null}
234      {label}
235      {badge ? sub('badge', badge.text, badge.color, badge.bg) : null}
236    </Box>
237  )
238}
239
240export default Entry
241
hooks/look.tsx 279 lines
1// hooks/look.tsx, by v-amorim
2// Shared look; every mod carries the same copy.
3import type { RenderElement } from 'claude-code'
4
5import style from './style'
6import type { Tip } from './entry'
7import { CAPS, decodeAll, MARK, mix, THEME, WASH } from './theme'
8
9type Glyphs = Omit<typeof style.glyphs.nerd, 'icon' | 'mark'> & { icon: Record<string, string>; mark: Record<string, string> }
10
11// Safe mode: common Unicode that every modern terminal font draws. Headings and rows carry no icon.
12const SAFE: Glyphs = decodeAll(style.glyphs.safe)
13
14// Nerd mode: Font Awesome, Material and VS Code glyphs from any Nerd Font, controls padded to the same three cells.
15const NERD: Glyphs = decodeAll(style.glyphs.nerd)
16
17export type Look = { nerd: boolean; glyph: typeof SAFE }
18
19export const lookFor = (nerd: boolean): Look => ({ nerd, glyph: nerd ? NERD : SAFE })
20
21// The icons setting every drawing mod declares in its userConfig.
22export type IconMode = 'auto' | 'nerd' | 'safe'
23
24export const terminalSettings = (localAppData: string) => [
25  `${localAppData}/Packages/Microsoft.WindowsTerminal_8wekyb3d8bbwe/LocalState/settings.json`,
26  `${localAppData}/Microsoft/Windows Terminal/settings.json`,
27]
28
29// Matches "Fira Code Nerd Font Mono" and the patched suffixes NF, NFM and NFP, as in "FiraCodeNFM-Reg".
30export const isNerdName = (face: string) => /nerd|\bNFM?\b/i.test(face) || /NF[MP]?(?![A-Za-z])/.test(face)
31
32export const isNerdFace = (settingsText: string) => [...settingsText.matchAll(/"face"\s*:\s*"([^"]*)"/g)].some(match => isNerdName(match[1]!))
33
34// The variables that name the terminal: WT_SESSION from Windows Terminal, TERM_PROGRAM from programs such as mintty and iTerm2.
35export type TerminalEnv = Partial<
36  Record<'WT_SESSION' | 'TERM_PROGRAM' | 'ITERM_PROFILE' | 'KITTY_WINDOW_ID' | 'ALACRITTY_WINDOW_ID' | 'GNOME_TERMINAL_SCREEN' | 'KONSOLE_VERSION', string | undefined>
37>
38
39// What detection reads: files by path, and the stdout of a command that exits 0 (it throws otherwise).
40export type Host = { localAppData: string; home: string; read: (path: string) => Promise<string>; run: (argv: string[]) => Promise<string> }
41
42// mintty inherits WT_SESSION from a Windows Terminal tab, so only an unset or Windows Terminal TERM_PROGRAM counts.
43export const isWindowsTerminal = ({ WT_SESSION, TERM_PROGRAM }: TerminalEnv) =>
44  Boolean(WT_SESSION) && (!TERM_PROGRAM || /windows\s*terminal/i.test(TERM_PROGRAM))
45
46// Terminals whose font sits in a text config: the first file that names a font wins, and within a file the first pattern.
47const CONFIG_TERMINALS: { is: (env: TerminalEnv) => boolean; files: (host: Host) => string[]; faces: RegExp[] }[] = [
48  {
49    is: env => env.TERM_PROGRAM === 'vscode',
50    files: ({ home, localAppData }) => [
51      `${home}/Library/Application Support/Code/User/settings.json`,
52      `${home}/.config/Code/User/settings.json`,
53      `${localAppData.replace(/\/Local$/, '/Roaming')}/Code/User/settings.json`,
54    ],
55    faces: [/"terminal\.integrated\.fontFamily"\s*:\s*"([^"]*)"/, /"editor\.fontFamily"\s*:\s*"([^"]*)"/],
56  },
57  {
58    is: env => env.TERM_PROGRAM === 'ghostty',
59    files: ({ home }) => [`${home}/.config/ghostty/config`, `${home}/Library/Application Support/com.mitchellh.ghostty/config`],
60    faces: [/^\s*font-family\s*=\s*"?([^"\n#]+)/m],
61  },
62  {
63    is: env => Boolean(env.KITTY_WINDOW_ID),
64    files: ({ home }) => [`${home}/.config/kitty/kitty.conf`],
65    faces: [/^\s*font_family\s+(.+)$/m],
66  },
67  {
68    is: env => env.TERM_PROGRAM === 'WezTerm',
69    files: ({ home }) => [`${home}/.wezterm.lua`, `${home}/.config/wezterm/wezterm.lua`],
70    faces: [/wezterm\.font(?:_with_fallback)?\(\s*\{?\s*["']([^"']+)["']/],
71  },
72  {
73    is: env => Boolean(env.ALACRITTY_WINDOW_ID),
74    files: ({ home }) => [`${home}/.config/alacritty/alacritty.toml`],
75    faces: [/\[font\.normal\][^[]*?family\s*=\s*"([^"]+)"/],
76  },
77]
78
79// iTerm2 keeps its profiles in a plist; plutil prints the part asked for. The profile is the one this window runs, else the default.
80async function itermFace(env: TerminalEnv, host: Host) {
81  const plist = `${host.home}/Library/Preferences/com.googlecode.iterm2.plist`
82  const profiles: { Name?: string; Guid?: string; 'Normal Font'?: string }[] = JSON.parse(await host.run(['plutil', '-extract', 'New Bookmarks', 'json', '-o', '-', plist]))
83  const named = profiles.find(profile => profile.Name === env.ITERM_PROFILE)
84  if (named) return named['Normal Font']
85  const guid = (await host.run(['plutil', '-extract', 'Default Bookmark Guid', 'raw', '-o', '-', plist])).trim()
86  return profiles.find(profile => profile.Guid === guid)?.['Normal Font']
87}
88
89// GNOME Terminal keeps the default profile's font in dconf, and ignores it while the profile uses the system font.
90async function gnomeFace(host: Host) {
91  const unquote = (text: string) => text.trim().replace(/^'|'$/g, '')
92  const schema = `org.gnome.Terminal.Legacy.Profile:/org/gnome/terminal/legacy/profiles:/:${unquote(await host.run(['gsettings', 'get', 'org.gnome.Terminal.ProfilesList', 'default']))}/`
93  if (unquote(await host.run(['gsettings', 'get', schema, 'use-system-font'])) === 'true') return undefined
94  return unquote(await host.run(['gsettings', 'get', schema, 'font']))
95}
96
97// Konsole names its profile in konsolerc, and the profile file holds `Font=Name,size,...`.
98async function konsoleFace(host: Host) {
99  const profile = /^DefaultProfile=(.+)$/m.exec(await host.read(`${host.home}/.config/konsolerc`))?.[1]?.trim()
100  return profile ? /^Font=([^,\n]+)/m.exec(await host.read(`${host.home}/.local/share/konsole/${profile}`))?.[1] : undefined
101}
102
103async function configFace(terminal: (typeof CONFIG_TERMINALS)[number], host: Host) {
104  for (const path of terminal.files(host)) {
105    const text = await host.read(path).catch(() => '')
106    for (const pattern of terminal.faces) {
107      const face = pattern.exec(text)?.[1]
108      if (face) return face
109    }
110  }
111  return undefined
112}
113
114function terminalFace(env: TerminalEnv, host: Host) {
115  if (env.TERM_PROGRAM === 'iTerm.app') return itermFace(env, host)
116  if (env.GNOME_TERMINAL_SCREEN) return gnomeFace(host)
117  if (env.KONSOLE_VERSION) return konsoleFace(host)
118  const terminal = CONFIG_TERMINALS.find(candidate => candidate.is(env))
119  return terminal ? configFace(terminal, host) : Promise.resolve(undefined)
120}
121
122// Every mod looks up the font on start at once, so a lookup can fail by chance; a third failure throws and the caller keeps its saved answer.
123async function retried<T>(attempt: () => Promise<T>, tries = 3): Promise<T> {
124  for (let left = tries - 1; ; left--) {
125    try {
126      return await attempt()
127    } catch (error) {
128      if (left === 0) throw error
129    }
130  }
131}
132
133// Nerd glyphs only when the terminal this window runs in names a Nerd font; an unknown terminal stays safe, and a lookup that keeps failing throws.
134export async function detectNerd(env: TerminalEnv, host: Host) {
135  if (isWindowsTerminal(env)) {
136    for (const path of terminalSettings(host.localAppData)) {
137      if (isNerdFace(await host.read(path).catch(() => ''))) return true
138    }
139    return false
140  }
141  const face = await retried(() => terminalFace(env, host))
142  return face !== undefined && isNerdName(face)
143}
144
145// `text` led by its icon in Nerd mode; the text alone in safe mode or when the name has no icon.
146export const withIcon = (look: Look, name: string, text: string) => (look.glyph.icon[name] ? `${look.glyph.icon[name]} ${text}` : text)
147
148// A status note with its leading mark (MARK.warn or MARK.ok from theme.ts) drawn as this look's glyph.
149export function withMark(look: Look, note: string) {
150  if (note.startsWith('! ')) return `${look.glyph.warn} ${note.slice(2)}`
151  if (note.startsWith('✓ ')) return `${look.glyph.ok} ${note.slice(2)}`
152  return note
153}
154
155type Elements = { Box: (props: any) => RenderElement; Text: (props: any) => RenderElement }
156
157// A pane's title line: the name's icon in Nerd mode, the safe mark otherwise.
158export const paneTitle = ({ Text }: Elements, look: Look, icon: string, text: string) => (
159  <Text key="pane-title" color={THEME.accent} bold>{`${look.glyph.icon[icon] ?? look.glyph.title} ${text}`}</Text>
160)
161
162// One line of text in `color` with every separator dot in the separator colour, since a dot in the text's colour reads as part of the words.
163export function sepText({ Text }: Pick<Elements, 'Text'>, text: string, color: string, key: string, props: Record<string, unknown> = {}) {
164  return (
165    <Text key={key} {...props}>
166      {text.split(MARK.sep).flatMap((part, at) => [
167        ...(at > 0 ? [<Text key={`${key}-sep-${at}`} color={style.colors.pills.separator}>{MARK.sep}</Text>] : []),
168        <Text key={`${key}-part-${at}`} color={color}>{part}</Text>,
169      ])}
170    </Text>
171  )
172}
173
174// The footer rows every pane reserves at its bottom: a rule, then the hovered control's glyph, label and what it does.
175export const FOOTER_ROWS = 2
176
177// A Button cannot post a tip, so its tip is drawn hidden and shown by the hover group the Button's pill shares.
178export type Reveal = { scope: string; tip: Tip }
179
180const tipRow = ({ Box, Text }: Elements, key: string, tip: Tip | null, columns: number, extra: Record<string, unknown> = {}) => (
181  <Box key={key} flexDirection="row" paddingX={1} width={columns} height={1} backgroundColor={THEME.bg} {...extra}>
182    {tip ? <Text color={THEME.accent} backgroundColor={THEME.bg}>{tip.glyph}</Text> : null}
183    {tip ? <Box flexShrink={0}><Text color={THEME.onSurface} backgroundColor={THEME.bg}>{` ${tip.label}`}</Text></Box> : null}
184    {tip ? sepText({ Text }, ` ${MARK.sep} ${tip.text}`, THEME.muted, `${key}-text`, { backgroundColor: THEME.bg, wrap: 'truncate-end' }) : <Text backgroundColor={THEME.bg}> </Text>}
185  </Box>
186)
187
188// Pinned at the pane bottom (scroll offset plus body rows) on its own background, so no row shows through; the row under the rule stays blank with no tip, so nothing jumps.
189export function paneFooter(elements: Elements, tip: Tip | null, columns: number, scroll: { offset: number; bodyRows: number }, reveals: Reveal[] = []) {
190  const { Box, Text } = elements
191  return (
192    <Box key="pane-footer" position="absolute" top={Math.max(0, scroll.offset + scroll.bodyRows - FOOTER_ROWS)} left={0} width={columns} height={FOOTER_ROWS} flexDirection="column" backgroundColor={THEME.bg}>
193      <Text key="footer-rule" color={THEME.border} backgroundColor={THEME.bg}>{MARK.rule.repeat(Math.max(1, columns))}</Text>
194      {tipRow(elements, 'footer-tip', tip, columns)}
195      {reveals.map((reveal, at) =>
196        tipRow(elements, `footer-reveal-${at}`, reveal.tip, columns, { position: 'absolute', top: 1, left: 0, display: 'none', hover: { scope: reveal.scope, display: 'flex' } }),
197      )}
198    </Box>
199  )
200}
201
202// A pill's leading icon, mark or digit, or its count: a rounded subpill washed from `hue` into the `fill` it sits on.
203export function subpill({ Box, Text }: Elements, nerd: boolean, fill: string, text: string, key: string, hue: string = THEME.accent) {
204  const bg = mix(hue, fill, WASH)
205  return (
206    <Box key={key} flexDirection="row" flexShrink={0}>
207      {nerd ? <Text key={`${key}-left`} color={bg} backgroundColor={fill}>{CAPS.left}</Text> : null}
208      <Text key={`${key}-text`} color={hue} backgroundColor={bg} bold>{nerd ? text : ` ${text} `}</Text>
209      {nerd ? <Text key={`${key}-right`} color={bg} backgroundColor={fill}>{CAPS.right}</Text> : null}
210    </Box>
211  )
212}
213
214// A section's heading: a surface3 pill led by its icon as a subpill in Nerd mode, bold accent text in safe mode.
215export function heading(elements: Elements, look: Look, key: string, icon: string, title: string) {
216  const { Box, Text } = elements
217  if (!look.nerd) {
218    return (
219      <Box key={`${key}-head`}>
220        <Text color={THEME.accent} bold>{title}</Text>
221      </Box>
222    )
223  }
224  const glyph = look.glyph.icon[icon]
225  return (
226    <Box key={`${key}-head`} flexDirection="row">
227      <Text color={THEME.surface3}>{CAPS.left}</Text>
228      <Box key={`${key}-head-body`} flexDirection="row" backgroundColor={THEME.surface3}>
229        {glyph ? subpill(elements, true, THEME.surface3, glyph, `${key}-head-icon`) : null}
230        <Text color={THEME.onSurface} bold>{glyph ? ` ${title}` : title}</Text>
231      </Box>
232      <Text color={THEME.surface3}>{CAPS.right}</Text>
233    </Box>
234  )
235}
236
237// A card's fill: surface1 washed lightly toward its hue, under a neutral border. Pills on the card sit on it.
238export const cardFill = (hue: string = THEME.accent) => mix(THEME.surface1, hue, 0.06)
239
240// The row above a card, painted on bg so a scrolled card above never shows through the gap.
241export function cardGap({ Box }: Elements, key: string, bg: string = THEME.bg) {
242  return <Box key={`${key}-gap`} height={1} flexShrink={0} backgroundColor={bg} />
243}
244
245// A card's title laid over its top border after the corner (`top` is the border's row); Pane refuses borderTop, so the border stays whole underneath.
246export function titleEdge(elements: Elements, look: Look, key: string, icon: string, title: string, hint = '', top = 1, edge: string = THEME.border) {
247  const { Box, Text } = elements
248  const glyph = look.nerd ? look.glyph.icon[icon] : undefined
249  return (
250    <Box key={`${key}-edge`} position="absolute" top={top} left={1} flexDirection="row" height={1} backgroundColor={THEME.bg}>
251      <Text color={edge}>{`${MARK.rule} `}</Text>
252      {glyph ? <Text color={THEME.accent}>{`${glyph} `}</Text> : null}
253      {sepText(elements, title, THEME.onSurface, `${key}-title`, { bold: true })}
254      {hint ? sepText(elements, ` ${hint}`, THEME.muted, `${key}-hint`) : null}
255      <Text> </Text>
256    </Box>
257  )
258}
259
260// A round card tinted toward `hue`, its title (and muted `hint`) set into the top edge, `right` (such as Fold all) at that edge's end.
261export function section(elements: Elements, look: Look, key: string, icon: string, title: string, body: unknown, hue: string = THEME.accent, { hint = '', right }: { hint?: string; right?: unknown } = {}) {
262  const { Box } = elements
263  return (
264    <Box key={`${key}-block`} flexDirection="column">
265      {cardGap(elements, key)}
266      <Box key={key} flexDirection="column" borderStyle="round" borderColor={THEME.border} backgroundColor={cardFill(hue)} paddingX={1}>
267        {body}
268      </Box>
269      {title ? titleEdge(elements, look, key, icon, title, hint) : null}
270      {right ? (
271        <Box key={`${key}-edge-right`} position="absolute" top={1} right={1} flexDirection="row" height={1} backgroundColor={THEME.bg}>
272          {right}
273        </Box>
274      ) : null}
275    </Box>
276  )
277}
278
279
hooks/pills.tsx 167 lines
1// hooks/pills.tsx, by v-amorim
2// Shared pill row; every mod that draws in the row carries the same copy.
3import type { RenderElement } from 'claude-code'
4
5import { entryCells, mix, type EntryProps, type Tip } from './entry'
6import style from './style'
7import { CAPS, decodeAll, THEME, WASH } from './theme'
8
9// The menu group on Moonlight surface-3, readouts on surface-2.
10export const PILL = { primary: style.colors.pills.primary, secondary: style.colors.pills.secondary }
11
12// Menu entries left to right, by the id in their `menu-pad-<id>` Box key; an unknown id goes last.
13const ENTRY_ORDER = ['manager', 'skills', 'prompts', 'filetree', 'progress', 'headsup']
14
15type Node = { props?: Record<string, unknown>; children?: unknown[] } | null | undefined
16type Elements = { Box: (props: any) => RenderElement; Text: (props: any) => RenderElement }
17
18const keyOf = (node: unknown) => (node as Node)?.props?.key as string | undefined
19const childrenOf = (node: unknown) => (node as Node)?.children ?? []
20
21// Splits next(e) into the menu entries other mods drew, the rest of the row, what else the band holds, and the hover tip under the row.
22export function splitRow(others: unknown) {
23  if (keyOf(others) !== 'menu-rows') return { group: [] as unknown[], row: [] as unknown[], rest: [others], tip: null as unknown }
24  const children = childrenOf(others)
25  const rest = children.filter(child => keyOf(child) !== 'menu-row' && keyOf(child) !== 'menu-tip')
26  const pills = childrenOf(children.find(child => keyOf(child) === 'menu-row'))
27  const groupPill = pills.find(child => keyOf(child) === 'menu-group')
28  const body = childrenOf(groupPill).find(child => keyOf(child) === 'menu-group-body')
29  return {
30    // A mod still running an older copy may draw `menu-group-sep-*` separators between entries; the group drops them.
31    group: childrenOf(body).filter(child => !keyOf(child)?.startsWith('menu-group-sep')),
32    row: pills.filter(child => keyOf(child) !== 'menu-group'),
33    rest,
34    tip: children.find(child => keyOf(child) === 'menu-tip') ?? null,
35  }
36}
37
38// The one-line tip above the pill row, filled while the pointer is on an entry: its "Name:" in bold accent, the rest muted.
39export const tipLine = ({ Box, Text }: Elements, text: string) => {
40  const at = text.indexOf(': ')
41  return (
42    <Box key="menu-tip" paddingLeft={2} flexDirection="row">
43      {at < 0 ? null : <Text color={THEME.accent} bold>{text.slice(0, at + 1)}</Text>}
44      <Text color={THEME.muted}>
45        {(at < 0 ? text : text.slice(at + 1)).split(style.marks.sep).flatMap((part, index) => (index === 0 ? [part] : [<Text key={`sep-${index}`} color={style.colors.pills.separator}>{style.marks.sep}</Text>, part]))}
46      </Text>
47    </Box>
48  )
49}
50
51// One pill with its own caps where the font has them; `on` is what it sits on, `hover` lights the caps with a Button scoped `key`.
52export function pill({ Box, Text }: Elements, nerd: boolean, bg: string, body: unknown, key = 'menu-pill', on?: string, hover?: string) {
53  const under = on ? { backgroundColor: on } : {}
54  const lit = hover ? { hover: { scope: key, color: hover } } : {}
55  return (
56    <Box key={key} flexDirection="row">
57      {nerd ? <Text key={`${key}-cap-left`} color={bg} {...under} {...lit}>{CAPS.left}</Text> : null}
58      <Box key={`${key}-body`} flexDirection="row" backgroundColor={bg}>
59        {body}
60      </Box>
61      {nerd ? <Text key={`${key}-cap-right`} color={bg} {...under} {...lit}>{CAPS.right}</Text> : null}
62    </Box>
63  )
64}
65
66// mod-menu's `labels`: `always` icon and label, `never` the icon alone, `auto` the icon alone while the row overflows (Nerd only; safe keeps labels).
67export type LabelsMode = 'auto' | 'always' | 'never'
68
69// The value of `{ plugin: 'mod-menu', key: 'labels' }` as a mode; a missing or unknown value is `auto`, so a mod works without mod-menu.
70export const labelsOf = (value: unknown): LabelsMode => (value === 'always' || value === 'never' ? value : 'auto')
71
72type ClientNode = { type: 'Client'; props: { props: Record<string, unknown> } }
73const isClient = (node: unknown): node is ClientNode => (node as { type?: string } | null)?.type === 'Client'
74
75// The cells a drawn row takes with every label shown: a Client that may go compact says its own `cells`, or is an entry.tsx one.
76const cellsOf = (node: unknown): number => {
77  if (Array.isArray(node)) return node.reduce((sum: number, each) => sum + cellsOf(each), 0)
78  if (typeof node === 'string') return [...node].length
79  if (!node || typeof node !== 'object') return 0
80  if (isClient(node)) {
81    const { props } = node.props
82    return typeof props.cells === 'number' ? props.cells : entryCells({ ...(props as EntryProps), compact: false })
83  }
84  // A `menu-flash` word comes and goes with the pointer; counting it would flip the row under the pointer.
85  const kids = childrenOf(node).filter(each => each && !keyOf(each)?.startsWith('menu-flash'))
86  return kids.reduce((sum: number, each) => sum + cellsOf(each), Number((node as Node)?.props?.columnGap ?? 0) * Math.max(0, kids.length - 1))
87}
88
89const compacted = (node: unknown): unknown => {
90  if (Array.isArray(node)) return node.map(compacted)
91  if (!node || typeof node !== 'object') return node
92  if (isClient(node)) return node.props.props.compact === false ? { ...node, props: { ...node.props, props: { ...node.props.props, compact: true } } } : node
93  const kids = (node as Node)?.children
94  return kids ? { ...node, children: kids.map(compacted) } : node
95}
96
97// A panel's entry in the menu group, keyed `menu-pad-<id>` for the row's order; the open one is a pill of its own.
98export function menuEntry({ Box, Client, Text }: Elements & { Client: (props: any) => RenderElement }, nerd: boolean, id: string, { labels, ...props }: Omit<EntryProps, 'id' | 'bg' | 'tight'> & { labels?: LabelsMode }) {
99  // The open panel reads as selected, like an On switch: ok text on ok washed into the group fill.
100  const bg = props.isOpen ? mix(THEME.ok, PILL.primary, WASH) : PILL.primary
101  const glyph = (style.glyphs.nerd.icon as Record<string, string>)[`menu-${id}`]
102  const icon = nerd && !props.lead && glyph ? decodeAll(glyph) : undefined
103  // The icon sits tight against the cap, so the label that follows brings its own gap.
104  const shown = icon ? { lead: icon, label: labels === 'never' ? '' : ` ${props.label}`, ...(labels === 'auto' ? { compact: false } : {}) } : {}
105  // On Nerd the caps take the label's padding cells, so an entry is as wide as before the caps.
106  const entry = <Client key={`menu-entry-${id}`} module="./entry.tsx" height={1} props={{ id, bg, ...props, ...(props.isOpen ? { color: THEME.ok } : {}), ...shown, nerd, tight: nerd } satisfies EntryProps} />
107  return (
108    <Box key={`menu-pad-${id}`} flexDirection="row">
109      {/* A closed entry gets caps in the group's own color, so opening a panel never shifts its neighbours. */}
110      {pill({ Box, Text }, nerd, bg, entry, props.isOpen ? `menu-open-${id}` : `menu-closed-${id}`, PILL.primary)}
111    </Box>
112  )
113}
114
115// The band above the prompt: what else it holds (Claude Code's own effort line), then the pill row, so the pills sit on the input.
116export function pillRow(elements: Elements & { Client?: (props: any) => RenderElement }, nerd: boolean, group: unknown[], row: unknown[], rest: unknown[], tip: unknown = null, columns?: number) {
117  const { Box, Client, Text } = elements
118  // Load order changes with an install or a toggle, so entries sort by a fixed rank.
119  const rank = (entry: unknown) => {
120    const at = ENTRY_ORDER.indexOf(String(keyOf(entry) ?? '').replace(/^menu-pad-/, ''))
121    return at < 0 ? ENTRY_ORDER.length : at
122  }
123  // Like a choice bar: the entries side by side on one pill, the open panel's lifted into its own, a divider Client between neighbours.
124  const sorted = [...group].sort((a, b) => rank(a) - rank(b))
125  const entries = Client
126    ? sorted.flatMap((entry, at) => (at === 0 ? [entry] : [<Client key={`menu-group-sep-${at}`} module="./entry.tsx" height={1} props={{ id: `sep-${at}`, label: '', isOpen: false, bg: PILL.primary, rail: true } satisfies EntryProps} />, entry]))
127    : sorted
128  const pills = (
129    <Box key="menu-row" flexDirection="row" columnGap={1}>
130      {group.length > 0 ? pill(elements, nerd, PILL.primary, entries, 'menu-group') : null}
131      {row}
132    </Box>
133  )
134  // Measured with every label shown, so a row cut to icons never grows back into a row that does not fit.
135  const fits = columns === undefined || cellsOf(pills) <= columns
136  return (
137    <Box key="menu-rows" flexDirection="column">
138      {rest}
139      {/* Above the row, kept even when empty so nothing above jumps when a tip appears. */}
140      {tip ?? tipLine(elements, ' ')}
141      {fits ? pills : compacted(pills)}
142    </Box>
143  )
144}
145
146// One chip per view of a panel (its list, its settings), standing in for the title: id, label, icon glyph, an optional count and an optional hover tip.
147export type Tab = { id: string; label: string; icon: string; isOpen: boolean; count?: number; tip?: Tip }
148
149// A panel's title line: the open view on surface3 and bold, the others on surface2 and muted; a click swaps the view.
150export function tabRow({ Box, Client, Text }: Elements & { Client: (props: any) => RenderElement }, nerd: boolean, tabs: Tab[], still: boolean) {
151  return (
152    <Box key="tab-row" flexDirection="row" columnGap={1}>
153      {tabs.map(tab => {
154        const bg = tab.isOpen ? THEME.surface3 : THEME.surface2
155        // Client props refuse an undefined value, so a tab without a count or tip leaves the key out.
156        const countColor = tab.isOpen ? THEME.accent : style.colors.pills.readout
157        const badge = tab.count === undefined ? {} : { badge: { text: String(tab.count), color: countColor, bg: mix(countColor, bg, WASH) } }
158        const tip = tab.tip ? { tip: tab.tip } : {}
159        const entry = (
160          <Client key={tab.id} module="./entry.tsx" height={1} props={{ id: tab.id, label: tab.label, isOpen: tab.isOpen, bg, idle: THEME.muted, still, subLead: tab.icon, subLeadColor: THEME.accent, nerd, ...(tab.isOpen ? { color: THEME.secondary } : {}), ...badge, ...tip } satisfies EntryProps} />
161        )
162        return pill({ Box, Text }, nerd, bg, entry, `${tab.id}-pill`)
163      })}
164    </Box>
165  )
166}
167
hooks/settings.tsx 297 lines
1// hooks/settings.tsx, by v-amorim
2// Shared settings body; every mod carries the same copy.
3import type { ConfigRow, ConfigValue, RenderElement } from 'claude-code'
4
5import type { EntryProps, Tip } from './entry'
6import { cardFill, cardGap, section, sepText, type Look, type Reveal } from './look'
7import style from './style'
8import { CAPS, fill, MARK, THEME } from './theme'
9
10// A button a mod offers beside its settings: runs `/<command> <args>`, declared in its showcase.json.
11// isLocal marks the drawing mod's own command, which it calls directly, so Claude working does not hold it.
12export type Action = { label: string; command: string; args?: string; isLocal?: boolean }
13
14type Elements = { Box: (props: any) => RenderElement; Button: (props: any) => RenderElement; Client: (props: any) => RenderElement; Text: (props: any) => RenderElement }
15
16export type SettingsProps = {
17  // The mod's own /config rows (`<plugin>.<field>`), as $.config.list() returns them.
18  rows: ConfigRow[]
19  actions: Action[]
20  // Another mod's command run while Claude works only queues as a prompt, so those actions go inert.
21  working: boolean
22  run: (action: Action) => unknown
23  // The animations setting is off: a clicked control does not shimmer.
24  still?: boolean
25  // Hover tips by setting field (the key after the dot); a setting without one shows its description's first sentence.
26  tips?: Record<string, string>
27  // The mod's example cards, from its showcase.json, so its own panel and mod-manager draw the same ones.
28  previews?: Previews
29  // The mod's plugin.json userConfig, read by its own panel and by mod-manager alike, for the min and max a number setting declares.
30  fields?: Record<string, Range>
31  // Text settings (by field) another mod edits in its own panel: the card shows a pill running that panel's command in place of "edit it in /config".
32  editors?: Record<string, Action>
33}
34
35export type Range = { min?: number; max?: number }
36
37// A title's trailing "(unit)" is what its number reads in; the head drops it and the value carries it.
38export function splitTitle(label: string) {
39  const match = /^(.*?)\s*\(([^()]+)\)$/.exec(label)
40  return match ? { name: match[1]!, unit: match[2]! } : { name: label, unit: '' }
41}
42
43// Large round counts read short, in thousands or millions.
44const short = (n: number) => (n >= 1_000_000 && n % 100_000 === 0 ? `${n / 1_000_000}M` : n >= 10_000 && n % 1000 === 0 ? `${n / 1000}K` : String(n))
45
46export function showNumber(value: number, unit: string) {
47  if (!unit) return String(value)
48  if (unit === '%') return `${value}%`
49  return `${short(value)} ${value === 1 ? unit.replace(/s$/, '') : unit}`
50}
51
52// About twenty clicks from one end of the range to the other, on a 1, 2 or 5 step; no range steps by one.
53export function stepOf({ min, max }: Range = {}) {
54  if (min === undefined || max === undefined || max <= min) return 1
55  const raw = (max - min) / 20
56  const power = 10 ** Math.floor(Math.log10(raw))
57  const nearest = [1, 2, 5, 10].map(factor => factor * power).reduce((best, step) => (Math.abs(step - raw) < Math.abs(best - raw) ? step : best))
58  return Math.max(1, nearest)
59}
60
61export function stepNumber(value: number, by: 1 | -1, range: Range = {}) {
62  return Math.min(range.max ?? Infinity, Math.max(range.min ?? -Infinity, value + by * stepOf(range)))
63}
64
65type ThemeColor = keyof typeof THEME
66
67// A literal run of an example line, as the mod's previews test drew it from its real tree; a color is a theme name or a #hex.
68export type PreviewSpan = { text: string; color?: string; bg?: string; bold?: boolean }
69export type PreviewLine = { spans: PreviewSpan[] }
70export type Preview = { frame?: { border: string; bg: string }; nerd: PreviewLine[]; safe: PreviewLine[] }
71
72// showcase.json's "previews", written by `just previews`: per setting field, per value as text ("true", "false", an option), the card in each icon mode.
73export type Previews = Record<string, Record<string, Preview>>
74
75const shade = (color: string | undefined) => (color === undefined ? undefined : color in THEME ? THEME[color as ThemeColor] : color)
76
77// What the row's current value looks like, as a card under it; null when the mod drew none for that value.
78function exampleOf({ Box, Text }: Elements, look: Look, previews: Previews | undefined, row: ConfigRow, index: number) {
79  const entry = previews?.[row.key.slice(row.key.indexOf('.') + 1)]?.[String(row.value)]
80  const lines = look.nerd ? entry?.nerd : entry?.safe
81  if (!entry || !lines?.length) return null
82  // The lines carry their own padding and strip, so the frame adds none. The example sinks to bg, so it never matches the card it sits in.
83  const sunk = (color: string | undefined) => (color !== undefined && color === shade(entry.frame?.bg) ? THEME.bg : color)
84  return (
85    <Box key={`setting-preview-${index}`} flexDirection="column" borderStyle="round" borderColor={shade(entry.frame?.border) ?? THEME.border} backgroundColor={THEME.bg} marginTop={1}>
86      {lines.map((line, index) => (
87        <Text key={`line-${index}`} wrap="truncate-end">
88          {line.spans.length === 0 ? ' ' : line.spans.map((span, at) => <Text key={`span-${at}`} color={shade(span.color) ?? THEME.onSurface} {...(span.bg ? { backgroundColor: sunk(shade(span.bg)) } : {})} {...(span.bold ? { bold: true } : {})}>{span.text}</Text>)}
89        </Text>
90      ))}
91    </Box>
92  )
93}
94
95// The value as the panel shows it: on/off as words, a list as its items.
96const shown = (row: ConfigRow) =>
97  row.kind === 'boolean' ? (row.value === true ? style.text.on : style.text.off) : Array.isArray(row.value) ? row.value.join(', ') : String(row.value)
98
99// Words that stay capitalized when a description follows a tip's label and colon.
100const PROPER = new Set(['Claude', 'Haiku', 'Opus', 'Sonnet', 'Windows', 'Nerd'])
101
102// A setting's description as the tip after "Label: ": its first sentence, led by a lowercase letter unless that word is a proper noun.
103export function descriptionTip(description: string) {
104  const sentence = description.split('. ')[0]!.replace(/\.$/, '')
105  const word = /^[A-Z][a-z]+\b/.exec(sentence)?.[0]
106  return word && !PROPER.has(word) ? sentence[0]!.toLowerCase() + sentence.slice(1) : sentence
107}
108
109// A control is an Entry pill posting `setting:<key>=<value as JSON>`; the mod's ui.message hook saves it with settingChoice.
110const SETTING = 'setting:'
111const settingId = (row: ConfigRow, value: ConfigValue) => `${SETTING}${row.key}=${JSON.stringify(value)}`
112
113// The change a clicked settings control asks for, or null when the message is something else.
114export function settingChoice(data: unknown): { key: string; value: ConfigValue } | null {
115  const message = data as { type?: string; id?: string } | undefined
116  if (message?.type !== 'menu-entry' || !message.id?.startsWith(SETTING)) return null
117  const rest = message.id.slice(SETTING.length)
118  const at = rest.indexOf('=')
119  if (at === -1) return null
120  try {
121    return { key: rest.slice(0, at), value: JSON.parse(rest.slice(at + 1)) as ConfigValue }
122  } catch {
123    return null
124  }
125}
126
127// A pill already frames the glyph, so drop the brackets and padding of the standalone button (safe `[↻]`).
128const bare = (glyph: string) => glyph.replace(/[[\]\s]/g, '')
129
130// A pill on the card's tint, capped where the font has the caps; `hover` lights the caps with a Button scoped to the pill's key.
131const pillOn = ({ Box, Text }: Elements, look: Look, key: string, bg: string, body: unknown, on = THEME.surface1, hover?: string) => {
132  const lit = hover ? { hover: { scope: key, color: hover } } : {}
133  return (
134    <Box key={key} flexDirection="row" flexShrink={0}>
135      {look.nerd ? <Text key={`${key}-left`} color={bg} backgroundColor={on} {...lit}>{CAPS.left}</Text> : null}
136      {body}
137      {look.nerd ? <Text key={`${key}-right`} color={bg} backgroundColor={on} {...lit}>{CAPS.right}</Text> : null}
138    </Box>
139  )
140}
141
142export type Choice = { key: string; isOn: boolean; props: Pick<EntryProps, 'id' | 'label' | 'still' | 'tip' | 'subLead' | 'subLeadColor'> }
143
144// A control track: a surface3 pill on `on`, padded by a space each side in safe mode, where it has no caps.
145function track(elements: Elements, look: Look, key: string, on: string, parts: unknown[]) {
146  const { Box, Text } = elements
147  const space = (side: string) => (look.nerd ? null : <Text key={`${key}-space-${side}`} backgroundColor={THEME.surface3}> </Text>)
148  return pillOn(elements, look, `${key}-bar`, THEME.surface3, <Box key={`${key}-options`} flexDirection="row" backgroundColor={THEME.surface3}>{[space('left'), ...parts, space('right')]}</Box>, on)
149}
150
151// A choice in a track: the pick a lifted (surface-4) subpill in primary-70 (`ok`), the rest muted; `on` is what the track sits on.
152export function choiceBar(elements: Elements, look: Look, key: string, on: string, choices: Choice[]) {
153  const { Client } = elements
154  const entry = (choice: Choice, shape: Partial<EntryProps>) => (
155    <Client key={choice.key} module="./entry.tsx" height={1} props={{ ...choice.props, isOpen: choice.isOn, bg: THEME.surface3, ...shape } satisfies EntryProps} />
156  )
157  const options = choices.map(choice =>
158    choice.isOn
159      ? // Tight, so the subpill is as wide as an idle option: the caps (Nerd) or a space (safe) stand where its padding was.
160        pillOn(elements, look, `${choice.key}-chosen`, THEME.lifted, entry(choice, { bg: THEME.lifted, color: THEME.ok, idle: THEME.muted, nerd: look.nerd, tight: true }), THEME.surface3)
161      : entry(choice, { idle: THEME.muted }),
162  )
163  return track(elements, look, key, on, options)
164}
165
166const actionScope = (index: number) => `action-row-${index}`
167
168// The tip each live action shows in the pane footer on hover; a Button cannot post one, so paneFooter reveals it by the pill's hover group.
169export const actionReveals = (actions: Action[], look: Look, working: boolean): Reveal[] =>
170  actions.flatMap((action, index) =>
171    working && !action.isLocal ? [] : [{ scope: actionScope(index), tip: { glyph: bare(look.glyph.go), label: action.label, text: fill(style.text.runsCommand, { command: [action.command, action.args].filter(Boolean).join(' ') }) } }],
172  )
173
174const editorScope = (index: number) => `editor-row-${index}`
175
176const fieldOf = (row: ConfigRow) => row.key.slice(row.key.indexOf('.') + 1)
177
178// The footer tip of each live editor pill, like actionReveals.
179export const editorReveals = (rows: ConfigRow[], editors: Record<string, Action>, look: Look, working: boolean): Reveal[] =>
180  rows.flatMap((row, index) => {
181    const action = row.kind === 'text' && !row.isLocked ? editors[fieldOf(row)] : undefined
182    return action && !working ? [{ scope: editorScope(index), tip: { glyph: MARK.open, label: action.label, text: fill(style.text.runsCommand, { command: [action.command, action.args].filter(Boolean).join(' ') }) } }] : []
183  })
184
185// A pill with a plain Button so the press is a real one; the glyph sits in the label so the whole pill lights.
186const buttonPill = (elements: Elements, look: Look, scope: string, key: string, glyph: string, label: string, onPress: () => unknown) => {
187  const { Button } = elements
188  return pillOn(
189    elements,
190    look,
191    scope,
192    THEME.surface3,
193    <Button key={key} label={look.nerd ? `${glyph} ${label}` : ` ${glyph} ${label} `} plain hover={{ scope, color: THEME.accent, inverse: true }} onPress={onPress} />,
194    THEME.surface1,
195    THEME.accent,
196  )
197}
198
199// Edits the row in place: on/off flips, a choice is a bar, a number steps; text is edited in /config.
200function control(elements: Elements, look: Look, row: ConfigRow, index: number, still: boolean, tips?: Record<string, string>, fields?: Record<string, Range>) {
201  const { Client, Text } = elements
202  const key = `setting-${index}`
203  const text = tips?.[row.key.slice(row.key.indexOf('.') + 1)] ?? (row.description ? descriptionTip(row.description) : '')
204  // Client props refuse an undefined value, so no tip text means no key.
205  const tipFor = (glyph: string) => (text ? { tip: { glyph: bare(glyph), label: splitTitle(row.label).name, text } satisfies Tip } : {})
206  const entry = (suffix: string, label: string, value: ConfigValue, bg: string, color: string, isOpen: boolean, glyph = look.glyph.cycle) => (
207    <Client key={`${key}-${suffix}`} module="./entry.tsx" height={1} props={{ id: settingId(row, value), label, isOpen, bg, color, idle: color, still, ...tipFor(glyph) } satisfies EntryProps} />
208  )
209  if (row.isLocked) return <Text color={THEME.muted}>{`${shown(row)} ${bare(look.glyph.locked)} ${style.text.locked}`}</Text>
210  if (row.kind === 'boolean') {
211    // The switch: `━━●` on, `●━━` off, the knob a bare glyph joined to the line.
212    const isOn = row.value === true
213    const shape = isOn
214      ? { lead: MARK.track, leadColor: THEME.accent, label: MARK.knob, idle: THEME.ok }
215      : { lead: MARK.knob, leadColor: style.colors.pills.readout, label: MARK.track, idle: THEME.trackOff }
216    const flip = <Client key={`${key}-flip`} module="./entry.tsx" height={1} props={{ id: settingId(row, !isOn), isOpen: false, bg: THEME.surface3, tight: true, still, ...shape, ...tipFor(look.glyph.cycle) } satisfies EntryProps} />
217    return track(elements, look, key, cardFill(), [flip])
218  }
219  if (row.kind === 'choice' && row.options?.length) {
220    const choices = row.options.map((option, at) => ({ key: `${key}-option-${at}`, isOn: option === String(row.value), props: { id: settingId(row, option), label: option, still, ...tipFor(look.glyph.cycle) } }))
221    return choiceBar(elements, look, key, cardFill(), choices)
222  }
223  if (row.kind === 'number' && typeof row.value === 'number') {
224    const range = fields?.[row.key.slice(row.key.indexOf('.') + 1)]
225    const value = <Text key={`${key}-value`} color={THEME.ok} backgroundColor={THEME.lifted} bold>{look.nerd ? showNumber(row.value, splitTitle(row.label).unit) : ` ${showNumber(row.value, splitTitle(row.label).unit)} `}</Text>
226    return track(elements, look, key, cardFill(), [
227      entry('down', bare(look.glyph.down), stepNumber(row.value, -1, range), THEME.surface3, THEME.muted, false, look.glyph.down),
228      pillOn(elements, look, `${key}-value-pill`, THEME.lifted, value, THEME.surface3),
229      entry('up', bare(look.glyph.up), stepNumber(row.value, 1, range), THEME.surface3, THEME.muted, false, look.glyph.up),
230    ])
231  }
232  return <Text color={THEME.secondary}>{shown(row)}</Text>
233}
234
235// A setting's first row: the label bold on the left, its control anchored right.
236export const settingHead = ({ Box, Text }: Pick<Elements, 'Box' | 'Text'>, key: string, label: string, control: unknown) => (
237  <Box key={key} flexDirection="row" columnGap={2} justifyContent="space-between">
238    <Text color={THEME.onSurface} bold>{label}</Text>
239    {control}
240  </Box>
241)
242
243// A hairline across its box, under a setting's head row; a Client, since only a Client learns the width it gets.
244export const hairline = ({ Client }: Pick<Elements, 'Client'>, key: string, bg: string) => (
245  <Client key={key} module="./entry.tsx" width="100%" height={1} props={{ id: key, label: '', isOpen: false, bg, rule: true } satisfies EntryProps} />
246)
247
248// The Settings and Actions sections of a mod's panel or page: one round-bordered card per setting.
249export function settingsBody(elements: Elements, look: Look, { rows, actions, working, run, previews, tips, fields, editors = {}, still = false }: SettingsProps) {
250  const { Box, Text } = elements
251  const settings = [
252    rows.length === 0 ? <Text key="settings-none" color={THEME.muted}>{style.text.noSettings}</Text> : null,
253    ...rows.map((row, index) => {
254      const example = exampleOf(elements, look, previews, row, index)
255      const editor = row.kind === 'text' && !row.isLocked ? editors[fieldOf(row)] : undefined
256      const head = editor && !working ? (
257        <Box key={`setting-control-${index}`} flexDirection="row" columnGap={1}>
258          {control(elements, look, row, index, still, tips, fields)}
259          {buttonPill(elements, look, editorScope(index), `setting-editor-${index}`, MARK.open, editor.label, () => run(editor))}
260        </Box>
261      ) : (
262        control(elements, look, row, index, still, tips, fields)
263      )
264      const hint = row.kind !== 'text' ? '' : editor ? (working ? style.text.idleOnly : '') : style.text.editInConfig
265      const note = [row.description, hint].filter(Boolean).join(` ${MARK.sep} `)
266      return (
267        <Box key={`setting-row-${index}`} flexDirection="column" borderStyle="round" borderColor={THEME.border} backgroundColor={cardFill()} paddingX={1} marginTop={index === 0 ? 0 : 1}>
268          {settingHead(elements, `setting-head-${index}`, splitTitle(row.label).name, head)}
269          {row.description || example ? hairline(elements, `setting-rule-${index}`, cardFill()) : null}
270          {row.description ? (
271            sepText(elements, note, THEME.muted, `setting-text-${index}`)
272          ) : null}
273          {example}
274        </Box>
275      )
276    }),
277  ]
278  const buttons = actions.map((action, index) =>
279    working && !action.isLocal ? (
280      sepText(elements, `${look.glyph.go} ${action.label} ${MARK.sep} ${style.text.idleOnly}`, THEME.muted, `action-${index}`)
281    ) : (
282      buttonPill(elements, look, actionScope(index), `action-${index}`, bare(look.glyph.go), action.label, () => run(action))
283    ),
284  )
285  return (
286    <Box key="settings" flexDirection="column">
287      {/* The cards carry their own border, and the panel's open Settings tab already names them. */}
288      <Box key="settings-cards" flexDirection="column">
289        {cardGap(elements, 'settings')}
290        {settings}
291      </Box>
292      {actions.length > 0 ? section(elements, look, 'actions', 'actions', style.text.actions, buttons) : null}
293    </Box>
294  )
295}
296
297
hooks/toasts.tsx 87 lines
1// hooks/toasts.tsx, by v-amorim
2// Shared toast cards; every mod that toasts carries the same copy.
3import type { RenderElement } from 'claude-code'
4
5import type { Tip } from './entry'
6import style from './style'
7import { CAPS, MARK, mix, THEME } from './theme'
8
9type Elements = { Box: (props: any) => RenderElement; Client: (props: any) => RenderElement; Text: (props: any) => RenderElement }
10
11// The slice of a mod's Look the cards read, typed here because mod-menu has no look.tsx.
12type ToastLook = { nerd: boolean; glyph: { close: string } }
13
14export const TOAST_MS = 4000
15
16const DISMISS_TIP = { label: 'Dismiss', text: 'hides this message' }
17const MAX_TOASTS = 3
18
19// A mod's own toasts, newest last. A toast reaches only the hooks above its caller, so each mod catches its own.
20export class ToastList {
21  items: { id: number; text: string }[] = []
22  // The owning mod's animations setting is off, so the dismiss × does not fade.
23  isStill = false
24  private lastId = 0
25
26  // `prefix` keeps the dismiss Client keys apart from other mods' cards in the band.
27  constructor(readonly prefix: string) {}
28
29  add(text: string) {
30    const id = (this.lastId += 1)
31    this.items = [...this.items, { id, text }].slice(-MAX_TOASTS)
32    return id
33  }
34
35  drop(id: number) {
36    this.items = this.items.filter(toast => toast.id !== id)
37  }
38
39  // The toast a dismiss click names, or undefined when the click was not on one of these cards.
40  idOf(entryId: string) {
41    const head = `toast-${this.prefix}-`
42    return entryId.startsWith(head) ? Number(entryId.slice(head.length)) : undefined
43  }
44}
45
46// A pill already frames the glyph, so drop the brackets and padding of the standalone button (safe `[×]`).
47const bare = (glyph: string) => glyph.replace(/[[\]\s]/g, '')
48
49// Callers write `✓ Saved` or `! Failed`; the card draws the mark.
50function toneOf(text: string) {
51  for (const [mark, color, bold] of [[MARK.ok, THEME.good, false], [MARK.warn, THEME.alert, true]] as const) {
52    if (text.startsWith(mark)) return { mark, color, bold, text: text.slice(mark.length).trimStart() }
53  }
54  return { mark: MARK.bullet, color: THEME.accent, bold: false, text }
55}
56
57// The web toast as a band card: tinted icon square, "Title: detail" split bold and muted, an error edged in alert; closeTip is the × tip.
58export const toastCards = ({ Box, Client, Text }: Elements, list: ToastList, look?: ToastLook, closeTip: Pick<Tip, 'label' | 'text'> = DISMISS_TIP) =>
59  list.items.map(toast => {
60    const id = `toast-${list.prefix}-${toast.id}`
61    const tone = toneOf(toast.text)
62    const nerd = look?.nerd === true
63    const close = bare(look?.glyph.close ?? style.glyphs.safe.close)
64    const fill = mix(THEME.surface1, tone.color, 0.06)
65    const split = tone.text.indexOf(': ')
66    const [title, detail] = split > 0 ? [tone.text.slice(0, split), tone.text.slice(split + 2)] : [tone.text, '']
67    const edge = tone.color === THEME.alert ? mix(fill, THEME.alert, 0.33) : THEME.border
68    return (
69      <Box key={id} flexDirection="row" columnGap={1} paddingX={1} borderStyle="round" borderColor={edge} backgroundColor={fill}>
70        <Box key={`${id}-mark`} flexShrink={0}>
71          <Text color={tone.color} backgroundColor={mix(fill, tone.color, 0.15)} bold={tone.bold}>{` ${tone.mark} `}</Text>
72        </Box>
73        <Box key={`${id}-text`} flexGrow={1} flexShrink={1}>
74          <Text wrap="wrap">
75            <Text color={THEME.onSurface} bold>{title}</Text>
76            {detail ? <Text color={THEME.muted}>{` ${detail}`}</Text> : null}
77          </Text>
78        </Box>
79        <Box key={`${id}-close`} flexDirection="row" flexShrink={0}>
80          {nerd ? <Text key={`${id}-close-left`} color={THEME.surface3} backgroundColor={fill}>{CAPS.left}</Text> : null}
81          <Client key={id} module="./entry.tsx" height={1} props={{ id, label: close, isOpen: false, bg: THEME.surface3, still: list.isStill, tip: { glyph: close, ...closeTip } }} />
82          {nerd ? <Text key={`${id}-close-right`} color={THEME.surface3} backgroundColor={fill}>{CAPS.right}</Text> : null}
83        </Box>
84      </Box>
85    )
86  })
87
hooks/prompts.ts 171 lines
1// 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
171
hooks/tail.ts 50 lines
1// 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}
50
hooks/timeline.tsx 85 lines
1// 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
85
hooks/theme.ts 46 lines
1// hooks/theme.ts, by v-amorim
2// Shared theme; every mod carries the same copy. Colors, marks and glyphs live in style.ts.
3import style from './style'
4
5export const THEME = style.colors.theme
6
7// Shell command roles: what runs, its options, quoted text, variables, numbers and the glue between steps.
8export const SYNTAX = style.colors.syntax
9
10// One cell each and never an emoji: ⚠ switches to its emoji form in some fonts and draws two cells wide.
11export const MARK = style.marks
12
13// style.ts writes Nerd glyphs as {U+XXXX}, since private-use characters do not survive many editors and tools.
14const decode = (text: string) => text.replace(/\{U\+([0-9A-F]+)\}/gi, (_, hex: string) => String.fromCodePoint(parseInt(hex, 16)))
15
16export function decodeAll<T>(value: T): T {
17  if (typeof value === 'string') return decode(value) as T
18  if (Array.isArray(value)) return value.map(decodeAll) as T
19  return Object.fromEntries(Object.entries(value as object).map(([key, inner]) => [key, decodeAll(inner)])) as T
20}
21
22// A pill's rounded ends, drawn only where the font has them.
23export const CAPS = decodeAll(style.caps)
24
25export const statusColor = (note: string) =>
26  note.startsWith(MARK.ok) ? THEME.good : note.startsWith(MARK.warn) ? THEME.alert : THEME.muted
27
28const channels = (hex: string) => [1, 3, 5].map(at => parseInt(hex.slice(at, at + 2), 16))
29const isHex = (color: string) => /^#[0-9a-f]{6}$/i.test(color)
30
31// The color `t` of the way from `from` to `to`; a color that is not #RRGGBB switches halfway.
32export function mix(from: string, to: string, t: number) {
33  if (t <= 0) return from
34  if (t >= 1) return to
35  if (!isHex(from) || !isHex(to)) return t < 0.5 ? from : to
36  const end = channels(to)
37  return `#${channels(from).map((value, at) => Math.round(value + (end[at]! - value) * t).toString(16).padStart(2, '0')).join('')}`
38}
39
40// The share of a hue left in a subpill or count badge, washed into the fill it sits on.
41export const WASH = 0.72
42
43// A text.ts string with its {name} slots filled; a slot with no value stays as written, so a missing one shows.
44export const fill = (template: string, values: Record<string, string | number> = {}) =>
45  template.replace(/\{(\w+)\}/g, (slot, name: string) => (name in values ? String(values[name]) : slot))
46