SLOPSHOPPER

mod-collision-guard

Pauses an edit when another open session edited the same file moments ago, and asks whether to proceed, move to a worktree, cancel or say something else

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mod-collision-guard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /mod-collision-guard ⎿ mod-collision-guard: Watching for edits from other sessions. On, 30 min window. /mod-collision-guard settings opens the panel ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

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

mod-collision-guard

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

Asks before editing a file another session just changed.

Install

Inside Claude Code:

/plugin marketplace add v-amorim/moonlight-claude-mods
/plugin install mod-collision-guard@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

Automatic.

See it

Asking before an edit
![][img-collision-guard]

Features

  • Shared log: Each session records the files it edits in its own small file under ~/.claude/mods-data/mod-collision-guard/sessions, so sessions never write over each other.
  • Pause: Before Edit, Write, MultiEdit or NotebookEdit, a question appears when another open session edited that file inside the window.
  • Four ways out: Proceed, move to a worktree (Claude calls EnterWorktree and redoes the edit there), cancel, or type an instruction for Claude.

Settings

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

SettingDefaultWhat it does
AnimationsonEntries shimmer while they wait. Hover colors also fade. Off, everything draws still
Guard editsonAsks when another session edited it. Only edits inside the window count. Off, edits are still recorded for other sessions but never paused
Window (minutes)30How long an edit counts as recent. Only other sessions' edits count
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

License

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

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

[img-collision-guard]: https://github.com/user-attachments/assets/5034753f-819f-4683-b8ec-68cb0da18b66

