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

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