Source 12 files
hooks/register.tsx 535 lines
1// mod-collision-guard / hooks/register.tsx, by v-amorim
2// Wires the mod into the engine: checks edit tools for conflicts, asks, and draws its pane.
3import type { ConfigValue, EngineInterface, Register, RenderChildren } from 'claude-code'
4
5import { agoText, clearOwn, cleanReason, clockText, EDIT_TOOLS, findConflict, normalize, pathOf, recordEdit, titleOf, type Clash, type Io } from './guard'
6import { GUARD, holdCard, type HoldView } from './hold'
7import { isTipMessage, tipText, type EntryMessage, type Tip, type TipMessage } from './entry'
8import { detectNerd, FOOTER_ROWS, lookFor, paneFooter, sepText, type IconMode, withMark } from './look'
9import { pillRow, splitRow, tabRow, tipLine } from './pills'
10import { settingChoice, settingsBody, type Action, type Previews, type Range } from './settings'
11import text from './text'
12import { TOAST_MS, ToastList, toastCards } from './toasts'
13import { fill, MARK, statusColor, THEME } from './theme'
14
15// The person's settings (userConfig), fixed per activation: a change reloads the mod.
16let enabled = true
17// The control under the pointer, from its tip post: the pane footer shows it.
18let hovered: TipMessage | null = null
19const hoveredTip = (): Tip | null => (hovered?.id && hovered.label ? { glyph: hovered.glyph ?? '', label: hovered.label, text: hovered.text ?? '' } : null)
20
21// A band redraw can replace a hovered entry, whose leave and quiet timer then never come, so a band tip ends on its own.
22const BAND_TIP_MS = 5000
23let bandTipAt = 0
24// The tip the band stopped showing; the pane footer keeps its own.
25let bandExpired: TipMessage | null = null
26
27function expireBandTip($: EngineInterface) {
28  const at = ++bandTipAt
29  $.clock.after(BAND_TIP_MS, () => {
30    if (at !== bandTipAt || !hovered?.id) return
31    bandExpired = hovered
32    $.ui.invalidate('ui.render')
33  })
34}
35let windowMinutes = 30
36// False for a `-p` run or the SDK, from session.start.
37let isInteractive = true
38// When the person last chose Proceed per file: quiet until another session edits it after that, so at most one window.
39const snoozed = new Map<string, number>()
40// The animations setting, fixed per activation: off, every entry draws still.
41let still = false
42const toasts = new ToastList('collisionguard')
43let iconMode: IconMode = 'auto'
44let look = lookFor(false)
45
46const PANE = 'collision-guard'
47let actions: Action[] = []
48let previews: Previews | undefined
49let fields: Record<string, Range> | undefined
50let note = ''
51
52const CHOICES = [text.choiceProceed, text.choiceWorktree, text.choiceCancel]
53
54// `$` cannot cross an import, so the log gets plain functions instead.
55const ioOf = ($: EngineInterface): Io => ({
56  read: path => $.fs.read(path),
57  write: (path, text) => $.fs.write(path, text),
58  list: path => $.fs.list(path),
59})
60
61// Claude's config folder: CLAUDE_CONFIG_DIR when set, else ~/.claude. The literal name is for the host's static scan.
62async function configDir($: EngineInterface) {
63  const custom = ((await $.env.get('CLAUDE_CONFIG_DIR')) ?? '').trim()
64  const dir = custom || `${(await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''}/.claude`
65  return dir.replace(/\\/g, '/').replace(/\/+$/, '')
66}
67
68async function logDir($: EngineInterface) {
69  return `${await configDir($)}/mods-data/mod-collision-guard/sessions`
70}
71
72// Nerd glyphs when the setting asks for them, or on auto when the terminal's font is a Nerd Font.
73async function detectLook($: EngineInterface) {
74  if (iconMode !== 'auto') return lookFor(iconMode === 'nerd')
75  const env = {
76    WT_SESSION: await $.env.get('WT_SESSION'),
77    TERM_PROGRAM: await $.env.get('TERM_PROGRAM'),
78    ITERM_PROFILE: await $.env.get('ITERM_PROFILE'),
79    KITTY_WINDOW_ID: await $.env.get('KITTY_WINDOW_ID'),
80    ALACRITTY_WINDOW_ID: await $.env.get('ALACRITTY_WINDOW_ID'),
81    GNOME_TERMINAL_SCREEN: await $.env.get('GNOME_TERMINAL_SCREEN'),
82    KONSOLE_VERSION: await $.env.get('KONSOLE_VERSION'),
83  }
84  const host = {
85    localAppData: ((await $.env.get('LOCALAPPDATA')) ?? '').replace(/\\/g, '/'),
86    home: ((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '').replace(/\\/g, '/'),
87    read: (path: string) => $.fs.read(path).then(String),
88    run: async (argv: string[]) => {
89      const result = await $.process.run(argv, { timeoutMs: 5000 })
90      if (result.exitCode !== 0) throw new Error(result.stderr)
91      return result.stdout
92    },
93  }
94  return lookFor(await detectNerd(env, host))
95}
96
97// Reading the terminal's settings takes a while, so on auto the last answer, kept in this mod's store, draws until it returns.
98async function savedLook($: EngineInterface) {
99  if (iconMode !== 'auto') return look
100  try {
101    const nerd = await $.store.get('look-nerd')
102    return typeof nerd === 'boolean' ? lookFor(nerd) : look
103  } catch {
104    return look
105  }
106}
107
108async function saveLook($: EngineInterface) {
109  try {
110    await $.store.set('look-nerd', look.nerd)
111  } catch {
112    // Without a store the mod still draws; it only starts from safe again next time.
113  }
114}
115
116// Hooks of one event run in turn, so the slow font check runs beside them instead of holding up every mod after this one.
117let lookLoad: Promise<void> | null = null
118
119function loadLook($: EngineInterface) {
120  lookLoad ??= (async () => {
121    look = await savedLook($)
122    void detectLook($).then(async found => {
123      const isChanged = found.nerd !== look.nerd
124      look = found
125      await saveLook($)
126      if (isChanged) $.ui.invalidate('ui.render')
127    }).catch(() => {
128      // A failed check keeps the icons drawn now.
129    })
130  })()
131  return lookLoad
132}
133
134const isPaneOpen = async ($: EngineInterface) => (await $.ui.panes()).some(pane => pane.id === PANE && pane.isPlaced)
135
136// `/mod-collision-guard settings` toggles the panel; an unplaced pane is waiting, not open.
137async function toggleSettings($: EngineInterface) {
138  if (await isPaneOpen($)) {
139    await $.ui.close({ id: PANE })
140    await $.state.set({ plugin: 'mod-collision-guard', key: 'pane' }, false)
141    return
142  }
143  note = ''
144  const showcase = await $.fs.read(`${$.plugin.root}/showcase.json`).then(text => JSON.parse(String(text))).catch(() => null)
145  previews = showcase?.previews
146  fields = await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`).then(text => JSON.parse(String(text)).userConfig).catch(() => undefined)
147  actions = (Array.isArray(showcase?.actions) ? showcase.actions : []).map((action: Action) => ({ ...action, isLocal: action.command === 'mod-collision-guard' }))
148  const opened = await $.ui.open({ id: PANE, title: text.paneName, closeOnEscape: true })
149  if (!opened.isPlaced) $.ui.toast(fill(text.settingsWaiting, { reason: opened.reason }))
150  await $.state.set({ plugin: 'mod-collision-guard', key: 'pane' }, opened.isPlaced)
151}
152
153// What `/mod-collision-guard <args>` does; the panel's own actions call it directly, since $.command.run skips the calling plugin.
154async function answer($: EngineInterface, args: string) {
155  if (args.trim() === 'settings') {
156    await toggleSettings($)
157    return ''
158  }
159  if (args.trim() === 'clear') {
160    await clearOwn(ioOf($), await logDir($), await $.session.id())
161    return text.cleared
162  }
163  const line = fill(text.statusLine, { state: enabled ? text.statusOn : text.statusOff, minutes: windowMinutes })
164  return enabled ? fill(text.statusWatching, { line }) : line
165}
166
167const worktreeReason = (path: string) =>
168  `The user chose to move to a worktree because another open session is editing ${path}. Call EnterWorktree, then redo this edit there.`
169
170// A worktree needs a git repo: a .git entry in the file's folder or one above it.
171async function isInRepo($: EngineInterface, path: string) {
172  const parts = path.replace(/\\/g, '/').split('/').slice(0, -1)
173  for (; parts.length > 0; parts.pop()) {
174    if (await $.fs.exists(`${parts.join('/') || '/'}/.git`).catch(() => false)) return true
175  }
176  return false
177}
178
179type Decision = 'proceed' | 'worktree' | 'cancel' | 'interrupted' | 'error'
180type Outcome = { decision: Decision; reason?: string }
181type Held = HoldView & { decision: Decision | null; reason?: string }
182
183// One hold at a time: edit calls in a turn run in order, but a subagent's can overlap.
184let held: Held | null = null
185
186// A transcript larger than this is never read for the other session's title: the card names it by its short id instead.
187const NAME_READ_MAX = 64_000_000
188
189// The other session's /rename title, read from its transcript once the card is up; the short id shows until it lands.
190async function loadName($: EngineInterface, mine: Held) {
191  const projects = `${await configDir($)}/projects`
192  const file = `${mine.session.replace(/[^A-Za-z0-9_-]/g, '_')}.jsonl`
193  for (const dir of await $.fs.list(projects)) {
194    if (dir.kind !== 'dir') continue
195    const stat = await $.fs.stat(`${projects}/${dir.name}/${file}`).catch(() => null)
196    if (!stat || stat.kind !== 'file') continue
197    if (stat.size > NAME_READ_MAX) return
198    const title = titleOf(String(await $.fs.read(`${projects}/${dir.name}/${file}`)))
199    if (title && held === mine) {
200      mine.name = title
201      $.ui.invalidate('ui.render')
202    }
203    return
204  }
205}
206
207// A hold's own time is 10 s, but time inside a `$` call is free, so the hold waits on a short child process.
208const PAUSES = {
209  windows: [['sleep', '0.25'], ['powershell', '-NoProfile', '-NonInteractive', '-Command', 'Start-Sleep -Milliseconds 250'], ['ping', '-n', '1', '-w', '250', '192.0.2.1']],
210  other: [['sleep', '0.25'], ['perl', '-e', 'select(undef,undef,undef,0.25)']],
211}
212let pauseAt = 0
213let isWindows: boolean | undefined
214
215async function pause($: EngineInterface) {
216  // A failed read is not kept: an environment that is failing is the very case the unchecked card holds.
217  if (isWindows === undefined) {
218    const os = await $.env.get('OS').then(value => value ?? '', () => null)
219    if (os !== null) isWindows = os === 'Windows_NT'
220  }
221  const choices = isWindows ? PAUSES.windows : PAUSES.other
222  // The last waiter that worked goes first, yet every one is tried before giving up, so one slow run never sticks.
223  for (let tried = 0; tried < choices.length; tried++) {
224    const at = (pauseAt + tried) % choices.length
225    try {
226      const waited = await $.process.run(choices[at]!, { timeoutMs: 5000 })
227      // Nothing answers ping's reserved address, so its exit 1 is the wait it was asked for.
228      if (waited.exitCode === 0 || (choices[at]![0] === 'ping' && waited.exitCode === 1)) {
229        pauseAt = at
230        return
231      }
232    } catch {
233      // that waiter is not installed or timed out; try the next one
234    }
235  }
236  throw new Error('no way to wait')
237}
238
239// A surface that draws the band can hold the call on a card; VS Code chat, -p and SDK sessions cannot, and ask in their own dialog instead.
240async function canDraw($: EngineInterface) {
241  const surfaces = await $.session.surfaces().catch(() => ['terminal' as const])
242  return surfaces.some(surface => surface === 'terminal' || surface === 'desktop')
243}
244
245// The dialog fallback for a session with no band: the same choices, and free text counts as the reason for cancelling.
246async function askInstead($: EngineInterface, view: HoldView, now: number): Promise<Outcome> {
247  try {
248    const question = view.isUnchecked ? fill(text.questionUnchecked, { path: view.path }) : fill(text.question, { path: view.path, ago: agoText(now - view.at), clock: clockText(view.at) })
249    const options = view.isUnchecked ? [text.choiceProceed, text.choiceCancel] : view.canWorktree ? CHOICES : CHOICES.filter(choice => choice !== text.choiceWorktree)
250    const picked = await $.ui.ask(question, { options, header: text.askHeader })
251    if (picked === text.choiceProceed) return { decision: 'proceed' }
252    if (picked === text.choiceWorktree) return { decision: 'worktree' }
253    if (picked !== text.choiceCancel) return { decision: 'cancel', reason: cleanReason(picked) }
254  } catch {
255    // Dismissed or nobody to ask: refuse, the safe answer.
256  }
257  return { decision: 'cancel' }
258}
259
260// A card action, from a click on its entry or its digit typed into the empty prompt.
261function choose($: EngineInterface, id: string) {
262  if (!held || held.decision !== null) return
263  if (held.isAsking) {
264    if (id === GUARD.send) sendReason($, held.draft)
265    if (id === GUARD.back) {
266      held.isAsking = false
267      held.draft = ''
268    }
269  } else if (id === GUARD.proceed) held.decision = 'proceed'
270  else if (id === GUARD.worktree && held.canWorktree && !held.isUnchecked) held.decision = 'worktree'
271  // Cancel asks why first; Enter on the field, empty or not, ends the hold.
272  else if (id === GUARD.cancel) held.isAsking = true
273  $.ui.invalidate('ui.render')
274}
275
276function sendReason($: EngineInterface, value: string) {
277  if (!held || held.decision !== null || !held.isAsking) return
278  held.reason = cleanReason(value)
279  held.decision = 'cancel'
280  $.ui.invalidate('ui.render')
281}
282
283function typeReason($: EngineInterface, value: string) {
284  if (!held?.isAsking) return
285  held.draft = value
286  $.ui.invalidate('ui.render')
287}
288
289// Holds the edit on the card until the person answers, or the turn is interrupted. No timeout decides for them.
290async function hold($: EngineInterface, next: { signal: AbortSignal }, view: HoldView): Promise<Outcome> {
291  if (!(await canDraw($))) return askInstead($, view, await $.clock.now())
292  // `held` is claimed with no await between the check and the claim, so two waiting calls cannot both pass.
293  while (held !== null) {
294    if (next.signal.aborted) return { decision: 'interrupted' }
295    try {
296      await pause($)
297    } catch {
298      return { decision: 'error' }
299    }
300  }
301  // The session folder is what the file link is shown relative to; an unreadable one leaves the absolute path.
302  const root = await $.session.cwd().catch(() => undefined)
303  const mine: Held = { ...view, root, decision: null }
304  held = mine
305  pauseAt = 0
306  try {
307    if (!view.isUnchecked) void Promise.resolve().then(() => loadName($, mine)).catch(() => undefined)
308    $.ui.invalidate('ui.render')
309    while (mine.decision === null) {
310      if (next.signal.aborted) mine.decision = 'interrupted'
311      else await pause($)
312    }
313  } catch {
314    mine.decision = 'error'
315  } finally {
316    if (held === mine) held = null
317    hovered = null
318    $.ui.invalidate('ui.render')
319  }
320  return { decision: mine.decision as Decision, reason: mine.reason }
321}
322
323const cancelDeny = (path: string, reason?: string) =>
324  `The user cancelled this edit of ${path} because another open session is editing it${reason ? `, and said why: "${reason}"` : ''}. Do not retry it; ask what to do instead.`
325
326const stopDeny = (decision: Decision) =>
327  decision === 'interrupted'
328    ? 'The turn was interrupted while this edit waited for the person, so it did not run.'
329    : 'Collision guard hit an error while holding this edit and did not run it. Do not retry it unless the person asks you to.'
330
331// The card, its answer and what each means for the call: undefined lets it run.
332async function decide($: EngineInterface, next: { signal: AbortSignal }, path: string, clash: Clash, now: number) {
333  // A `-p` run has nobody to ask, and refusing would fail every collision in it.
334  if (!isInteractive) return undefined
335  const key = normalize(path)
336  const quiet = snoozed.get(key)
337  if (quiet !== undefined && clash.at <= quiet) return undefined
338  // Fire and forget: a refused notification must never touch the pause.
339  void Promise.resolve().then(() => $.ui.notify(fill(text.notifyPaused, { file: path.split(/[\\/]/).pop() ?? '' }), { title: text.notifyTitle })).catch(() => undefined)
340  const outcome = await hold($, next, { path, session: clash.session, prompt: clash.prompt, at: clash.at, more: clash.more, isUnchecked: false, canWorktree: await isInRepo($, path), isAsking: false, draft: '' })
341  if (outcome.decision === 'proceed') {
342    snoozed.set(key, now)
343    return undefined
344  }
345  if (outcome.decision === 'worktree') return { deny: worktreeReason(path) }
346  if (outcome.decision === 'cancel') return { deny: cancelDeny(path, outcome.reason) }
347  return { deny: stopDeny(outcome.decision) }
348}
349
350// The check itself failed: the edit is not let through unasked, and with nobody to ask it is refused.
351async function askUnchecked($: EngineInterface, next: { signal: AbortSignal }, path: string) {
352  const outcome = await hold($, next, { path, session: '', at: 0, more: 0, isUnchecked: true, canWorktree: false, isAsking: false, draft: '' }).catch((): Outcome => ({ decision: 'error' }))
353  if (outcome.decision === 'proceed') return undefined
354  if (outcome.decision === 'interrupted' || outcome.decision === 'error') return { deny: stopDeny(outcome.decision) }
355  const why = outcome.reason ? `, and the person said why: "${outcome.reason}"` : ''
356  return { deny: `Collision guard could not check ${path} against other open sessions and did not run this edit${why}. Do not retry it; ask what to do instead.` }
357}
358
359// A settings card's click: its row, saved through /config; a saved change reloads this mod.
360async function saveSetting($: EngineInterface, key: string, value: ConfigValue) {
361  const row = (await $.config.list()).find(each => each.key === key)
362  if (!row) return
363  const result = await $.config.set({ key: row.key, value })
364  note = 'deny' in result && result.deny ? `${MARK.warn} ${result.deny}` : `${MARK.ok} ${fill(text.settingSaved, { label: row.label })}`
365  $.ui.invalidate('ui.render')
366}
367
368export const register: Register = (on, options) => {
369  still = options.animations === false
370  toasts.isStill = still
371  // Cards in the band; Claude Code's own toast box is narrow and cuts long text.
372  on('ui.toast', async ($, e) => {
373    const id = toasts.add(e.text)
374    $.ui.invalidate('ui.render')
375    $.clock.after(e.timeoutMs ?? TOAST_MS, () => {
376      toasts.drop(id)
377      $.ui.invalidate('ui.render')
378    })
379    return { value: undefined }
380  })
381
382  on('ui.message', async ($, e, next) => {
383    if (isTipMessage(e.data)) {
384      hovered = e.data
385      if (hovered.id) expireBandTip($)
386      $.ui.invalidate('ui.render')
387      return next(e)
388    }
389    const clicked = e.data as EntryMessage
390    const dismissed = clicked?.type === 'menu-entry' ? toasts.idOf(clicked.id) : undefined
391    if (dismissed !== undefined) {
392      toasts.drop(dismissed)
393      $.ui.invalidate('ui.render')
394      return next(e)
395    }
396    const change = settingChoice(e.data)
397    if (change) await saveSetting($, change.key, change.value)
398    // The card's choices are entry Clients; a click posts the entry's id.
399    if (clicked?.type === 'menu-entry') choose($, clicked.id)
400
401    return next(e)
402  })
403
404  enabled = options.enabled !== false
405  snoozed.clear()
406  isInteractive = true
407  const minutes = Number(options.window_minutes)
408  windowMinutes = minutes > 0 ? minutes : 30
409  iconMode = (options.icons as IconMode | undefined) ?? 'auto'
410  // A forced mode needs no detection; auto loads the saved answer, then checks the terminal font beside the hooks.
411  look = lookFor(iconMode === 'nerd')
412
413  on('session.start', async ($, e, next) => {
414    isInteractive = e.isInteractive !== false
415    await loadLook($)
416    await $.command.register({
417      name: 'mod-collision-guard',
418      description: text.commandDescription,
419      argumentHint: '[settings|clear]',
420    })
421    await $.state.set({ plugin: 'mod-collision-guard', key: 'pane' }, await isPaneOpen($))
422    $.ui.invalidate('ui.render')
423
424    return next(e)
425  })
426
427  // A closed session no longer collides with anything, so its entries go with it.
428  on('session.end', async ($, e, next) => {
429    await clearOwn(ioOf($), await logDir($), e.sessionId).catch(() => undefined)
430
431    return next(e)
432  })
433
434  // The host accepts a list of tools here, yet its types name a single one.
435  on('tool.call', { tool: EDIT_TOOLS as unknown as 'Edit' }, async ($, e, next) => {
436    const windowMs = windowMinutes * 60000
437    let path = ''
438    // The host lets a call through when its hook throws, so a failure before the verdict asks instead.
439    try {
440      path = pathOf(e as unknown as Record<string, unknown>)
441      if (!path) return next(e)
442
443      // A log that cannot be read must never block an edit.
444      const clash = enabled ? await findConflict(ioOf($), await logDir($), await $.session.id(), path, await $.clock.now(), windowMs).catch(() => undefined) : undefined
445      if (clash) {
446        const verdict = await decide($, next, path, clash, await $.clock.now())
447        if (verdict) return verdict
448      }
449    } catch {
450      const verdict = await askUnchecked($, next, path || 'this file')
451      if (verdict) return verdict
452    }
453
454    const result = (await next(e)) as { deny?: string; isError?: boolean }
455    try {
456      if (path && !result.deny && !result.isError) await recordEdit(ioOf($), await logDir($), await $.session.id(), path, await $.clock.now(), windowMs, await $.session.turns().catch(() => undefined))
457    } catch {
458      // The edit already ran; a log that cannot be written only costs the next check.
459    }
460
461    return result as never
462  })
463
464  on('command.run', { command: 'mod-collision-guard' }, async ($, e) => ({ text: await answer($, e.args) }))
465
466  on('ui.close', async ($, e, next) => {
467    if (e.id === PANE) await $.state.set({ plugin: 'mod-collision-guard', key: 'pane' }, false)
468
469    return next(e)
470  })
471
472  // One panel at a time: another mod's pane opening closes this one, deferred past that plugin's dispatch.
473  on('state.set', async ($, e, next) => {
474    if (e.key === 'pane' && e.plugin !== 'mod-collision-guard' && e.value === true) {
475      $.clock.after(0, async () => {
476        if (!(await isPaneOpen($))) return
477        await $.ui.close({ id: PANE })
478        await $.state.set({ plugin: 'mod-collision-guard', key: 'pane' }, false)
479      })
480    }
481
482    return next(e)
483  })
484
485  // The hold card and the toasts draw here; the pill row below is other mods', joined through splitRow so load order cannot start a second row.
486  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
487    await loadLook($)
488    // The card draws wherever the band does, desktop included, since canDraw lets a hold start there.
489    if (held && held.decision === null) {
490      // @ts-expect-error The host's types omit Client from an AbovePrompt's elements, yet it draws it.
491      const { Box, Button, Client, Input, Link, Text } = $.ui.resolve(e)
492      const { group, row, tip: menuTip } = splitRow(await next(e))
493      const cards = toastCards({ Box, Client, Text }, toasts, look, text.tipToast)
494      const tip = hovered?.id && hovered !== bandExpired ? tipLine({ Box, Text }, tipText(hovered)) : menuTip
495      const view = { now: await $.clock.now(), columns: e.props.bodyColumns, maxRows: e.props.maxRows, still }
496      // While a call is held only the card and the pill row draw, so other rows cannot fill the band and cut the card off.
497      return pillRow({ Box, Client, Text }, look.nerd, group, row, [...cards, holdCard({ Box, Button, Client, Input, Link, Text }, look, held, view, { key: id => choose($, id), input: value => typeReason($, value), submit: value => sendReason($, value) })], tip, e.props.bodyColumns)
498    }
499    if (e.surface !== 'terminal' || e.props.hasSurvey) return next(e)
500    const { Box, Client, Text } = $.ui.resolve(e)
501    const cards = toastCards({ Box, Client, Text }, toasts, look, text.tipToast)
502    if (cards.length === 0) return next(e)
503    const { group, row, rest, tip: menuTip } = splitRow(await next(e))
504    const tip = hovered?.id && hovered !== bandExpired ? tipLine({ Box, Text }, tipText(hovered)) : menuTip
505    if (group.length || row.length || tip) return pillRow({ Box, Client, Text }, look.nerd, group, row, [...cards, ...rest], tip, e.props.bodyColumns)
506    return <Box key="collisionguard" flexDirection="column">{cards}{rest as RenderChildren}</Box>
507  })
508
509  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
510    await loadLook($)
511    if (e.requestId !== PANE) return next(e)
512
513    // @ts-expect-error The host's types omit Client from a Pane's elements, yet a Pane draws it.
514    const { Box, Button, Client, Text } = $.ui.resolve(e)
515    const rows = (await $.config.list()).filter(row => row.key.startsWith('mod-collision-guard.'))
516    // A saved change reloads this mod, which redraws the panel with the new value.
517    const run = async (action: Action) => {
518      note = await answer($, action.args ?? '')
519        .then(text => `${MARK.ok} ${text || action.label}`)
520        .catch(error => `${MARK.warn} ${String(error)}`)
521      $.ui.invalidate('ui.render')
522    }
523
524    return (
525      <Box flexDirection="column" backgroundColor={THEME.bg} paddingX={1} minHeight={e.props.scroll.bodyRows}>
526        {tabRow({ Box, Client, Text }, look.nerd, [{ id: 'mod-collision-guard-settings-tab', label: `${text.paneTitle} ${MARK.sep} ${text.paneTitleSettings}`, icon: look.glyph.gear, isOpen: true, tip: { glyph: look.glyph.gear, label: text.paneTitleSettings, text: text.tipTabSettings } }], still)}
527        {sepText({ Text }, withMark(look, note) || `${text.defaultNote} ${MARK.sep} ${text.defaultNoteTail}`, statusColor(note), 'settings-note', { wrap: 'truncate-end' })}
528        {settingsBody({ Box, Button, Client, Text }, look, { rows, actions, working: false, run, previews, fields, still })}
529        <Box key="footer-space" height={FOOTER_ROWS} flexShrink={0} />
530        {paneFooter({ Box, Text }, hoveredTip(), e.props.bodyColumns, e.props.scroll)}
531      </Box>
532    )
533  })
534}
535
hooks/guard.ts 150 lines
1// mod-collision-guard / hooks/guard.ts, by v-amorim
2// Holds the pure logic that records edits per session and finds conflicts between sessions.
3import text from './text'
4import { fill } from './theme'
5
6// `$` never crosses an import, so the register file hands in plain functions.
7// prompt: the recording session's prompt count when it edited, shown on the card as #N.
8export type Edit = { path: string; session: string; at: number; prompt?: number }
9
10// The newest clash plus how many other sessions also edited the file inside the window.
11export type Clash = Edit & { more: number }
12
13export type Io = {
14  read: (path: string) => Promise<unknown>
15  write: (path: string, text: string) => Promise<void>
16  list: (path: string) => Promise<{ name: string; kind: string; mtimeMs?: number }[]>
17}
18
19export const EDIT_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
20
21// Edit, Write and MultiEdit name `file_path`; NotebookEdit names `notebook_path`.
22export const pathOf = (input: Record<string, unknown>) => {
23  const raw = input.file_path ?? input.notebook_path
24  return typeof raw === 'string' && raw ? raw : ''
25}
26
27// One spelling per file: forward slashes, git-bash `/c/` as `c:/`, and case folded on a Windows drive path.
28export const normalize = (path: string) => {
29  const flat = path.replace(/\\/g, '/').replace(/\/{2,}/g, '/').replace(/^\/([A-Za-z])\//, '$1:/')
30  return /^[A-Za-z]:/.test(flat) ? flat.toLowerCase() : flat
31}
32
33const fileOf = (dir: string, session: string) => `${dir}/${session.replace(/[^A-Za-z0-9_-]/g, '_')}.json`
34
35// A corrupt or half-written file reads as empty instead of failing the edit.
36export function parseEdits(text: unknown): Edit[] {
37  try {
38    const data = JSON.parse(String(text))
39    if (!Array.isArray(data)) return []
40    return data.filter((item): item is Edit => item && typeof item.path === 'string' && typeof item.session === 'string' && typeof item.at === 'number')
41  } catch {
42    return []
43  }
44}
45
46// Each session writes only its own file, so concurrent sessions never overwrite one another; one writer at a time inside a session.
47let queue: Promise<unknown> = Promise.resolve()
48const serial = <T>(task: () => Promise<T>) => {
49  const run = queue.then(task, task)
50  queue = run.catch(() => undefined)
51  return run
52}
53
54export const recordEdit = (io: Io, dir: string, session: string, path: string, now: number, windowMs: number, prompt?: number) =>
55  serial(async () => {
56    const file = fileOf(dir, session)
57    const own = parseEdits(await io.read(file).catch(() => ''))
58    const kept = own.filter(edit => now - edit.at <= windowMs && edit.path !== normalize(path))
59    kept.push({ path: normalize(path), session, at: now, ...(prompt === undefined ? {} : { prompt }) })
60    await io.write(file, JSON.stringify(kept))
61  })
62
63export const clearOwn = (io: Io, dir: string, session: string) => serial(() => io.write(fileOf(dir, session), '[]'))
64
65// The newest edit of this file by another session inside the window, with the count of the other sessions that also edited it, or undefined.
66export async function findConflict(io: Io, dir: string, session: string, path: string, now: number, windowMs: number) {
67  const mine = fileOf(dir, session).slice(dir.length + 1)
68  const target = normalize(path)
69  const entries = await io.list(dir).catch(() => [])
70  let newest: Edit | undefined
71  const sessions = new Set<string>()
72  for (const entry of entries) {
73    if (entry.kind !== 'file' || !entry.name.endsWith('.json') || entry.name === mine) continue
74    // Ended sessions leave their files behind; one not written inside the window holds no live edit, so it is never read.
75    if (entry.mtimeMs && now - entry.mtimeMs > windowMs) continue
76    for (const edit of parseEdits(await io.read(`${dir}/${entry.name}`).catch(() => ''))) {
77      if (edit.session === session || normalize(edit.path) !== target || now - edit.at > windowMs) continue
78      sessions.add(edit.session)
79      if (!newest || edit.at > newest.at) newest = edit
80    }
81  }
82  return newest ? { ...newest, more: sessions.size - 1 } : undefined
83}
84
85export function agoText(ms: number) {
86  const minutes = Math.floor(ms / 60000)
87  if (minutes < 1) return text.agoLessThanMinute
88  if (minutes < 60) return fill(text.agoMinutes, { minutes })
89  return fill(text.agoHoursMinutes, { hours: Math.floor(minutes / 60), minutes: minutes % 60 })
90}
91
92// Claude reads the reason in one line, so a long one is cut here.
93export const REASON_MAX = 300
94
95// What the person typed as one quoted line: flat, no control characters, cut, and its quotes escaped.
96export const cleanReason = (raw: string) => {
97  const flat = raw.replace(/\s+/g, ' ').replace(/\p{Cc}/gu, '').trim()
98  const cut = [...flat].length > REASON_MAX ? `${[...flat].slice(0, REASON_MAX).join('').trimEnd()}...` : flat
99  return cut.replace(/"/g, '\\"')
100}
101
102// vscode://file/<absolute path>, with the path in forward slashes and `?` and `#` escaped.
103export const vscodeHref = (path: string) => {
104  const flat = path.replace(/\\/g, '/').replace(/^\/([A-Za-z])\//, '$1:/')
105  return `vscode://file${flat.startsWith('/') ? '' : '/'}${encodeURI(flat).replace(/[?#]/g, mark => encodeURIComponent(mark))}`
106}
107
108// A long path cut in the middle, keeping the first folder and the name.
109export function shortPath(path: string, max: number) {
110  if ([...path].length <= max) return path
111  const parts = path.replace(/\\/g, '/').split('/')
112  const name = parts[parts.length - 1]!
113  const kept = `${parts.slice(0, parts[0] === '' ? 2 : 1).join('/')}/…/${name}`
114  return [...kept].length <= max ? kept : `…${[...name].slice(-(max - 1)).join('')}`
115}
116
117// A path as { folder, name } for the link label: relative to the session folder when inside it, else absolute; forward slashes, folder keeps its trailing slash.
118export function splitPath(path: string, root: string | undefined, max: number) {
119  const flat = path.replace(/\\/g, '/')
120  const base = (root ?? '').replace(/\\/g, '/').replace(/\/+$/, '')
121  // Windows drive paths compare without case, since the drive letter and folders vary in how they are typed.
122  const isDrive = /^[A-Za-z]:(\/|$)/.test(flat) && /^[A-Za-z]:(\/|$)/.test(base)
123  const isInside = base !== '' && (isDrive ? flat.toLowerCase().startsWith(`${base.toLowerCase()}/`) : flat.startsWith(`${base}/`))
124  const label = shortPath(isInside ? flat.slice(base.length + 1) : flat, max)
125  const cut = label.lastIndexOf('/')
126  return { folder: label.slice(0, cut + 1), name: label.slice(cut + 1) }
127}
128
129export const shortId =(session: string) => session.slice(0, 8)
130
131// The last /rename title in a session's transcript text, or undefined.
132export function titleOf(transcript: string) {
133  let found: string | undefined
134  for (const match of transcript.matchAll(/"type":"custom-title","customTitle":"((?:[^"\\]|\\.)*)"/g)) {
135    try {
136      found = JSON.parse(`"${match[1]}"`) as string
137    } catch {
138      // A half-written row keeps the title found before it.
139    }
140  }
141  return found?.trim() || undefined
142}
143
144const two = (n: number) => String(n).padStart(2, '0')
145
146export const clockText = (at: number) => {
147  const date = new Date(at)
148  return `${two(date.getHours())}:${two(date.getMinutes())}`
149}
150
hooks/hold.tsx 148 lines
1// mod-collision-guard / hooks/hold.tsx, by v-amorim
2// The card above the prompt that holds an edit: who edited the file, the file as a link, and the ways out.
3import type { RenderElement } from 'claude-code'
4
5import { entryCells, type EntryProps } from './entry'
6import { agoText, clockText, splitPath, vscodeHref } from './guard'
7import { cardFill, sepText, subpill, titleEdge, type Look } from './look'
8import style from './style'
9import text from './text'
10import { CAPS, fill as slot, MARK, THEME } from './theme'
11
12type Part = (props: any) => RenderElement
13export type Parts = { Box: Part; Button: Part; Client: Part; Input: Part; Link: Part; Text: Part }
14
15export const GUARD = { cancel: 'guard-cancel', worktree: 'guard-worktree', proceed: 'guard-proceed', send: 'guard-reason-send', back: 'guard-reason-back', field: 'guard-reason' }
16
17// isUnchecked: the check itself failed, so there is no other session to name.
18export type HoldView = {
19  path: string
20  root?: string
21  session: string
22  name?: string
23  prompt?: number
24  at: number
25  more: number
26  isUnchecked: boolean
27  canWorktree: boolean
28  isAsking: boolean
29  draft: string
30}
31
32const NAME_MAX = 24
33
34// The VS Code mark a path link leads with in Nerd mode, local since style.ts is a shared copy (as in mod-transcript's linkPill).
35const LINK_ICON = String.fromCodePoint(0xf0a1e)
36
37// A clickable pill, the whole of it the link: caps on surface3 and the icon leading, or one padding cell each side without the font; the text underlined.
38function linkPill({ Link, Text }: Pick<Parts, 'Link' | 'Text'>, href: string, label: { folder: string; name: string }, nerd: boolean, key: string) {
39  return (
40    <Link key={key} href={href}>
41      {nerd ? <Text color={THEME.surface3}>{CAPS.left}</Text> : null}
42      <Text backgroundColor={THEME.surface3}>
43        {nerd ? <Text color={THEME.secondary}>{`${LINK_ICON} `}</Text> : ' '}
44        {/* Dim text is measured only on bg and surface-1, so on the pill's surface-3 the quieter folder takes muted. */}
45        {label.folder ? <Text color={THEME.muted} underline>{label.folder}</Text> : null}
46        <Text color={THEME.secondary} underline>{label.name}</Text>
47        {nerd ? '' : ' '}
48      </Text>
49      {nerd ? <Text color={THEME.surface3}>{CAPS.right}</Text> : null}
50    </Link>
51  )
52}
53
54// Rows the card takes, border included: head, file, the blank row and the actions (or the field and its hint).
55const CARD_ROWS = 6
56// The card keeps its blank row only where the band has room for it above the pill row.
57const BLANK_FROM_ROWS = CARD_ROWS + 3
58
59export function holdCard(
60  { Box, Button, Client, Input, Link, Text }: Parts,
61  look: Look,
62  view: HoldView,
63  { now, columns, maxRows, still }: { now: number; columns: number; maxRows: number; still: boolean },
64  on: { key: (id: string) => unknown; input: (value: string) => unknown; submit: (value: string) => unknown },
65) {
66  const width = Math.max(20, columns - 4)
67  const bg = cardFill()
68  const name = view.name ?? view.session.slice(0, 8)
69  const shownName = [...name].length > NAME_MAX ? `${[...name].slice(0, NAME_MAX - 1).join('')}…` : name
70
71  const subject = view.isUnchecked ? (
72    <Text key="guard-head" color={THEME.muted} wrap="truncate-end">{text.card.headUnchecked}</Text>
73  ) : (
74    <Box key="guard-head" flexDirection="row" columnGap={1}>
75      <Box key="guard-head-left" flexDirection="row" columnGap={1} flexShrink={1}>
76        <Box key="guard-head-label" flexShrink={1}><Text color={THEME.muted} wrap="truncate-end">{text.card.head}</Text></Box>
77        <Box key="guard-head-name" flexShrink={0}><Text color={THEME.onSurface} bold>{shownName}</Text></Box>
78        {view.prompt === undefined ? null : subpill({ Box, Text }, look.nerd, bg, slot(text.card.prompt, { n: view.prompt }), 'guard-head-prompt')}
79        {view.more > 0 ? <Box key="guard-head-more" flexShrink={0}><Text color={THEME.muted}>{view.more === 1 ? text.card.moreOne : slot(text.card.moreMany, { n: view.more })}</Text></Box> : null}
80      </Box>
81      <Box key="guard-head-when" flexGrow={1} flexShrink={0} justifyContent="flex-end">
82        {sepText({ Text }, slot(text.card.when, { ago: agoText(now - view.at), clock: clockText(view.at) }), THEME.muted, 'guard-when')}
83      </Box>
84    </Box>
85  )
86
87  const file = (
88    <Box key="guard-file" flexDirection="row">
89      {linkPill({ Link, Text }, vscodeHref(view.path), splitPath(view.path, view.root, width - 4), look.nerd, 'guard-file-link')}
90    </Box>
91  )
92
93  // The digit chip and its label in one entry; a Button nobody sees carries the digit, since only a Button takes a hotkey and a visible one inverts under the pointer.
94  const choice = (id: string, label: string, digit: string, tipBody: string) => {
95    const props = { id, label, isOpen: false, bg, subLead: digit, subLeadColor: THEME.onSurface, idle: THEME.muted, still, nerd: look.nerd, tip: { glyph: digit, label, text: tipBody } } satisfies EntryProps
96    return (
97      <Box key={`${id}-pad`} flexDirection="row">
98        <Client key={id} module="./entry.tsx" width={entryCells(props)} height={1} props={props} />
99        <Box key={`${id}-key`} width={0} height={0} overflow="hidden">
100          <Button key={`${id}-hotkey`} label={label} hotkey={digit} plain onPress={() => on.key(id)} />
101        </Box>
102      </Box>
103    )
104  }
105  const choices = [
106    { id: GUARD.cancel, label: text.choiceCancel, tip: text.tips.cancel },
107    ...(view.canWorktree && !view.isUnchecked ? [{ id: GUARD.worktree, label: text.choiceWorktree, tip: text.tips.worktree }] : []),
108    { id: GUARD.proceed, label: text.choiceProceed, tip: view.isUnchecked ? text.tips.proceedUnchecked : text.tips.proceed },
109  ]
110  const actions = (
111    <Box key="guard-actions" flexDirection="row" columnGap={3}>
112      {choices.map((each, at) => choice(each.id, each.label, String(at + 1), each.tip))}
113    </Box>
114  )
115
116  const hint = (id: string, key: string, label: string, tipBody: string) => {
117    const props = { id, label, isOpen: false, bg, subLead: key, subLeadColor: THEME.onSurface, idle: THEME.muted, still, nerd: look.nerd, tip: { glyph: key, label, text: tipBody } } satisfies EntryProps
118    return <Client key={id} module="./entry.tsx" width={entryCells(props)} height={1} props={props} />
119  }
120  const isEmpty = view.draft.trim() === ''
121  const sendLabel = isEmpty ? text.reason.skip : text.reason.send
122  const reasonRows = (
123    <Box key="guard-reason-rows" flexDirection="column">
124      <Box key="guard-reason-field" backgroundColor={THEME.surface3} paddingX={1}>
125        <Input key={GUARD.field} placeholder={text.reason.placeholder} value={view.draft} submitLabel={sendLabel} autoFocus onInput={on.input} onSubmit={on.submit} />
126      </Box>
127      <Box key="guard-reason-hint" flexDirection="row" columnGap={1}>
128        {hint(GUARD.send, text.reason.enter, sendLabel, isEmpty ? text.tips.reasonSkip : text.tips.reasonSend)}
129        <Text key="guard-reason-sep" color={style.colors.pills.separator}>{MARK.sep}</Text>
130        {hint(GUARD.back, text.reason.esc, text.reason.back, text.tips.reasonBack)}
131      </Box>
132    </Box>
133  )
134
135  return (
136    <Box key="guard-card" flexDirection="column">
137      <Box key="guard-card-box" flexDirection="column" borderStyle="round" borderColor={THEME.border} backgroundColor={bg} paddingX={1}>
138        {subject}
139        {file}
140        {view.isAsking ? reasonRows : null}
141        {!view.isAsking && maxRows >= BLANK_FROM_ROWS ? <Box key="guard-blank" height={1} /> : null}
142        {view.isAsking ? null : actions}
143      </Box>
144      {titleEdge({ Box, Text }, look, 'guard-card', '', text.card.title, text.card.titleHint, 0, THEME.border)}
145    </Box>
146  )
147}
148
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/text.ts 58 lines
1// mod-collision-guard / hooks/text.ts, by v-amorim
2// Every string this mod shows. Edit the values to reword or translate; keep the keys and the {slots}.
3export default {
4  "paneTitle": "Collision guard",
5  "notifyTitle": "Collision guard",
6  "notifyPaused": "Edit paused: another session edited {file}",
7  "paneTitleSettings": "Settings",
8  "paneName": "Collision Guard",
9  "settingsWaiting": "Settings panel waiting: {reason}",
10  "commandDescription": "Collision Guard status; `settings` opens its panel, `clear` forgets this session's recorded edits",
11  "cleared": "Recorded edits of this session forgotten",
12  "statusOn": "On",
13  "statusOff": "Off",
14  "statusLine": "{state}, {minutes} min window. /mod-collision-guard settings opens the panel",
15  "statusWatching": "Watching for edits from other sessions. {line}",
16  "askHeader": "Collision",
17  "choiceProceed": "Proceed",
18  "choiceWorktree": "Move to a worktree",
19  "choiceCancel": "Cancel",
20  "question": "Another open session edited {path} {ago} ({clock}). Edit it anyway?",
21  "questionUnchecked": "Collision guard could not check {path} for edits from other sessions. Edit it anyway?",
22  "card": {
23    "title": "Paused",
24    "titleHint": "collision guard",
25    "head": "Another open session edited this file:",
26    "headUnchecked": "Could not check other sessions, so this edit waits for you.",
27    "prompt": "#{n}",
28    "moreOne": "+1 more session",
29    "moreMany": "+{n} more sessions",
30    "when": "{ago} · {clock}"
31  },
32  "tips": {
33    "cancel": "stops this edit and lets you say why",
34    "worktree": "refuses this edit and has Claude redo it in a worktree",
35    "proceed": "lets this edit run; this file stays quiet until the other session edits it again",
36    "proceedUnchecked": "lets this edit run without the check",
37    "reasonSend": "sends your reason to Claude and cancels the edit",
38    "reasonSkip": "cancels the edit without a reason",
39    "reasonBack": "goes back to the choices, nothing is decided"
40  },
41  "reason": {
42    "placeholder": "Why? Enter sends, Enter alone skips",
43    "enter": "Enter",
44    "esc": "Esc",
45    "send": "send",
46    "skip": "skip",
47    "back": "back to the choices"
48  },
49  "settingSaved": "{label} saved",
50  "defaultNote": "Click a setting to change it",
51  "defaultNoteTail": "The mod reloads",
52  "agoLessThanMinute": "less than a minute ago",
53  "agoMinutes": "{minutes} min ago",
54  "agoHoursMinutes": "{hours} h {minutes} min ago",
55  "tipTabSettings": "shows this mod's settings",
56  "tipToast": {"label":"Dismiss","text":"hides this message"}
57}
58
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/theme.ts 46 lines
1// hooks/theme.ts, by v-amorim
2// Shared theme; every mod carries the same copy. Colors, marks and glyphs live in style.ts.
3import style from './style'
4
5export const THEME = style.colors.theme
6
7// Shell command roles: what runs, its options, quoted text, variables, numbers and the glue between steps.
8export const SYNTAX = style.colors.syntax
9
10// One cell each and never an emoji: ⚠ switches to its emoji form in some fonts and draws two cells wide.
11export const MARK = style.marks
12
13// style.ts writes Nerd glyphs as {U+XXXX}, since private-use characters do not survive many editors and tools.
14const decode = (text: string) => text.replace(/\{U\+([0-9A-F]+)\}/gi, (_, hex: string) => String.fromCodePoint(parseInt(hex, 16)))
15
16export function decodeAll<T>(value: T): T {
17  if (typeof value === 'string') return decode(value) as T
18  if (Array.isArray(value)) return value.map(decodeAll) as T
19  return Object.fromEntries(Object.entries(value as object).map(([key, inner]) => [key, decodeAll(inner)])) as T
20}
21
22// A pill's rounded ends, drawn only where the font has them.
23export const CAPS = decodeAll(style.caps)
24
25export const statusColor = (note: string) =>
26  note.startsWith(MARK.ok) ? THEME.good : note.startsWith(MARK.warn) ? THEME.alert : THEME.muted
27
28const channels = (hex: string) => [1, 3, 5].map(at => parseInt(hex.slice(at, at + 2), 16))
29const isHex = (color: string) => /^#[0-9a-f]{6}$/i.test(color)
30
31// The color `t` of the way from `from` to `to`; a color that is not #RRGGBB switches halfway.
32export function mix(from: string, to: string, t: number) {
33  if (t <= 0) return from
34  if (t >= 1) return to
35  if (!isHex(from) || !isHex(to)) return t < 0.5 ? from : to
36  const end = channels(to)
37  return `#${channels(from).map((value, at) => Math.round(value + (end[at]! - value) * t).toString(16).padStart(2, '0')).join('')}`
38}
39
40// The share of a hue left in a subpill or count badge, washed into the fill it sits on.
41export const WASH = 0.72
42
43// A text.ts string with its {name} slots filled; a slot with no value stays as written, so a missing one shows.
44export const fill = (template: string, values: Record<string, string | number> = {}) =>
45  template.replace(/\{(\w+)\}/g, (slot, name: string) => (name in values ? String(values[name]) : slot))
46
hooks/style.ts 280 lines
1// hooks/style.ts, by v-amorim
2// Shared style; every mod carries the same copy. Change values freely, keep the keys. {U+XXXX} is a Nerd Font code point.
3export default {
4  "colors": {
5    "theme": {
6      "bg": "#0D0E17",
7      "surface1": "#141726",
8      "surface2": "#1C2033",
9      "surface3": "#252A42",
10      "lifted": "#2F3654",
11      "trackOff": "#4B5270",
12      "border": "#242839",
13      "onSurface": "#EEEEFA",
14      "muted": "#9BA3C4",
15      "accent": "#7386D0",
16      "secondary": "#B9C6F5",
17      "good": "#49EF95",
18      "ok": "#A2B0EA",
19      "alert": "#CA5F71",
20      "warn": "#FFCB6B",
21      "folder": "#86E1FC",
22      "pill": "#B9C6F5",
23      "addBg": "#1D3B38",
24      "delBg": "#3D2737"
25    },
26    "syntax": {
27      "command": "#7386D0",
28      "option": "#FFAFAF",
29      "text": "#A5CFFF",
30      "variable": "#79C0FF",
31      "number": "#B0C8FF",
32      "operator": "#CDCFE7"
33    },
34    "pills": {
35      "primary": "#252A42",
36      "secondary": "#1C2033",
37      "separator": "#3B446A",
38      "readout": "#7E86A4"
39    },
40    "shimmer": [
41      "#A2B0EA",
42      "#949FCE",
43      "#8993B9"
44    ],
45    "usage": [
46      "#B9C6F5",
47      "#ACB9E6",
48      "#A5A1E9",
49      "#CF95CA",
50      "#CA5FA6",
51      "#CA5F71"
52    ],
53    "statusline": {
54      "pillBorder": "#3A3D4A",
55      "menuBackground": "#1C1D24",
56      "divergence": "#B86CB3",
57      "behind": "#8A9BE0"
58    },
59    "models": {
60      "opus": "#C3A6FF",
61      "sonnet": "#79C0FF",
62      "haiku": "#7CD591",
63      "inherit": "#9BA3C4"
64    },
65    "kinds": {
66      "thinking": { "hue": "#C3A6FF", "wash": "#494267" },
67      "read": { "hue": "#B9C6F5", "wash": "#464C64" },
68      "search": { "hue": "#A5CFFF", "wash": "#404E67" },
69      "edit": { "hue": "#B9C6F5", "wash": "#4C5167" },
70      "shell": { "hue": "#FFCB6B", "wash": "#5B4D3B" },
71      "web": { "hue": "#79C0FF", "wash": "#324A67" },
72      "agent": { "hue": "#F99BD1", "wash": "#593F59" },
73      "tool": { "hue": "#9BA3C4", "wash": "#3D4155" }
74    }
75  },
76  "tags": {
77    "router": {
78      "color": "#F99BD1",
79      "icon": "◆"
80    },
81    "debug": {
82      "color": "#FF9C8B",
83      "icon": "‼"
84    },
85    "interface": {
86      "color": "#EEB154",
87      "icon": "▪"
88    },
89    "writing": {
90      "color": "#C3C55A",
91      "icon": "≡"
92    },
93    "utility": {
94      "color": "#7CD591",
95      "icon": "☼"
96    },
97    "start": {
98      "color": "#2AD7D7",
99      "icon": "»"
100    },
101    "mods": {
102      "color": "#5DCBFF",
103      "icon": "+"
104    },
105    "review": {
106      "color": "#D1A8FF",
107      "icon": "∴"
108    },
109    "personal": {
110      "color": "#9BA3C4",
111      "icon": "●"
112    }
113  },
114  "levels": {
115    "low": { "glyph": "○", "color": "#ACB9E6" },
116    "medium": { "glyph": "◐", "color": "#A5A1E9" },
117    "high": { "glyph": "●", "color": "#CF95CA" },
118    "xhigh": { "glyph": "◉", "color": "#CA5FA6" },
119    "max": { "glyph": "◈", "color": "#CA5F71" }
120  },
121  "statuslineMarks": {
122    "branch": "⑂",
123    "dirty": "●",
124    "diverged": "⇅",
125    "ahead": "↑",
126    "behind": "↓",
127    "pathSep": "›",
128    "menu": "⋯",
129    "close": "×"
130  },
131  "text": {
132    "settings": "Settings",
133    "actions": "Actions",
134    "noSettings": "No settings.",
135    "on": "On",
136    "off": "Off",
137    "locked": "locked",
138    "editInConfig": "edit it in /config",
139    "idleOnly": "available once Claude is idle",
140    "runsCommand": "runs /{command}"
141  },
142  "marks": {
143    "stage": "▸",
144    "cursor": "❯",
145    "bullet": "•",
146    "ok": "✓",
147    "warn": "!",
148    "sep": "·",
149    "stop": "○",
150    "stopOpen": "●",
151    "rail": "│",
152    "rule": "─",
153    "track": "━━",
154    "knob": "●",
155    "spinner": "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏",
156    "open": "↗",
157    "strip": "▎"
158  },
159  "caps": {
160    "left": "{U+E0B6}",
161    "right": "{U+E0B4}"
162  },
163  "glyphs": {
164    "safe": {
165      "on": "[◉]",
166      "off": "[○]",
167      "go": "[↵]",
168      "reveal": "⊞",
169      "cycle": "[↻]",
170      "down": "[−]",
171      "up": "[+]",
172      "locked": "[■]",
173      "close": "[×]",
174      "warn": "!",
175      "ok": "✓",
176      "expanded": "▾",
177      "collapsed": "▸",
178      "more": "▾",
179      "less": "▴",
180      "back": "<",
181      "title": "▸",
182      "gear": "⊛",
183      "copy": "⧉",
184      "icon": { "cost": "$", "arrow": "→" },
185      "mark": {
186        "thinking": "∴",
187        "read": "≡",
188        "search": "?",
189        "edit": "±",
190        "shell": "$",
191        "web": "@",
192        "agent": "◆",
193        "tool": "•"
194      }
195    },
196    "nerd": {
197      "on": " {U+F205} ",
198      "off": " {U+F204} ",
199      "go": " {U+F054} ",
200      "reveal": "{U+EAF7}",
201      "cycle": " {U+F021} ",
202      "down": " {U+F068} ",
203      "up": " {U+F067} ",
204      "locked": " {U+F023} ",
205      "close": " {U+F00D} ",
206      "warn": "{U+EA6C}",
207      "ok": "{U+EAB2}",
208      "expanded": "{U+EAB4}",
209      "collapsed": "{U+EAB6}",
210      "more": "{U+F0D7}",
211      "less": "{U+F0D8}",
212      "back": "{U+F053}",
213      "title": "{U+F054}",
214      "gear": "{U+F0493}",
215      "copy": "{U+F018F}",
216      "icon": {
217        "settings": "{U+F1DE}",
218        "actions": "{U+F0E7}",
219        "wrapup": "{U+F187}",
220        "features": "{U+F005}",
221        "mod": "{U+F12E}",
222        "installed": "{U+F487}",
223        "arrow": "{U+F061}",
224        "not-installed": "{U+F019}",
225        "icons": "{U+F031}",
226        "updates": "{U+F0AA}",
227        "welcome": "{U+F135}",
228        "apply": "{U+F00C}",
229        "cost": "{U+F155}",
230        "skill": "{U+F0D0}",
231        "prompt": "{U+F075}",
232        "fold": "{U+F066}",
233        "files": "{U+EB86}",
234        "replay": "{U+EA82}",
235        "folder": "{U+EA83}",
236        "folder-open": "{U+EAF7}",
237        "file": "{U+EA7B}",
238        "ext-ts": "{U+E628}",
239        "ext-react": "{U+E625}",
240        "ext-js": "{U+E781}",
241        "ext-json": "{U+E60B}",
242        "ext-md": "{U+E609}",
243        "ext-py": "{U+E73C}",
244        "ext-config": "{U+E615}",
245        "ext-sh": "{U+F489}",
246        "Edit": "{U+F040}",
247        "MultiEdit": "{U+F040}",
248        "NotebookEdit": "{U+F040}",
249        "Write": "{U+F15B}",
250        "Read": "{U+F02D}",
251        "Bash": "{U+F120}",
252        "Grep": "{U+F002}",
253        "Glob": "{U+F002}",
254        "Agent": "{U+EBBC}",
255        "Task": "{U+EBBC}",
256        "Skill": "{U+F0D0}",
257        "WebFetch": "{U+F0AC}",
258        "WebSearch": "{U+F0AC}",
259        "TodoWrite": "{U+F0AE}",
260        "tool": "{U+F0AD}",
261        "menu-skills": "{U+F0D0}",
262        "menu-prompts": "{U+F075}",
263        "menu-filetree": "{U+EB86}",
264        "menu-progress": "{U+EBBC}",
265        "menu-headsup": "{U+EA6C}"
266      },
267      "mark": {
268        "thinking": "{U+F09D1}",
269        "read": "{U+F02D}",
270        "search": "{U+F002}",
271        "edit": "{U+F040}",
272        "shell": "{U+F120}",
273        "web": "{U+F0AC}",
274        "agent": "{U+EBBC}",
275        "tool": "{U+F0AD}"
276      }
277    }
278  }
279}
280
types/index.d.ts 8 lines
1// mod-collision-guard / types/index.d.ts, by v-amorim
2// Declares the mod's plugin state type with a pane flag.
3declare module 'claude-code' {
4  interface PluginState {
5    'mod-collision-guard': { pane: boolean }
6  }
7}
8