Lists every mod, installs the ones you tick, turns them on or off, updates them, and shows what each does

<!-- mod-manager / README.md, by v-amorim --> <!-- Generated from the root README, plugin.json and showcase.json; edit those. -->
Part of [moonlight-claude-mods][doc-root].
Installs, turns on and off, and updates mods, and edits their settings.
Inside Claude Code:
/plugin marketplace add v-amorim/moonlight-claude-mods
/plugin install mod-manager@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].
The gear pill, or /mod-manager.
| Icons on Auto (Nerd Font) | Icons on Plain | A mod's page: its spend and its settings |
|---|---|---|
| ![][img-manager-icons-auto] | ![][img-manager-icons-plain] | ![][img-manager-page] |
| The first-run card, a mod's spend, and a toggle |
|---|
| https://github.com/user-attachments/assets/362d6469-ed76-4465-a7f0-b6b4457a7f2d |
Change them with /mod-manager 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 |
| 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-manager-icons-auto]: https://github.com/user-attachments/assets/bb4107d9-8ce0-4400-8fda-d6c386f401e6 [img-manager-icons-plain]: https://github.com/user-attachments/assets/ec418df4-5d28-4fea-8945-e82a40727922 [img-manager-page]: https://github.com/user-attachments/assets/fabff194-14e6-4c84-a4e4-de54d9ada145
hooks/register.tsx 1101 lines1// mod-manager / hooks/register.tsx, by v-amorim
2// Registers the mod manager pane, pill, command and toggle actions with the engine.
3import type { ConfigValue, EngineInterface, Register, RenderChildren } from 'claude-code'
4
5import { catalogOf, catalogState, discover, featuresOf, marketOf, refreshCatalog, SELF, setEnabled, type CatalogEntry, type Io, type ModInfo } from './registry'
6import { checkedOf, planOf, runSteps, type Step } from './plan'
7import { OPEN_SETUP_SCOPE, signatureOf, updatesCard, welcomeCard } from './cards'
8import { checkedRows, planRows, type PickMessage, type PickProps } from './setup'
9import { fill, MARK, statusColor, THEME } from './theme'
10import text from './text'
11import { isTipMessage, type EntryMessage, type EntryProps, type Tip } from './entry'
12import { labelsOf, menuEntry, pill, pillRow, splitRow, tabRow, tipLine } from './pills'
13import { TOAST_MS, ToastList, toastCards } from './toasts'
14import { cardFill, cardGap, detectNerd, FOOTER_ROWS, lookFor, paneFooter, section, sepText, titleEdge, type IconMode, type Reveal, withMark } from './look'
15import { actionReveals, choiceBar, editorReveals, hairline, settingChoice, settingHead, settingsBody, type Action } from './settings'
16import { cardSpace, HEAD_ROWS, stickyCard } from './card'
17import { costPill } from './cost'
18import { readSpend, resetSpend, spendLine, type Spend } from './spend'
19import { type ListMessage, type ListProps } from './list'
20
21const homeOf = async ($: EngineInterface) => ((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '').replace(/\\/g, '/')
22
23const configDir = async ($: EngineInterface) => ((await $.env.get('CLAUDE_CONFIG_DIR')) || `${await homeOf($)}/.claude`).replace(/\\/g, '/')
24
25// `$` cannot cross an import, so the registry gets plain functions instead.
26const ioOf = ($: EngineInterface): Io => ({
27 config: () => configDir($),
28 read: path => $.fs.read(path),
29 write: (path, text) => $.fs.write(path, text),
30 list: path => $.fs.list(path),
31 pluginDirs: async () => (await $.env.get('CLAUDE_CODE_PLUGIN_DIRS')) ?? '',
32 home: async () => (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '',
33 // The validator needs literal names, so the refresh opt-outs are listed here.
34 env: async name => {
35 const known: Record<string, string | undefined> = {
36 DISABLE_AUTOUPDATER: (await $.env.get('DISABLE_AUTOUPDATER')) ?? undefined,
37 DISABLE_UPDATES: (await $.env.get('DISABLE_UPDATES')) ?? undefined,
38 CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: (await $.env.get('CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC')) ?? undefined,
39 }
40 return known[name]
41 },
42 run: async (argv, timeoutMs) => {
43 const result = await $.process.run(argv, { timeoutMs })
44 return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }
45 },
46 store: { get: key => $.store.get(key), set: (key, value) => $.store.set(key, value as ConfigValue) },
47})
48
49// The icons setting (userConfig), fixed per activation: a change reloads the mod.
50let iconMode: IconMode = 'auto'
51let look = lookFor(false)
52
53// Whether the terminal's font is a Nerd Font, whatever the icons setting says.
54async function nerdFontFound($: EngineInterface) {
55 const env = {
56 WT_SESSION: await $.env.get('WT_SESSION'),
57 TERM_PROGRAM: await $.env.get('TERM_PROGRAM'),
58 ITERM_PROFILE: await $.env.get('ITERM_PROFILE'),
59 KITTY_WINDOW_ID: await $.env.get('KITTY_WINDOW_ID'),
60 ALACRITTY_WINDOW_ID: await $.env.get('ALACRITTY_WINDOW_ID'),
61 GNOME_TERMINAL_SCREEN: await $.env.get('GNOME_TERMINAL_SCREEN'),
62 KONSOLE_VERSION: await $.env.get('KONSOLE_VERSION'),
63 }
64 const host = {
65 localAppData: ((await $.env.get('LOCALAPPDATA')) ?? '').replace(/\\/g, '/'),
66 home: ((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '').replace(/\\/g, '/'),
67 read: (path: string) => $.fs.read(path).then(String),
68 run: async (argv: string[]) => {
69 const result = await $.process.run(argv, { timeoutMs: 5000 })
70 if (result.exitCode !== 0) throw new Error(result.stderr)
71 return result.stdout
72 },
73 }
74 return detectNerd(env, host)
75}
76
77// Nerd glyphs when the setting asks for them, or on auto when the terminal's font is a Nerd Font.
78async function detectLook($: EngineInterface) {
79 if (iconMode !== 'auto') return lookFor(iconMode === 'nerd')
80 return lookFor(await nerdFontFound($))
81}
82
83// Reading the terminal's settings takes a while, so on auto the last answer, kept in this mod's store, draws until it returns.
84async function savedLook($: EngineInterface) {
85 if (iconMode !== 'auto') return look
86 try {
87 const nerd = await $.store.get('look-nerd')
88 return typeof nerd === 'boolean' ? lookFor(nerd) : look
89 } catch {
90 return look
91 }
92}
93
94async function saveLook($: EngineInterface) {
95 try {
96 await $.store.set('look-nerd', look.nerd)
97 } catch {
98 // Without a store the mod still draws; it only starts from safe again next time.
99 }
100}
101
102// Hooks of one event run in turn, so the slow font check runs beside them instead of holding up every mod after this one.
103let lookLoad: Promise<void> | null = null
104
105function loadLook($: EngineInterface) {
106 lookLoad ??= (async () => {
107 look = await savedLook($)
108 void detectLook($).then(async found => {
109 const isChanged = found.nerd !== look.nerd
110 look = found
111 await saveLook($)
112 if (isChanged) $.ui.invalidate('ui.render')
113 }).catch(() => {
114 // A failed check keeps the icons drawn now.
115 })
116 })()
117 return lookLoad
118}
119
120let mods: ModInfo[] = []
121// Each mod with a cost, by name: its last days of side-model spend, or null; read again after a few seconds and after a Reset.
122let spends: Record<string, Spend | null> = {}
123let spendsAt = -Infinity
124const SPEND_FRESH_MS = 3000
125
126async function loadSpends($: EngineInterface) {
127 const now = await $.clock.now()
128 if (now - spendsAt < SPEND_FRESH_MS) return
129 spendsAt = now
130 const config = await configDir($)
131 const next: Record<string, Spend | null> = {}
132 for (const mod of mods) if (mod.showcase?.cost) next[mod.name] = await readSpend(path => $.fs.read(path), config, mod.name, now)
133 spends = next
134}
135
136// A failed write leaves the counts as they were.
137async function resetSpendOf($: EngineInterface, name: string) {
138 await resetSpend((path, body) => $.fs.write(path, body), await configDir($), name).catch(() => undefined)
139 spendsAt = -Infinity
140 $.ui.invalidate('ui.render')
141}
142// Mods toggled in the pane, by name, with the state Apply gives them; toggling back drops one.
143const pending = new Map<string, boolean>()
144// The mod whose page fills the pane, or -1 for the list.
145let page = -1
146// The mod under the pointer in the list, whose description shows in a card at the bottom of the pane.
147let hovering = -1
148// The row whose cut description keeps its card after the pointer left the row, so the pointer can reach the card's control.
149let heldCard = -1
150// The mod whose card shows every line; pointing at another row or control clears it.
151let fullCard = ''
152let note = ''
153// The page's Features card shows every line in full.
154let featuresOpen = false
155let working = false
156
157// The marketplace mod-manager came from and its listing; each mod's state is worked out from it and `mods` on every draw.
158let market = 'claude-mods'
159let catalogEntries: CatalogEntry[] = []
160let catalogLoaded = false
161// Catalog keys ticked for install, and update keys the person unticked: updates start ticked.
162const installs = new Set<string>()
163const skipped = new Set<string>()
164// What the Checked list reads, once per pane open: the `claude --version` output and whether the font is a Nerd Font.
165let ccVersion: string | null = null
166let fontFound: boolean | null = null
167
168const catalogRows = () => catalogState(catalogEntries, mods, market)
169
170const planNow = () => planOf({ pending, shown: shownName, catalog: catalogRows(), installs, skipped, iconMode, arrow: look.glyph.icon.arrow ?? '' })
171
172// The catalog is cheap to read; the version and the font take longer, so they fill in beside the first draw.
173async function loadSetup($: EngineInterface) {
174 const io = ioOf($)
175 market = await marketOf(io)
176 catalogEntries = await catalogOf(io, market).catch(() => [])
177 catalogLoaded = true
178 void (async () => {
179 ccVersion = await io.run?.(['claude', '--version'], 10000).then(result => (result.exitCode === 0 ? result.stdout : null)).catch(() => null) ?? null
180 fontFound = await nerdFontFound($).catch(() => false)
181 $.ui.invalidate('ui.render')
182 })()
183}
184
185const isOn = (mod: ModInfo) => pending.get(mod.name) ?? mod.enabled
186
187// A click only changes the plan; settings.json waits for Apply.
188function toggleMod($: EngineInterface, index: number) {
189 const mod = mods[index]
190 if (!mod) return
191 if (mod.name === SELF) {
192 note = `${MARK.warn} ${text.refuseSelf}`
193 } else {
194 note = ''
195 if (pending.has(mod.name)) pending.delete(mod.name)
196 else pending.set(mod.name, !mod.enabled)
197 }
198 $.ui.invalidate('ui.render')
199}
200
201// A second Apply while one runs would reload twice.
202let isApplying = false
203
204// Toggles write one at a time, as two read-edit-writes of settings.json would cross; the updates card passes only its update steps.
205async function applyChanges($: EngineInterface, only?: Step[]) {
206 if (isApplying) return
207 isApplying = true
208 const steps = only ?? planNow()
209 $.ui.invalidate('ui.render')
210 try {
211 const io = ioOf($)
212 // Only a folder new to the dirs list waits for a restart; every other toggle lands with /reload-plugins.
213 let needsRestart = false
214 for (const step of steps) {
215 const mod = step.kind === 'on' || step.kind === 'off' ? mods.find(each => each.name === step.name) : undefined
216 if (!mod) continue
217 const outcome = await setEnabled(io, mod, step.kind === 'on')
218 if (!outcome.ok) $.ui.toast(`${MARK.warn} ${fill(text.toggleFailed, { line: step.line, error: outcome.reason })}`)
219 else if (outcome.needsRestart) needsRestart = true
220 }
221 if (!only) pending.clear()
222 await runSteps(io, steps, (step, reason) => $.ui.toast(`${MARK.warn} ${fill(step.kind === 'install' ? text.installFailed : text.updateFailed, { name: shownName(step.name), error: reason })}`))
223 if (!only) {
224 installs.clear()
225 skipped.clear()
226 }
227 mods = await discover(io)
228 await settingsCaughtUp($).catch(() => {})
229 note = await $.command
230 .run({ command: 'reload-plugins' })
231 .then(() => `${MARK.ok} ${fill(text.applyDone, { n: steps.length })}${needsRestart ? `, ${text.howRestart}` : ''}`)
232 .catch(error => `${MARK.warn} ${fill(text.reloadFailed, { error: String(error) })}`)
233 } finally {
234 isApplying = false
235 // The Apply card goes once nothing is pending, so its tip goes with it.
236 paneTip = null
237 $.ui.invalidate('ui.render')
238 }
239}
240
241// Claude Code picks up a settings.json write a moment later; a reload before that applied the old toggles.
242async function settingsCaughtUp($: EngineInterface) {
243 const onDisk = JSON.stringify(JSON.parse(String(await $.fs.read(`${await configDir($)}/settings.json`))).enabledPlugins ?? {})
244 for (let tries = 0; tries < 30; tries++) {
245 const loaded = await $.settings.read({ source: 'user' })
246 if (JSON.stringify(loaded.enabledPlugins ?? {}) === onDisk) return
247 await $.clock.sleep(100)
248 }
249}
250
251// `mod-skill-panel` reads `Skill Panel`: the package name is for commands, people read this.
252const shownName = (name: string) => name.replace(/^mod-/, '').split('-').map(word => (word ? `${word[0]!.toUpperCase()}${word.slice(1)}` : word)).join(' ')
253const isOpen = async ($: EngineInterface) => (await $.ui.panes()).some(pane => pane.id === 'manager' && pane.isPlaced)
254
255// The command and the bar entry both toggle. An unplaced pane is waiting, not open.
256async function togglePane($: EngineInterface) {
257 if (await isOpen($)) {
258 await $.ui.close({ id: 'manager' })
259 await $.state.set({ plugin: 'mod-manager', key: 'pane' }, false)
260 return
261 }
262 mods = await discover(ioOf($))
263 page = -1
264 note = ''
265 pending.clear()
266 installs.clear()
267 skipped.clear()
268 await loadSetup($)
269 const opened = await $.ui.open({ id: 'manager', title: text.paneTitle, closeOnEscape: true })
270 if (!opened.isPlaced) $.ui.toast(text.panelWaiting)
271 await $.state.set({ plugin: 'mod-manager', key: 'pane' }, opened.isPlaced)
272}
273
274// Marketplace auto-update lives in /plugin, which a mod cannot set; this opens it.
275async function openPlugin($: EngineInterface) {
276 note = await $.command
277 .run({ command: 'plugin' })
278 .then(() => '')
279 .catch(error => `${MARK.warn} ${String(error)}`)
280 $.ui.invalidate('ui.render')
281}
282
283// What the band cards read, loaded beside the first draw: the band never waits on the catalog or the font.
284let bandLoad: Promise<void> | null = null
285let isBandReady = false
286// What the band last said about Claude working, since a click handler cannot read the band's props.
287let bandIsWorking = false
288
289function loadBand($: EngineInterface) {
290 bandLoad ??= (async () => {
291 const io = ioOf($)
292 const font = nerdFontFound($).catch(() => false)
293 if (!catalogLoaded) {
294 market = await marketOf(io)
295 catalogEntries = await catalogOf(io, market).catch(() => [])
296 catalogLoaded = true
297 }
298 if (mods.length === 0) mods = await discover(io)
299 isBandReady = true
300 fontFound ??= await font
301 $.ui.invalidate('ui.render')
302 })().catch(() => {})
303 return bandLoad
304}
305
306// A store read can throw; then neither card draws, since a card that cannot be dismissed would come back on every draw.
307async function readStore($: EngineInterface, key: string) {
308 try {
309 return { isReadable: true, value: await $.store.get(key) }
310 } catch {
311 return { isReadable: false, value: undefined }
312 }
313}
314
315async function writeStore($: EngineInterface, key: string, value: ConfigValue) {
316 try {
317 await $.store.set(key, value)
318 } catch {
319 // The card stays until the store works again.
320 }
321}
322
323const updatesNow = () => catalogRows().filter(cat => cat.canUpdate)
324
325// The Update button runs the Apply flow for the update lines only.
326async function updateFromCard($: EngineInterface) {
327 if (isApplying) return
328 if (working || bandIsWorking) {
329 $.ui.toast(text.cardUpdateBusy)
330 return
331 }
332 const steps = planOf({ pending: new Map(), shown: shownName, catalog: catalogRows(), installs: new Set(), skipped: new Set(), iconMode, arrow: look.glyph.icon.arrow ?? '' })
333 if (steps.length) await applyChanges($, steps)
334}
335
336// At most one card: the welcome while `welcomed` is unset, else the updates of a release the person has not put off.
337async function bandCard($: EngineInterface, elements: Parameters<typeof welcomeCard>[0], maxRows: number, isWorking: boolean) {
338 const welcomed = await readStore($, 'welcomed')
339 // The font check lands in a moment; drawing the card after it keeps the tip from growing the band under the prompt.
340 if (welcomed.isReadable && !welcomed.value) {
341 return fontFound === null ? null : welcomeCard(elements, look, { showFontTip: fontFound === false, still, maxRows, onOpen: () => cardClick($, 'card-welcome-open') })
342 }
343 if (!isBandReady) return null
344 const updates = updatesNow()
345 if (updates.length === 0) return null
346 const dismissed = await readStore($, 'updates-dismissed')
347 if (!dismissed.isReadable || dismissed.value === signatureOf(updates)) return null
348 return updatesCard(elements, look, { updates, shown: shownName, isWorking: isWorking || working, isUpdating: isApplying, still, maxRows })
349}
350
351async function cardClick($: EngineInterface, id: string) {
352 // The card may go with this click, and a tip left in the band row would name a control no longer there.
353 toastTip = ''
354 if (id === 'card-welcome-open' || id === 'card-welcome-skip') {
355 // The pane opens first: once the card is gone, the press behind it no longer counts as the person asking.
356 if (id === 'card-welcome-open') await openingPane($)
357 await writeStore($, 'welcomed', true)
358 $.ui.invalidate('ui.render')
359 }
360 if (id === 'card-update-skip') {
361 await writeStore($, 'updates-dismissed', signatureOf(updatesNow()))
362 $.ui.invalidate('ui.render')
363 }
364 if (id === 'card-update-run') await updateFromCard($)
365}
366
367const openPage = ($: EngineInterface, index: number) => {
368 page = index
369 note = ''
370 $.ui.invalidate('ui.render')
371}
372
373// The panel's title: the list as a tab with its count; on a mod's page a click on it goes back.
374const listTab = (isShown: boolean) => ({ id: 'manager-tab', label: text.paneTitle, icon: look.glyph.icon.mod ?? look.glyph.title, isOpen: isShown, count: mods.length, tip: { glyph: look.glyph.title, label: text.paneTitle, text: isShown ? text.tipTabList : text.tipTabBack } })
375
376// A saved change reloads that mod, which redraws its page with the new value.
377async function saveSetting($: EngineInterface, key: string, value: ConfigValue) {
378 const row = (await $.config.list()).find(each => each.key === key)
379 const result = await $.config.set({ key, value })
380 note = 'deny' in result && result.deny ? `${MARK.warn} ${result.deny}` : `${MARK.ok} ${fill(text.settingSaved, { label: row?.label ?? key })}`
381 $.ui.invalidate('ui.render')
382}
383
384// True while the pointer is on this mod's pill entry, which then shows its tip under the row.
385let tipShown = false
386
387// The control the pointer is on in the pane, shown in its footer.
388let paneTip: Tip | null = null
389// The toast × under the pointer, shown in the band's tip row.
390let toastTip = ''
391
392// A band redraw can replace a hovered entry, whose leave and quiet timer then never come, so a band tip ends on its own.
393const BAND_TIP_MS = 5000
394let bandTipAt = 0
395
396function expireBandTip($: EngineInterface) {
397 const at = ++bandTipAt
398 $.clock.after(BAND_TIP_MS, () => {
399 if (at !== bandTipAt || (!tipShown && !toastTip)) return
400 tipShown = false
401 toastTip = ''
402 $.ui.invalidate('ui.render')
403 })
404}
405
406const toasts = new ToastList('manager')
407// The animations setting, fixed per activation: off, every entry draws still.
408let still = false
409
410// The entry shimmers from the click until the pane draws, so a slow open never looks ignored.
411const OPEN_WAIT_MS = 8000
412let isOpening = false
413
414function endOpening($: EngineInterface) {
415 if (!isOpening) return
416 isOpening = false
417 $.ui.invalidate('ui.render')
418}
419
420async function openingPane($: EngineInterface) {
421 if (!(await isOpen($))) {
422 isOpening = true
423 $.ui.invalidate('ui.render')
424 $.clock.after(OPEN_WAIT_MS, () => endOpening($))
425 }
426 await togglePane($)
427 if (!(await isOpen($))) endOpening($)
428}
429
430// The icons switch: every mod keeps its own icons setting, and this writes the same value into each, as a person would in its panel.
431const ICON_MODES = ['auto', 'nerd', 'safe'] as const
432
433// The hover card's own rows, border included.
434const CARD_ROWS = 4
435const TEXT_ROWS = CARD_ROWS - 2
436const CARD_MORE = 'manager-card-more'
437
438// The rows a text takes at this width, wrapped at spaces, a word wider than the width breaking across rows.
439const wrapRows = (value: string, width: number) =>
440 value.split('\n').flatMap(line => {
441 const rows = ['']
442 let used = 0
443 for (const word of line.split(' ')) {
444 const chars = [...word]
445 if (used > 0 && used + 1 + chars.length <= width) {
446 rows[rows.length - 1] += ` ${word}`
447 used += 1 + chars.length
448 continue
449 }
450 if (used > 0) rows.push('')
451 rows[rows.length - 1] = chars.slice(0, width).join('')
452 used = Math.min(chars.length, width)
453 for (let at = width; at < chars.length; at += width) {
454 rows.push(chars.slice(at, at + width).join(''))
455 used = Math.min(chars.length - at, width)
456 }
457 }
458 return rows
459 })
460
461// A control glyph without the brackets and padding it carries as a Button label.
462const bare = (glyph: string) => glyph.replace(/[[\]\s]/g, '')
463
464// A mod follows the standard when its icons field offers exactly auto, nerd and safe.
465const followsStandard = (options: readonly string[] | undefined) =>
466 Array.isArray(options) && options.length === ICON_MODES.length && ICON_MODES.every(mode => options.includes(mode))
467
468const iconRowsOf = async ($: EngineInterface) =>
469 (await $.config.list()).filter(row => row.key.startsWith('mod-') && row.key.endsWith('.icons') && followsStandard(row.options))
470
471async function setAllIcons($: EngineInterface, mode: string) {
472 const rows = await iconRowsOf($)
473 // A saved change reloads that mod, this one included, so this mod's own setting goes last.
474 const ordered = [...rows.filter(row => !row.key.startsWith('mod-manager.')), ...rows.filter(row => row.key.startsWith('mod-manager.'))]
475 const changing = ordered.filter(row => row.value !== mode)
476 note = `${MARK.ok} ${fill(text.iconsSaved, { mode: (text.iconModes as Record<string, string>)[mode] ?? mode, n: changing.length })}`
477 $.ui.invalidate('ui.render')
478 for (const row of changing) {
479 const result = await $.config.set({ key: row.key, value: mode })
480 if ('deny' in result && result.deny) {
481 note = `${MARK.warn} ${result.deny}`
482 $.ui.invalidate('ui.render')
483 return
484 }
485 }
486}
487
488export const register: Register = (on, options) => {
489 still = options.animations === false
490 toasts.isStill = still
491 // This mod's toasts draw as cards in the band; Claude Code's own box is narrow and cuts them.
492 on('ui.toast', async ($, e) => {
493 const id = toasts.add(e.text)
494 $.ui.invalidate('ui.render')
495 $.clock.after(e.timeoutMs ?? TOAST_MS, () => {
496 toasts.drop(id)
497 $.ui.invalidate('ui.render')
498 })
499 return { value: undefined }
500 })
501
502 iconMode = (options.icons as IconMode | undefined) ?? 'auto'
503 // A forced mode needs no detection; auto loads the saved answer, then checks the terminal font beside the hooks.
504 look = lookFor(iconMode === 'nerd')
505
506 on('session.start', async ($, e, next) => {
507 await loadLook($)
508 await $.command.register({ name: 'mod-manager', description: text.commandDescription })
509 // The bar entry reads this; an unload drops the pane without raising ui.close here.
510 await $.state.set({ plugin: 'mod-manager', key: 'pane' }, await isOpen($))
511 // A hot reload leaves an open pane on its old drawing until something redraws it.
512 $.ui.invalidate('ui.render')
513 // Background and silent: a failure keeps the old catalog.
514 const io = ioOf($)
515 // A refreshed listing may hold new versions, so the cards read it again.
516 void marketOf(io)
517 .then(async name => {
518 await refreshCatalog(io, name)
519 catalogEntries = await catalogOf(io, name)
520 market = name
521 $.ui.invalidate('ui.render')
522 })
523 .catch(() => undefined)
524
525 return next(e)
526 })
527
528 // Subagent turns carry agentId and say nothing about the main prompt being free.
529 on('turn.start', async ($, e, next) => {
530 if (!(e as { agentId?: string }).agentId) {
531 working = true
532 $.ui.invalidate('ui.render')
533 }
534
535 return next(e)
536 })
537
538 on('turn.complete', async ($, e, next) => {
539 if (!e.agentId) {
540 working = false
541 $.ui.invalidate('ui.render')
542 }
543
544 return next(e)
545 })
546
547 on('command.run', { command: 'mod-manager' }, async $ => {
548 await togglePane($)
549
550 return { text: '' }
551 })
552
553 on('ui.close', async ($, e, next) => {
554 if (e.id === 'manager') await $.state.set({ plugin: 'mod-manager', key: 'pane' }, false)
555
556 return next(e)
557 })
558
559 // One panel at a time: another mod's pane opening closes this one.
560 on('state.set', async ($, e, next) => {
561 // Deferred past this dispatch, so this mod's own state write is not nested in another plugin's.
562 // The declared state names no other mod's pane, so the types narrow this event to never.
563 if (e.key === 'pane' && e.plugin !== 'mod-manager' && (e as { value?: unknown }).value === true) {
564 $.clock.after(0, async () => {
565 if (!(await isOpen($))) return
566 await $.ui.close({ id: 'manager' })
567 await $.state.set({ plugin: 'mod-manager', key: 'pane' }, false)
568 })
569 }
570
571 return next(e)
572 })
573
574 // The bar entry lives here, not in mod-menu: only a press in this mod counts as the person asking for this pane.
575 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
576 await loadLook($)
577 const hidden = await $.state.get({ plugin: 'mod-menu', key: 'hidden' })
578 const labels = labelsOf((await $.state.get({ plugin: 'mod-menu', key: 'labels' })).value)
579 if (e.surface !== 'terminal' || e.props.hasSurvey) return next(e)
580 const { Box, Button, Client, Link, Text } = $.ui.resolve(e)
581 void loadBand($)
582 bandIsWorking = e.props.isWorking
583 // A card that fails to draw must not take the pill row with it: one throw in this hook drops the whole band.
584 const card = await bandCard($, { Box, Button, Client, Link, Text }, e.props.maxRows, e.props.isWorking).catch(() => null)
585 // A Button cannot post a tip, so Open setup's tip lies hidden over the tip row and shows while its pill is lit; padded to cover what is under it.
586 const openTip = (card as { props?: { key?: unknown } } | null)?.props?.key === 'card-welcome-block' ? (
587 <Box key="card-open-tip" position="absolute" top={0} left={0} display="none" hover={{ scope: OPEN_SETUP_SCOPE, display: 'flex' }}>
588 {tipLine({ Box, Text }, `${text.cardOpenSetup}: ${text.tipCardOpen}`.padEnd(Math.max(0, e.props.bodyColumns - 2)))}
589 </Box>
590 ) : null
591 if (hidden.value === true) {
592 if (toasts.items.length === 0 && !card) return next(e)
593 return <Box key="toasts-manager" flexDirection="column">{toastCards({ Box, Client, Text }, toasts, look, text.tipToast)}{card}{await next(e)}</Box>
594 }
595 // Reading the value subscribes the bar to redraws; the look trusts the pane list itself.
596 await $.state.get({ plugin: 'mod-manager', key: 'pane' })
597 const shown = await isOpen($)
598 const { group, row, rest, tip } = splitRow(await next(e))
599 // Opening the pane from here queues nothing, so the entry stays live mid-turn.
600 const entry = menuEntry({ Box, Client, Text }, look.nerd, 'manager', { label: look.glyph.gear, isOpen: shown, idle: THEME.pill, isBusy: isOpening, still, labels })
601
602 // splitRow types the tip as unknown (shared pills.tsx); it is always a menu-tip child of the row stack.
603 const tipNow = (toastTip ? tipLine({ Box, Text }, toastTip) : tipShown ? tipLine({ Box, Text }, text.pillTip) : tip) as RenderChildren
604 // Keyed as the tip row, so the next mod's splitRow takes it as the tip and never as one more row.
605 const tipSlot = openTip ? <Box key="menu-tip" flexDirection="column">{tipNow ?? tipLine({ Box, Text }, ' ')}{openTip}</Box> : tipNow
606 return pillRow({ Box, Client, Text }, look.nerd, [entry, ...group], row, [...toastCards({ Box, Client, Text }, toasts, look, text.tipToast), ...(card ? [card] : []), ...rest], tipSlot, e.props.bodyColumns)
607 })
608
609 on('ui.message', async ($, e, next) => {
610 const clicked = e.data as EntryMessage
611 const dismissed = clicked?.type === 'menu-entry' ? toasts.idOf(clicked.id) : undefined
612 if (dismissed !== undefined) {
613 toasts.drop(dismissed)
614 $.ui.invalidate('ui.render')
615 return next(e)
616 }
617 if (isTipMessage(e.data)) {
618 const tip = e.data
619 // A band card's buttons and the toast × show their tip in the band's tip row.
620 const isToast = Boolean(tip.id?.startsWith('toast-manager-') || tip.id?.startsWith('card-'))
621 toastTip = isToast ? `${tip.label}: ${tip.text}` : ''
622 if (toastTip) expireBandTip($)
623 paneTip = tip.id && !isToast ? { glyph: tip.glyph ?? '', label: tip.label ?? '', text: tip.text ?? '' } : null
624 // The list's tip carries the row under the pointer, whose card shows above the list.
625 hovering = tip.id?.startsWith('manager-row-') ? Number(tip.id.slice('manager-row-'.length)) : -1
626 if (hovering >= 0 && mods[hovering]?.name !== fullCard) fullCard = ''
627 // Leaving a row for empty cells or for the card's own control keeps the held card; any other control drops it.
628 if (hovering >= 0 || (tip.id && tip.id !== CARD_MORE)) {
629 heldCard = -1
630 if (hovering < 0) fullCard = ''
631 }
632 $.ui.invalidate('ui.render')
633 return next(e)
634 }
635 const hover = e.data as { type?: string; id?: string; isOver?: boolean }
636 if (hover?.type === 'menu-hover' && hover.id === 'manager') {
637 tipShown = hover.isOver === true
638 if (tipShown) expireBandTip($)
639 $.ui.invalidate('ui.render')
640 }
641 const message = e.data as EntryMessage
642 if (message?.type === 'menu-entry' && message.id === 'manager') await openingPane($)
643 if (message?.type === 'menu-entry' && message.id === CARD_MORE) {
644 const shown = mods[hovering >= 0 ? hovering : heldCard]
645 if (shown) fullCard = fullCard === shown.name ? '' : shown.name
646 $.ui.invalidate('ui.render')
647 }
648 if (message?.type === 'menu-entry' && message.id === 'manager-features') {
649 featuresOpen = !featuresOpen
650 $.ui.invalidate('ui.render')
651 }
652 if (message?.type === 'menu-entry' && message.id.startsWith('spend-reset-')) await resetSpendOf($, message.id.slice('spend-reset-'.length))
653 if (message?.type === 'menu-entry' && message.id.startsWith('icons-')) await setAllIcons($, message.id.slice('icons-'.length))
654 if (message?.type === 'menu-entry' && message.id === 'manager-apply') await applyChanges($)
655 if (message?.type === 'menu-entry' && message.id.startsWith('card-')) await cardClick($, message.id)
656 if (message?.type === 'menu-entry' && message.id === 'manager-tab' && page >= 0) openPage($, -1)
657 const change = settingChoice(e.data)
658 if (change) await saveSetting($, change.key, change.value)
659
660 return next(e)
661 })
662
663 on('ui.message', { requestId: 'manager' }, async ($, e, next) => {
664 const message = e.data as ListMessage | PickMessage
665 // A click posts only its own message, so a toggle also says which row the pointer is on.
666 // While Apply runs, a toggle or a tick would change the plan under it.
667 if (message?.type === 'toggle' && !isApplying) {
668 hovering = message.index
669 toggleMod($, message.index)
670 }
671 if (message?.type === 'pick' && !isApplying) {
672 const set = message.kind === 'install' ? installs : skipped
673 if (!set.delete(message.key)) set.add(message.key)
674 $.ui.invalidate('ui.render')
675 }
676 if (message?.type === 'select') {
677 hovering = -1
678 heldCard = -1
679 fullCard = ''
680 paneTip = null
681 openPage($, message.index)
682 }
683
684 return next(e)
685 })
686
687 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
688 await loadLook($)
689 if (e.requestId !== 'manager') return next(e)
690 if (isOpening) $.clock.after(0, () => endOpening($))
691
692 // A reload resets module state while the pane stays open, so reload the list.
693 if (mods.length === 0) mods = await discover(ioOf($))
694 await loadSpends($).catch(() => undefined)
695
696 // @ts-expect-error The host's types omit Client from a Pane's elements, yet a Pane draws it.
697 const { Box, Button, Client, Link, Text } = $.ui.resolve(e)
698 const redraw = () => $.ui.invalidate('ui.render')
699 const frame = (children: RenderChildren, reveals: Reveal[] = []) => (
700 <Box flexDirection="column" backgroundColor={THEME.bg} paddingX={1} minHeight={e.props.scroll.bodyRows}>
701 {children}
702 <Box key="footer-space" height={FOOTER_ROWS} flexShrink={0} />
703 {paneFooter({ Box, Text }, paneTip, e.props.bodyColumns, e.props.scroll, reveals)}
704 </Box>
705 )
706
707 const mod = mods[page]
708 if (mod) {
709 // /config lists a plugin's settings as `<plugin>.<field>`, and only while the plugin is on.
710 const rows = mod.enabled ? (await $.config.list()).filter(row => row.key.startsWith(`${mod.name}.`)) : []
711 const actions: Action[] = mod.enabled ? (mod.showcase?.actions ?? []) : []
712 // showcase.json names the text settings the mod edits in its own panel, which its command opens; the manifest has no room for it.
713 const editors: Record<string, Action> = {}
714 for (const field of mod.showcase?.editors ?? []) editors[field] = { label: fill(text.openInSettings, { name: shownName(mod.name) }), command: mod.name, args: 'settings' }
715 const run = async (action: Action) => {
716 note = await $.command
717 .run({ command: action.command, args: action.args ?? '' })
718 .then(() => `${MARK.ok} ${fill(text.actionRan, { command: [action.command, action.args].filter(Boolean).join(' ') })}`)
719 .catch(error => `${MARK.warn} ${String(error)}`)
720 redraw()
721 }
722 const hint = rows.length || actions.length ? fill(text.pageHint, { sep: MARK.sep }) : text.pageHintBack
723 const listed = featuresOf(mod)
724 // The card's border and padding take two columns a side; the title, its gap and the separator come before the detail.
725 const room = e.props.bodyColumns - 4
726 const isCut = listed.some(item => item.title.length + 3 + item.detail.length > room)
727 // Closed, one cut row each; open, the name on its own row with its text two cells in, so every wrapped line hangs at one indent.
728 const titleWidth = Math.max(0, ...listed.map(item => item.title.length))
729 const features = listed.map((item, index) =>
730 featuresOpen ? (
731 <Box key={`feature-${index}`} flexDirection="column" marginTop={index === 0 ? 0 : 1}>
732 <Text key={`feature-${index}-name`} color={THEME.onSurface} bold>{item.title}</Text>
733 <Box key={`feature-${index}-detail`} paddingLeft={2}>
734 <Text color={THEME.muted} wrap="wrap">{item.detail}</Text>
735 </Box>
736 </Box>
737 ) : (
738 <Box key={`feature-${index}`} flexDirection="row" columnGap={1}>
739 <Box flexShrink={0}>
740 <Text color={THEME.onSurface} bold>{item.title.padEnd(titleWidth)}</Text>
741 </Box>
742 {sepText({ Text }, `${MARK.sep} ${item.detail}`, THEME.muted, `feature-${index}-detail`, { wrap: 'truncate-end' })}
743 </Box>
744 ),
745 )
746 // Arrow then muted label, at the right end of the last feature row.
747 const label = featuresOpen ? text.featuresLess : text.featuresAll
748 const arrow = featuresOpen ? look.glyph.less : look.glyph.more
749 const more = isCut ? (
750 <Client key="manager-features" module="./entry.tsx" height={1} props={{ id: 'manager-features', label, lead: arrow, leadColor: THEME.accent, isOpen: false, bg: cardFill(), idle: THEME.muted, nerd: look.nerd, still, tip: { glyph: arrow, label, text: featuresOpen ? text.tipFeaturesLess : text.tipFeaturesAll } } satisfies EntryProps} />
751 ) : null
752 const featureRows = more
753 ? [
754 ...features.slice(0, -1),
755 <Box key="features-last" flexDirection="row" alignItems="flex-end" columnGap={1}>
756 <Box key="features-last-text" flexGrow={1} flexShrink={1}>{features[features.length - 1]}</Box>
757 <Box key="features-more" flexShrink={0}>{more}</Box>
758 </Box>,
759 ]
760 : features
761
762 const spend = spends[mod.name]
763 const resetGlyph = look.glyph.cycle.replace(/[[\]\s]/g, '')
764 // The Spend card leads the settings cards, in the same round border.
765 const spendCard = mod.showcase?.cost
766 ? section(
767 { Box, Text },
768 look,
769 'page-spend',
770 'cost',
771 text.spendTitle,
772 <Text key="page-spend-text" color={THEME.muted} wrap="wrap">{spend ? spendLine(spend) : text.spendNone}</Text>,
773 THEME.accent,
774 {
775 right: spend
776 ? pill(
777 { Box, Text },
778 look.nerd,
779 THEME.surface3,
780 <Client key="page-spend-reset" module="./entry.tsx" height={1} props={{ id: `spend-reset-${mod.name}`, label: `${text.spendReset} ${resetGlyph}`, isOpen: false, bg: THEME.surface3, color: THEME.onSurface, idle: THEME.onSurface, still, tip: { glyph: resetGlyph, label: text.spendReset, text: text.tipSpendReset } } satisfies EntryProps} />,
781 'page-spend-pill',
782 THEME.bg,
783 )
784 : null,
785 },
786 )
787 : null
788 return frame([
789 <Box key="page-head" flexDirection="row" columnGap={1}>
790 {tabRow({ Box, Client, Text }, look.nerd, [listTab(false), { id: 'manager-page', label: shownName(mod.name), icon: look.glyph.icon.mod ?? look.glyph.title, isOpen: true, tip: { glyph: look.glyph.title, label: shownName(mod.name), text: text.tipTabPage } }], still)}
791 {sepText({ Text }, `${MARK.sep} ${mod.enabled ? text.stateOn : text.stateOff}`, mod.enabled ? THEME.ok : THEME.muted, 'page-state')}
792 </Box>,
793 sepText({ Text }, withMark(look, note) || hint, statusColor(note), 'page-note', { wrap: 'truncate-end' }),
794 section(
795 { Box, Text },
796 look,
797 'page-about',
798 'mod',
799 fill(text.pageTitle, { name: shownName(mod.name), sep: MARK.sep, version: mod.version }),
800 <Box key="page-about-body" flexDirection="column">
801 <Text color={THEME.muted}>{mod.description}</Text>
802 {mod.showcase?.tagline ? <Text color={THEME.secondary}>{mod.showcase.tagline}</Text> : null}
803 </Box>,
804 ),
805 mod.showcase?.cost
806 ? section(
807 { Box, Text },
808 look,
809 'page-cost',
810 'cost',
811 text.costTitle,
812 // A setting named in backticks stands out, so the reader knows what to look for.
813 <Text key="page-cost-text" color={THEME.muted} wrap="wrap">
814 {mod.showcase.cost.summary.split('`').map((part, n) => (n % 2 ? <Text key={`cost-part-${n}`} color={THEME.accent}>{part}</Text> : part))}
815 </Text>,
816 THEME.accent,
817 { right: costPill({ Box, Text }, look.nerd, look.glyph.icon.cost!, THEME.bg, 'page-cost-pill') },
818 )
819 : null,
820 spendCard,
821 mod.enabled || mod.settings === 0 ? (
822 settingsBody({ Box, Button, Client, Text }, look, { rows, actions, working, run, previews: mod.showcase?.previews, fields: mod.fields, editors, still })
823 ) : (
824 <Box key="page-off" flexDirection="column">
825 {cardGap({ Box, Text }, 'page-off')}
826 <Text color={THEME.muted}>{fill(text.turnOnFirst, { name: shownName(mod.name) })}</Text>
827 </Box>
828 ),
829 section({ Box, Text }, look, 'features', 'features', text.sectionFeatures, features.length ? featureRows : <Text color={THEME.muted}>{text.noFeatures}</Text>),
830 ], [...actionReveals(actions, look, working), ...editorReveals(rows, editors, look, working)])
831 }
832
833 if (!catalogLoaded) await loadSetup($)
834 const cats = catalogRows()
835 const steps = planNow()
836 const isBusy = working || isApplying
837 // The switch already says on or off, so the text beside the version is only for an update.
838 const updateOf = (item: ModInfo) => {
839 const update = cats.find(cat => cat.name === item.name && cat.canUpdate)
840 return update ? { update: fill(text.stateUpdate, { version: update.version }) } : {}
841 }
842
843 // One section per source, so a row needs no "yours" or "installed" label of its own.
844 const group = (source: ModInfo['source'], title: string, icon: string) => {
845 const members = mods.map((item, index) => ({ item, index })).filter(({ item }) => item.isOwn === (source === 'dir'))
846 if (members.length === 0) return null
847 return section(
848 { Box, Text },
849 look,
850 `list-${source}`,
851 icon,
852 title,
853 <Client
854 key={`list-${source}`}
855 module="./list.tsx"
856 height={members.length}
857 props={{
858 rows: members.map(({ item, index }) => ({
859 index,
860 name: shownName(item.name),
861 version: item.version,
862 enabled: isOn(item),
863 // A command named after its mod repeats the folder the hover card names, so only other commands show.
864 commands: item.commands.filter(name => name !== item.name),
865 hasSettings: item.settings > 0,
866 ...updateOf(item),
867 // A Client prop refuses undefined, so a mod without a cost leaves the key out.
868 // The footer tip is one row, so it says only that the mod costs; the page card holds the full summary.
869 ...(item.showcase?.cost ? { cost: spends[item.name] ? `${spendLine(spends[item.name]!)} ${MARK.sep} ${text.costTip}` : text.costTip } : {}),
870 })),
871 gear: look.glyph.gear,
872 still,
873 nameWidth: Math.max(...members.map(({ item }) => shownName(item.name).length)),
874 icon: look.glyph.icon[icon] ?? '',
875 nerd: look.nerd,
876 switchTip: { glyph: bare(look.glyph.on), label: '', text: text.tipSwitch },
877 openTip: text.tipOpen,
878 costGlyph: look.glyph.icon.cost!,
879 costLabel: text.costTitle,
880 } satisfies ListProps}
881 />,
882 )
883 }
884 const applyLabel = fill(text.applyAction, { n: steps.length })
885 const onCount = mods.filter(isOn).length
886
887 // The sticky card above the list keeps its space while nothing is hovered, so the list never jumps.
888 const shownAt = hovering >= 0 ? hovering : heldCard
889 const hovered = mods[shownAt]
890 // The card's text is the pane's width less its border and padding; a description past its rows gives the last one to the control.
891 const wrapped = hovered ? wrapRows(hovered.description || text.noDescription, Math.max(10, e.props.bodyColumns - 6)) : []
892 const isClipped = wrapped.length > TEXT_ROWS
893 // A cut description keeps its card once the pointer leaves the row, so the pointer can reach the control.
894 if (hovering >= 0) heldCard = isClipped ? hovering : -1
895 const isFull = isClipped && hovered !== undefined && fullCard === hovered.name
896 // Opened, the card grows to every row, as far as the pane has room for it.
897 const roomRows = Math.max(TEXT_ROWS, e.props.scroll.bodyRows - HEAD_ROWS - FOOTER_ROWS - 4)
898 const textShown = isFull ? Math.min(wrapped.length, roomRows) : TEXT_ROWS
899 const cardRows = CARD_ROWS + (textShown - TEXT_ROWS)
900 const shownRows = wrapped.slice(0, textShown)
901 const moreLabel = isFull ? text.showLess : text.showAll
902 const moreArrow = isFull ? look.glyph.less : look.glyph.more
903 const moreControl = (
904 <Client key={CARD_MORE} module="./entry.tsx" width={[...moreLabel].length + 4} height={1} props={{ id: CARD_MORE, label: moreLabel, lead: moreArrow, leadColor: THEME.accent, isOpen: false, bg: cardFill(), idle: THEME.muted, still, nerd: look.nerd, tip: { glyph: moreArrow, label: moreLabel, text: isFull ? text.tipShowLess : text.tipShowAll } } satisfies EntryProps} />
905 )
906 const hoverCard = () => {
907 return stickyCard(
908 { Box, Text },
909 e.props.scroll.offset,
910 e.props.bodyColumns,
911 cardRows,
912 hovered ? (
913 <Box key="hover-card" flexDirection="column">
914 <Box key="hover-card-text" height={shownRows.length} flexDirection="column" overflow="hidden" flexShrink={0}>
915 {shownRows.map((row, at) =>
916 isClipped && at === shownRows.length - 1 ? (
917 <Box key={`card-row-${at}`} flexDirection="row">
918 <Box key="card-row-text" flexShrink={1}><Text color={THEME.muted} wrap="truncate-end">{row || ' '}</Text></Box>
919 <Box key="card-row-gap" flexGrow={1} />
920 <Box key="card-row-more" flexShrink={0}>{moreControl}</Box>
921 </Box>
922 ) : (
923 <Text key={`card-row-${at}`} color={THEME.muted} wrap="truncate-end">{row || ' '}</Text>
924 ),
925 )}
926 </Box>
927 </Box>
928 ) : (
929 <Text key="hover-card-empty" color={THEME.muted}>{text.cardEmpty}</Text>
930 ),
931 THEME.accent,
932 hovered ? titleEdge({ Box, Text }, look, 'hover-card', '', fill(text.pageTitle, { name: shownName(hovered.name), sep: MARK.sep, version: hovered.version }), `${MARK.sep} ${hovered.name}`, 0) : null,
933 )
934 }
935
936 // One switch for every mod's icons; Mixed when they differ, and nothing at all when no mod has the setting.
937 const iconRows = await iconRowsOf($)
938 const iconValues = new Set(iconRows.map(row => String(row.value)))
939 const iconsNow = iconValues.size === 1 ? [...iconValues][0] : ''
940 const iconChoices = ICON_MODES.map(mode => ({
941 key: `icons-${mode}`,
942 isOn: iconsNow === mode,
943 props: { id: `icons-${mode}`, label: (text.iconModes as Record<string, string>)[mode]!, still, tip: { glyph: look.glyph.title, label: text.iconsLabel, text: fill(text.tipIcons, { mode: (text.iconModes as Record<string, string>)[mode]! }) } },
944 }))
945 // The card also carries the font line: shown once the check found no Nerd Font, with a link to get one.
946 const iconsCard =
947 iconRows.length || fontFound === false
948 ? section(
949 { Box, Text },
950 look,
951 'sec-icons',
952 'icons',
953 text.iconsLabel,
954 <Box key="icons-body" flexDirection="column">
955 {iconRows.length
956 ? settingHead({ Box, Text }, 'icons-switch-row', iconsNow ? text.iconsAll : `${text.iconsAll} ${MARK.sep} ${text.iconsMixed}`, choiceBar({ Box, Client, Text, Button }, look, 'icons', cardFill(), iconChoices))
957 : null}
958 {iconRows.length ? hairline({ Client }, 'icons-rule', cardFill()) : null}
959 {iconRows.length ? <Text color={THEME.muted}>{text.iconsDescription}</Text> : null}
960 {fontFound === false ? (
961 <Text key="nerd-line" color={THEME.muted}>
962 <Text color={THEME.warn}>{look.glyph.warn}</Text>
963 {sepText({ Text }, ` ${text.noNerdFont} ${MARK.sep} `, THEME.muted, 'nerd-line-text')}
964 <Link href={text.nerdFontsUrl} label={text.getOne} />
965 </Text>
966 ) : null}
967 </Box>,
968 )
969 : null
970
971 const notInstalled = cats.filter(cat => cat.canInstall)
972 const pickList = (kind: PickProps['kind'], rows: PickProps['rows'], tickedLabel: string, tip: string) => (
973 <Client
974 key={`list-${kind}`}
975 module="./setup.tsx"
976 height={rows.length}
977 props={{ kind, rows, nameWidth: Math.max(...rows.map(row => row.name.length)), on: tickedLabel, nerd: look.nerd, tipGlyph: bare(look.glyph.on), tip, still } satisfies PickProps}
978 />
979 )
980 const notInstalledCard = notInstalled.length
981 ? section(
982 { Box, Text },
983 look,
984 'sec-install',
985 'not-installed',
986 text.sectionNotInstalled,
987 pickList('install', notInstalled.map(cat => ({ key: cat.key, name: shownName(cat.name), ticked: installs.has(cat.key), detail: fill(text.pickDetail, { version: cat.version, sep: MARK.sep, description: cat.description }) })), text.tickAdd, text.tipPickInstall),
988 )
989 : null
990
991 // Updates start ticked; the card lists every mod behind the marketplace, and always says where auto-update lives.
992 const updatable = cats.filter(cat => cat.canUpdate)
993 const arrow = look.glyph.icon.arrow ?? ''
994 const pluginControl = isBusy ? (
995 <Text key="open-plugin" color={THEME.muted}>{`${look.glyph.go} ${text.openPlugin}`}</Text>
996 ) : (
997 // A pill around a plain Button, since opening /plugin needs the person's press behind it; the glyph rides in the label so the whole pill lights.
998 pill(
999 { Box, Text },
1000 look.nerd,
1001 THEME.surface3,
1002 <Button key="open-plugin" label={look.nerd ? `${bare(look.glyph.go)} ${text.openPlugin}` : ` ${bare(look.glyph.go)} ${text.openPlugin} `} plain hover={{ scope: 'open-plugin-pill', color: THEME.accent, inverse: true }} onPress={() => openPlugin($)} />,
1003 'open-plugin-pill',
1004 cardFill(),
1005 THEME.accent,
1006 )
1007 )
1008 const updatesCard = section(
1009 { Box, Text },
1010 look,
1011 'sec-updates',
1012 'updates',
1013 text.sectionUpdates,
1014 <Box key="updates-body" flexDirection="column">
1015 {updatable.length ? (
1016 pickList('update', updatable.map(cat => ({ key: cat.key, name: shownName(cat.name), ticked: !skipped.has(cat.key), detail: fill(text.pickUpdateDetail, { from: cat.installedVersion, to: cat.version, arrow }) })), text.tickUpdate, text.tipPickUpdate)
1017 ) : (
1018 <Text key="updates-none" color={THEME.muted}>{catalogEntries.length ? text.updatesNone : text.noCatalog}</Text>
1019 )}
1020 <Box key="updates-auto" flexDirection="column" marginTop={updatable.length ? 1 : 0}>
1021 <Text color={THEME.muted}>{text.autoUpdateOff}</Text>
1022 <Box key="updates-auto-row" flexDirection="row" columnGap={1}>
1023 {/* The hint wraps in a narrow pane; the pill never does. */}
1024 <Box key="open-plugin-cell" flexShrink={0} alignSelf="flex-start">{pluginControl}</Box>
1025 <Text color={THEME.muted} wrap="wrap">{fill(text.pluginHint, { market })}</Text>
1026 </Box>
1027 {/* Its own line, so the busy note never reads as part of the Open /plugin instruction. */}
1028 {isBusy ? <Text key="open-plugin-busy" color={THEME.muted}>{fill(text.pluginBusy, { label: text.openPlugin })}</Text> : null}
1029 </Box>
1030 </Box>,
1031 )
1032
1033 // Only while something is pending: what was checked, every line Apply runs, and the Apply control.
1034 const checks = checkedOf({ version: ccVersion, market, hasCatalog: catalogEntries.length > 0, fontFound })
1035 const goLead = { subLead: bare(look.glyph.go), subLeadColor: THEME.accent }
1036 const spinning = isApplying ? (
1037 <Box key="apply-running" flexDirection="row">
1038 <Client key="manager-applying" module="./entry.tsx" height={1} props={{ id: 'manager-applying', label: fill(text.applyRunning, { n: steps.length }), isOpen: false, bg: cardFill(), nerd: look.nerd, still, spinner: true, tight: true }} />
1039 {sepText({ Text }, ` ${fill(text.applyRunningNote, { sep: MARK.sep })}`, THEME.muted, 'apply-running-note')}
1040 </Box>
1041 ) : null
1042 const applyCard =
1043 steps.length || isApplying
1044 ? [
1045 section(
1046 { Box, Text },
1047 look,
1048 'sec-apply',
1049 'apply',
1050 text.checkedTitle,
1051 <Box key="apply-body" flexDirection="column">
1052 {checkedRows({ Box, Text }, { ok: text.tagOk, warn: text.tagWarn }, checks)}
1053 </Box>,
1054 ),
1055 section(
1056 { Box, Text },
1057 look,
1058 'sec-about',
1059 'apply',
1060 text.aboutTitle,
1061 <Box key="about-body" flexDirection="column">
1062 <Box key="apply-lines" flexDirection="column">
1063 {planRows({ Box, Text }, steps)}
1064 </Box>
1065 <Box key="list-apply" flexDirection="row" marginTop={1}>
1066 {/* A reload while Claude works would only queue as a prompt, and Apply twice would reload twice. */}
1067 {isApplying ? (
1068 spinning
1069 ) : working ? (
1070 sepText({ Text }, fill(text.applyBusy, { label: applyLabel, sep: MARK.sep }), THEME.muted, 'apply-busy')
1071 ) : (
1072 [
1073 <Box key="apply-gap" flexGrow={1} />,
1074 pill({ Box, Text }, look.nerd, THEME.surface3, <Client key="manager-apply" module="./entry.tsx" height={1} props={{ id: 'manager-apply', label: applyLabel, isOpen: true, bg: THEME.surface3, nerd: look.nerd, still, ...goLead, tip: { glyph: bare(look.glyph.cycle), label: applyLabel, text: text.tipApply } }} />, 'manager-apply-pill', cardFill()),
1075 ]
1076 )}
1077 </Box>
1078 </Box>,
1079 ),
1080 ]
1081 : null
1082
1083 return frame([
1084 <Box key="list-head" flexDirection="row" columnGap={1}>
1085 {tabRow({ Box, Client, Text }, look.nerd, [listTab(true)], still)}
1086 <Text key="on-count" color={THEME.muted}>{fill(text.onCount, { on: onCount })}</Text>
1087 </Box>,
1088 sepText({ Text }, withMark(look, note) || fill(text.listHint, { sep: MARK.sep }), statusColor(note), 'list-note', { wrap: 'truncate-end' }),
1089 cardSpace({ Box }, cardRows),
1090 mods.length === 0 ? <Text key="list-empty" color={THEME.muted}>{text.noMods}</Text> : null,
1091 group('dir', text.sectionYours, 'mod'),
1092 group('installed', text.sectionInstalled, 'installed'),
1093 notInstalledCard,
1094 iconsCard,
1095 updatesCard,
1096 applyCard ? <Box key="apply-cards" flexDirection="column">{applyCard}</Box> : null,
1097 hoverCard(),
1098 ], isBusy ? [] : [{ scope: 'open-plugin-pill', tip: { glyph: bare(look.glyph.go), label: text.openPlugin, text: text.tipOpenPlugin } }])
1099 })
1100}
1101hooks/registry.ts 366 lines1// mod-manager / hooks/registry.ts, by v-amorim
2// Discovers installed mods, reads their features and writes enabled state to settings.
3import type { Previews, Range } from './settings'
4import { fill } from './theme'
5import shown from './text'
6
7export type Io = {
8 read: (path: string) => Promise<string>
9 write: (path: string, text: string) => Promise<void>
10 list: (path: string) => Promise<{ name: string; kind: string }[]>
11 home: () => Promise<string>
12 // CLAUDE_CODE_PLUGIN_DIRS from the process environment, beside the one in settings.json.
13 pluginDirs?: () => Promise<string>
14 // CLAUDE_CONFIG_DIR when set, else ~/.claude.
15 config: () => Promise<string>
16 // A process environment variable (the refresh opt-outs).
17 env?: (name: string) => Promise<string | undefined>
18 // Runs the claude CLI.
19 run?: (argv: string[], timeoutMs: number) => Promise<{ exitCode: number; stdout: string; stderr: string }>
20 // This mod's store, for the refresh timestamp.
21 store?: { get: (key: string) => Promise<unknown>; set: (key: string, value: unknown) => Promise<void> }
22 now?: () => number
23}
24
25export type Feature = { title: string; detail: string }
26export type ShowcaseAction = { label: string; command: string; args?: string }
27export type Showcase = { tagline?: string; features?: Feature[]; actions?: ShowcaseAction[]; editors?: string[]; previews?: Previews; cost?: { summary: string } }
28
29export type ModInfo = {
30 id: string
31 name: string
32 description: string
33 version: string
34 root: string
35 source: 'dir' | 'installed'
36 // Lives in ~/.claude/mods: listed as the person's own even when installed from their local marketplace.
37 isOwn: boolean
38 enabled: boolean
39 events: string[]
40 commands: string[]
41 showcase: Showcase | null
42 // How many settings its manifest declares (userConfig); /config lists them only while it is on.
43 settings: number
44 // Its userConfig fields, for the min and max a number setting declares.
45 fields: Record<string, Range>
46}
47
48export type Outcome = { ok: true; needsRestart?: boolean } | { ok: false; reason: string }
49
50export const SELF = 'mod-manager'
51
52const BLURBS: Record<string, string> = shown.blurbs
53
54type Json = Record<string, any>
55
56const readJson = async (io: Io, path: string): Promise<Json | null> => {
57 try {
58 return JSON.parse(String(await io.read(path))) as Json
59 } catch {
60 return null
61 }
62}
63
64const slashes = (path: string) => path.replace(/\\/g, '/')
65
66const homeDir = async (io: Io) => slashes(await io.home())
67
68const expand = (path: string, home: string) => slashes(path.startsWith('~') ? home + path.slice(1) : path)
69
70const tildeForm = (path: string, home: string) => (path.startsWith(home) ? '~' + path.slice(home.length) : path)
71
72const separatorFor = (home: string) => (/^[A-Za-z]:/.test(home) ? ';' : ':')
73
74const splitDirs = (value: string, separator: string) =>
75 value
76 .split(separator)
77 .map(entry => entry.trim())
78 .filter(Boolean)
79
80const scan = (source: string) => {
81 const events = new Set<string>()
82 for (const found of source.matchAll(/\bon\(\s*['"]([\w.*]+)['"](?:\s*,\s*\{\s*component:\s*['"](\w+)['"])?/g)) {
83 events.add(found[2] ? `${found[1]}:${found[2]}` : found[1]!)
84 }
85 const commands = [...source.matchAll(/command\.register\(\{\s*name:\s*['"]([\w-]+)['"]/g)].map(found => found[1]!)
86 return { events: [...events], commands }
87}
88
89// A cost's longer readme is for the mod's own page elsewhere; only its one-sentence summary is kept.
90const showcaseOf = (json: Json | null) => {
91 if (!json) return null
92 const { cost, ...rest } = json
93 return (typeof cost?.summary === 'string' ? { ...rest, cost: { summary: cost.summary } } : rest) as Showcase
94}
95
96const inspect = async (
97 io: Io,
98 root: string,
99 source: ModInfo['source'],
100 id: string,
101 enabled: boolean,
102): Promise<ModInfo | null> => {
103 const hooks = await readJson(io, `${root}/hooks/hooks.json`)
104 const modules: unknown = hooks?.modules
105 if (!Array.isArray(modules)) return null
106 const manifest = (await readJson(io, `${root}/.claude-plugin/plugin.json`)) ?? {}
107 let text = ''
108 for (const module of modules) {
109 text += await io.read(`${root}/hooks/${String(module).replace(/^\.\//, '')}`).catch(() => '')
110 }
111 return {
112 isOwn: false,
113 id,
114 name: String(manifest.name ?? root.split('/').pop()),
115 description: String(manifest.description ?? ''),
116 version: String(manifest.version ?? ''),
117 root,
118 source,
119 enabled,
120 ...scan(text),
121 showcase: showcaseOf(await readJson(io, `${root}/showcase.json`)),
122 settings: Object.keys(manifest.userConfig ?? {}).length,
123 fields: manifest.userConfig ?? {},
124 }
125}
126
127export async function discover(io: Io): Promise<ModInfo[]> {
128 const home = await homeDir(io)
129 const claudeDir = slashes(await io.config())
130 const settings = (await readJson(io, `${claudeDir}/settings.json`)) ?? {}
131 const separator = separatorFor(home)
132 const fromProcess = splitDirs(String((await io.pluginDirs?.().catch(() => '')) ?? ''), separator)
133 const fromSettings = splitDirs(String(settings.env?.CLAUDE_CODE_PLUGIN_DIRS ?? ''), separator)
134 const active = [...new Set([...fromSettings, ...fromProcess].map(entry => expand(entry, home)))]
135
136 const roots = new Set(active)
137 for (const entry of await io.list(`${claudeDir}/mods`).catch(() => [])) {
138 if (entry.kind === 'dir') roots.add(`${claudeDir}/mods/${entry.name}`)
139 }
140
141 // A folder installed from the local marketplace loads in place: its installed entry is the one that loads and toggles.
142 const installed = await readJson(io, `${claudeDir}/plugins/installed_plugins.json`)
143 const entries = Object.entries<any[]>(installed?.plugins ?? {}).map(([id, list]) => ({ id, entry: Array.isArray(list) ? (list.find(candidate => candidate?.scope === 'user') ?? list[0]) : undefined }))
144 const same = (a: string, b: string) => slashes(a).replace(/\/+$/, '').toLowerCase() === slashes(b).replace(/\/+$/, '').toLowerCase()
145 const isOwnPath = (path: string) => slashes(path).toLowerCase().startsWith(`${claudeDir}/mods/`.toLowerCase())
146
147 // A local marketplace over ~/.claude/mods loads its plugins in place, though the install record names a cache path.
148 const markets = (await readJson(io, `${claudeDir}/plugins/known_marketplaces.json`)) ?? {}
149 const ownMarkets = new Set(
150 Object.entries<any>(markets)
151 .filter(([, market]) => [market?.source?.path, market?.installLocation].some(path => typeof path === 'string' && same(path, `${claudeDir}/mods`)))
152 .map(([name]) => name),
153 )
154 const folderOf = (id: string) => {
155 const [name, market] = id.split('@')
156 return market && ownMarkets.has(market) ? `${claudeDir}/mods/${name}` : null
157 }
158 const installedFolders = entries.map(({ id }) => folderOf(id)).filter((folder): folder is string => folder !== null)
159
160 const mods: ModInfo[] = []
161 for (const root of roots) {
162 if (!active.includes(root) && installedFolders.some(folder => same(folder, root))) continue
163 if (!active.includes(root) && entries.some(({ entry }) => entry?.installPath && same(entry.installPath, root))) continue
164 const mod = await inspect(io, root, 'dir', `dir:${root}`, active.includes(root))
165 // A listed dir loads as `<name>@inline`, which enabledPlugins can switch off without unlisting it.
166 if (mod) mods.push({ ...mod, isOwn: true, enabled: mod.enabled && settings.enabledPlugins?.[inlineId(mod)] !== false })
167 }
168
169 for (const { id, entry } of entries) {
170 if (!entry?.installPath) continue
171 const folder = folderOf(id)
172 // A switched-off install of a folder that already loads as a listed dir would only repeat that row.
173 if (folder && settings.enabledPlugins?.[id] !== true && active.some(root => same(root, folder))) continue
174 const mod = await inspect(io, folder ?? slashes(entry.installPath), 'installed', id, settings.enabledPlugins?.[id] === true)
175 if (mod) mods.push({ ...mod, isOwn: folder !== null || isOwnPath(entry.installPath) })
176 }
177 return mods.sort((a, b) => a.name.localeCompare(b.name))
178}
179
180export const featuresOf = (mod: ModInfo): Feature[] =>
181 mod.showcase?.features ??
182 mod.events.map(event => ({
183 title: event,
184 detail: BLURBS[event.split(':')[0]!] ?? fill(shown.reactsTo, { event }),
185 }))
186
187const refuse = (reason: string): Outcome => ({ ok: false, reason })
188
189const inlineId = (mod: ModInfo) => `${mod.name}@inline`
190
191const untouched = (before: Json, after: Json, edited: string[]) =>
192 Object.keys(before).every(key => edited.includes(key) || JSON.stringify(before[key]) === JSON.stringify(after[key])) &&
193 Object.keys(after).every(key => edited.includes(key) || key in before)
194
195export async function setEnabled(io: Io, mod: ModInfo, enabled: boolean): Promise<Outcome> {
196 if (mod.name === SELF) return refuse(shown.refuseSelf)
197
198 const home = await homeDir(io)
199 const path = `${slashes(await io.config())}/settings.json`
200 let text: string
201 try {
202 text = String(await io.read(path))
203 } catch {
204 return refuse(shown.refuseUnreadable)
205 }
206 let before: Json
207 try {
208 before = JSON.parse(text)
209 } catch {
210 return refuse(shown.refuseInvalid)
211 }
212
213 const after: Json = JSON.parse(text)
214 const edited = ['enabledPlugins']
215 let needsRestart = false
216 if (mod.source === 'installed') {
217 after.enabledPlugins = { ...after.enabledPlugins, [mod.id]: enabled }
218 } else {
219 // /reload-plugins applies `<name>@inline`, but reads the dirs list only at startup: a dir stays listed and only a new one forces a restart.
220 after.enabledPlugins = { ...after.enabledPlugins, [inlineId(mod)]: enabled }
221 const separator = separatorFor(home)
222 const entries = splitDirs(String(after.env?.CLAUDE_CODE_PLUGIN_DIRS ?? ''), separator)
223 if (enabled && !entries.some(entry => expand(entry, home) === mod.root)) {
224 after.env = { ...after.env, CLAUDE_CODE_PLUGIN_DIRS: [...entries, tildeForm(mod.root, home)].join(separator) }
225 edited.push('env')
226 needsRestart = true
227 }
228 }
229
230 const indent = /^\{\r?\n([ \t]+)"/.exec(text)?.[1] ?? ' '
231 const out = JSON.stringify(after, null, indent) + '\n'
232 if (!untouched(before, JSON.parse(out), edited)) return refuse(shown.refuseOtherSettings)
233
234 // The first backup is the one worth keeping: later writes never replace it.
235 const backup = `${path}.mod-manager.bak`
236 const hasBackup = await io.read(backup).then(() => true, () => false)
237 if (!hasBackup) await io.write(backup, text)
238 await io.write(path, out)
239 return mod.source === 'dir' ? { ok: true, needsRestart } : { ok: true }
240}
241
242export const DEFAULT_MARKET = 'claude-mods'
243export const REFRESH_KEY = 'catalog-refreshed'
244export const REFRESH_MS = 24 * 60 * 60 * 1000
245export const RETRY_MS = 60 * 60 * 1000
246export const REFRESH_OPT_OUTS = ['DISABLE_AUTOUPDATER', 'DISABLE_UPDATES', 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC']
247
248// The marketplace mod-manager itself was installed from, else the default.
249export async function marketOf(io: Io): Promise<string> {
250 const claudeDir = slashes(await io.config())
251 const installed = await readJson(io, `${claudeDir}/plugins/installed_plugins.json`)
252 const id = Object.keys(installed?.plugins ?? {}).find(key => key.startsWith(`${SELF}@`))
253 return id ? id.slice(SELF.length + 1) : DEFAULT_MARKET
254}
255
256export type CatalogEntry = { name: string; description: string; version: string; declaresIcons: boolean }
257
258// The marketplace's plugin list, each with the version and icons support its own plugin.json declares.
259export async function catalogOf(io: Io, market: string): Promise<CatalogEntry[]> {
260 const claudeDir = slashes(await io.config())
261 const known = await readJson(io, `${claudeDir}/plugins/known_marketplaces.json`)
262 const location = known?.[market]?.installLocation
263 if (typeof location !== 'string') return []
264 const base = slashes(location).replace(/\/+$/, '')
265 const listing = await readJson(io, `${base}/.claude-plugin/marketplace.json`)
266 if (!Array.isArray(listing?.plugins)) return []
267 const entries: CatalogEntry[] = []
268 for (const plugin of listing.plugins) {
269 if (typeof plugin?.name !== 'string') continue
270 const source = typeof plugin.source === 'string' ? plugin.source : typeof plugin.source?.path === 'string' ? plugin.source.path : plugin.name
271 const manifest = (await readJson(io, `${base}/${source.replace(/^\.\//, '')}/.claude-plugin/plugin.json`)) ?? {}
272 entries.push({
273 name: plugin.name,
274 description: String(plugin.description ?? manifest.description ?? ''),
275 version: String(manifest.version ?? ''),
276 declaresIcons: Boolean(manifest.userConfig?.icons),
277 })
278 }
279 return entries
280}
281
282const parts = (version: string) => {
283 const found = /^v?(\d+)\.(\d+)\.(\d+)/.exec(version.trim())
284 return found ? [Number(found[1]), Number(found[2]), Number(found[3])] : null
285}
286
287// True when `latest` is higher than `current` on major.minor.patch; an unreadable version never is.
288export function isNewer(latest: string, current: string): boolean {
289 const a = parts(latest)
290 const b = parts(current)
291 if (!a || !b) return false
292 for (let at = 0; at < 3; at++) if (a[at] !== b[at]) return a[at]! > b[at]!
293 return false
294}
295
296export type CatalogState = 'on' | 'off' | 'not-installed' | 'update'
297// Where the running copy comes from: this marketplace, a folder, or another marketplace.
298export type CopyOrigin = 'marketplace' | 'dir' | 'other'
299
300export type CatalogMod = {
301 // Own id space, never an index into `mods`.
302 key: string
303 name: string
304 description: string
305 version: string
306 declaresIcons: boolean
307 state: CatalogState
308 origin: CopyOrigin | null
309 // A copy is enabled, whatever the state says (an update can be on or off).
310 enabled: boolean
311 installedVersion: string
312 // The loaded copy's id, for setEnabled.
313 modId: string | null
314 // A folder copy is never offered either: it would load twice.
315 canInstall: boolean
316 canUpdate: boolean
317 // `<name>@<market>`, for the CLI.
318 target: string
319}
320
321export function catalogState(catalog: CatalogEntry[], mods: ModInfo[], market: string): CatalogMod[] {
322 return catalog.map(entry => {
323 const copies = mods.filter(mod => mod.name === entry.name)
324 const originOf = (mod: ModInfo): CopyOrigin => (mod.source === 'dir' ? 'dir' : mod.id.endsWith(`@${market}`) ? 'marketplace' : 'other')
325 const running = copies.find(mod => mod.enabled) ?? copies[0] ?? null
326 const hasFolder = copies.some(mod => originOf(mod) === 'dir')
327 const fromMarket = copies.find(mod => originOf(mod) === 'marketplace')
328 const behind = !!fromMarket && !hasFolder && isNewer(entry.version, fromMarket.version)
329 const state: CatalogState = !running ? 'not-installed' : behind ? 'update' : running.enabled ? 'on' : 'off'
330 return {
331 key: `cat:${entry.name}`,
332 name: entry.name,
333 description: entry.description,
334 version: entry.version,
335 declaresIcons: entry.declaresIcons,
336 state,
337 origin: running ? originOf(running) : null,
338 enabled: !!running?.enabled,
339 installedVersion: fromMarket?.version ?? running?.version ?? '',
340 modId: running?.id ?? null,
341 canInstall: copies.length === 0,
342 canUpdate: behind,
343 target: `${entry.name}@${market}`,
344 }
345 })
346}
347
348export type Refresh = 'ran' | 'throttled' | 'disabled' | 'failed'
349
350// Pulls the marketplace at most once a day, never when an opt-out env var is set. A failure is silent, keeps the old catalog and tries again an hour later.
351export async function refreshCatalog(io: Io, market: string): Promise<Refresh> {
352 if (!io.run) return 'failed'
353 for (const name of REFRESH_OPT_OUTS) {
354 const value = (await io.env?.(name).catch(() => undefined)) ?? ''
355 if (value !== '' && !/^(0|false)$/i.test(value)) return 'disabled'
356 }
357 const now = (io.now ?? Date.now)()
358 const last = await io.store?.get(REFRESH_KEY).catch(() => undefined)
359 if (typeof last === 'number' && now - last < REFRESH_MS) return 'throttled'
360 // Stamped before the run, so two sessions starting together pull once; a failure moves the stamp back to allow the retry.
361 await io.store?.set(REFRESH_KEY, now).catch(() => undefined)
362 const outcome = await io.run(['claude', 'plugin', 'marketplace', 'update', market], 60000).then(result => (result.exitCode === 0 ? 'ran' : 'failed'), () => 'failed' as const)
363 if (outcome === 'failed') await io.store?.set(REFRESH_KEY, now - REFRESH_MS + RETRY_MS).catch(() => undefined)
364 return outcome
365}
366hooks/plan.ts 84 lines1// mod-manager / hooks/plan.ts, by v-amorim
2// Works out what Apply will do and what was checked, and runs the claude CLI steps one by one.
3import type { CatalogMod, Io } from './registry'
4import text from './text'
5import { fill } from './theme'
6
7// kind on and off are settings.json toggles Apply writes first; install and update carry the CLI call.
8export type Step = { kind: 'on' | 'off' | 'install' | 'update'; name: string; line: string; argv?: string[] }
9
10export type PlanInput = {
11 // Mods toggled in the pane and not yet applied, by name, with the state they should get.
12 pending: ReadonlyMap<string, boolean>
13 shown: (name: string) => string
14 catalog: CatalogMod[]
15 // Catalog keys ticked for install, and update keys the person unticked (updates start ticked).
16 installs: ReadonlySet<string>
17 skipped: ReadonlySet<string>
18 // This mod's own icons setting: a forced mode rides along with an install of a mod that declares icons.
19 iconMode: string
20 arrow: string
21}
22
23// Each CLI call may take a while: it downloads the plugin.
24export const CLI_MS = 120000
25
26// Toggles first, then installs, then updates: the order Apply runs them in.
27export function planOf({ pending, shown, catalog, installs, skipped, iconMode, arrow }: PlanInput): Step[] {
28 const toggles = [...pending].map(([name, isOn]): Step => ({ kind: isOn ? 'on' : 'off', name, line: fill(isOn ? text.planOn : text.planOff, { name: shown(name) }) }))
29 const added = catalog
30 .filter(mod => mod.canInstall && installs.has(mod.key))
31 .map((mod): Step => {
32 const withIcons = iconMode !== 'auto' && mod.declaresIcons
33 return {
34 kind: 'install',
35 name: mod.name,
36 line: fill(withIcons ? text.planInstallIcons : text.planInstall, { name: shown(mod.name), version: mod.version, mode: iconMode }),
37 argv: ['claude', 'plugin', 'install', mod.target, '--scope', 'user', ...(withIcons ? ['--config', `icons=${iconMode}`] : [])],
38 }
39 })
40 const updated = catalog
41 .filter(mod => mod.canUpdate && !skipped.has(mod.key))
42 .map((mod): Step => ({ kind: 'update', name: mod.name, line: fill(text.planUpdate, { name: shown(mod.name), from: mod.installedVersion, to: mod.version, arrow }), argv: ['claude', 'plugin', 'update', mod.target] }))
43 return [...toggles, ...added, ...updated]
44}
45
46export type Check = { ok: boolean; line: string }
47
48export type CheckedInput = { version: string | null; market: string; hasCatalog: boolean; fontFound: boolean | null }
49
50// What was detected: the Claude Code version, the marketplace and the font. A miss is not an error, only a heads-up.
51export function checkedOf({ version, market, hasCatalog, fontFound }: CheckedInput): Check[] {
52 const number = version ? /\d+\.\d+\.\d+\S*/.exec(version)?.[0] : undefined
53 return [
54 number ? { ok: true, line: fill(text.checkVersion, { version: number }) } : { ok: false, line: text.checkVersionUnknown },
55 hasCatalog ? { ok: true, line: fill(text.checkMarket, { market }) } : { ok: false, line: fill(text.checkMarketMissing, { market }) },
56 fontFound ? { ok: true, line: text.checkFont } : { ok: false, line: text.checkNoFont },
57 ]
58}
59
60const firstLine = (output: string) => output.trim().split('\n').find(Boolean)?.trim() ?? ''
61
62// A spawn that fails because `claude` is missing reads as an OS error, which tells the person nothing.
63const isMissingCli = (message: string) => /ENOENT|not found|not recognized|cannot find|no such file/i.test(message)
64
65// Runs each CLI step in turn; a failure goes to onFail and the rest still run. Returns how many failed.
66export async function runSteps(io: Io, steps: Step[], onFail: (step: Step, reason: string) => void): Promise<number> {
67 let failed = 0
68 for (const step of steps) {
69 if (!step.argv) continue
70 try {
71 if (!io.run) throw new Error('cannot run claude')
72 const result = await io.run(step.argv, CLI_MS).catch(error => {
73 const message = error instanceof Error ? error.message : String(error)
74 throw new Error(isMissingCli(message) ? text.cliMissing : message)
75 })
76 if (result.exitCode !== 0) throw new Error(firstLine(result.stderr) || firstLine(result.stdout) || `exit ${result.exitCode}`)
77 } catch (error) {
78 failed++
79 onFail(step, error instanceof Error ? error.message : String(error))
80 }
81 }
82 return failed
83}
84hooks/cards.tsx 151 lines1// mod-manager / hooks/cards.tsx, by v-amorim
2// Draws the two band cards above the prompt: the first-run welcome and the updates card.
3import type { RenderElement } from 'claude-code'
4
5import { cardFill, titleEdge, type Look } from './look'
6import type { CatalogMod } from './registry'
7import type { EntryProps, Tip } from './entry'
8import { pill } from './pills'
9import text from './text'
10import { fill, THEME } from './theme'
11
12type Elements = {
13 Box: (props: any) => RenderElement
14 Client: (props: any) => RenderElement
15 Text: (props: any) => RenderElement
16 Link: (props: any) => RenderElement
17 Button?: (props: any) => RenderElement
18}
19
20// The hover group of the welcome card's Open setup pill; the band shows that Button's tip while it is lit.
21export const OPEN_SETUP_SCOPE = 'card-welcome-open-pill'
22
23// The tip row and the pill row sit under the card in the band.
24const BAND_ROWS = 2
25
26// The card's own rows besides its body: the two border rows (the title sits in the top one) and the buttons.
27const CHROME_ROWS = 3
28
29// The release the card is about: the sorted name@version list, so a new version changes it and the card returns once.
30export const signatureOf = (updates: CatalogMod[]) => updates.map(mod => `${mod.name}@${mod.version}`).sort().join(',')
31
32// A control glyph without the brackets and padding it carries as a Button label.
33const bare = (glyph: string) => glyph.replace(/[[\]\s]/g, '')
34
35type ButtonSpec = { id: string; label: string; tip: Tip; icon?: string; isOpen?: boolean; isBusy?: boolean; spinner?: boolean }
36
37// A card button is a pill around an entry Client, so it fades under the pointer and posts its tip to the band's tip row.
38function button({ Box, Client, Text }: Elements, look: Look, still: boolean, spec: ButtonSpec) {
39 const props = {
40 id: spec.id,
41 label: spec.label,
42 isOpen: spec.isOpen ?? true,
43 bg: THEME.surface3,
44 color: THEME.onSurface,
45 nerd: look.nerd,
46 still,
47 isBusy: spec.isBusy ?? false,
48 tip: spec.tip,
49 ...(spec.spinner ? { spinner: true, tight: true } : {}),
50 ...(look.nerd && spec.icon && !spec.spinner ? { subLead: spec.icon, subLeadColor: THEME.accent } : {}),
51 } satisfies EntryProps
52 return pill({ Box, Text }, look.nerd, THEME.surface3, <Client key={spec.id} module="./entry.tsx" height={1} props={props} />, `${spec.id}-pill`, cardFill())
53}
54
55const buttonRow = (elements: Elements, key: string, buttons: ButtonSpec[], look: Look, still: boolean) => {
56 const { Box } = elements
57 return (
58 <Box key={`${key}-buttons`} flexDirection="row" columnGap={1} justifyContent="flex-end">
59 {buttons.map(spec => button(elements, look, still, spec))}
60 </Box>
61 )
62}
63
64// onOpen hands back its promise: the press counts as the person asking only until it settles.
65export type WelcomeInput = { showFontTip: boolean; still: boolean; maxRows: number; onOpen: () => Promise<unknown> }
66
67// First run: what the mods are for, a font tip when none was found, and two buttons that both clear the card.
68export function welcomeCard(elements: Elements, look: Look, { showFontTip, still, maxRows, onOpen }: WelcomeInput) {
69 const { Box, Button, Link, Text } = elements
70 // The least needed line goes first when the band is short: the font tip, then the intro.
71 const room = Math.max(0, maxRows - BAND_ROWS - CHROME_ROWS)
72 const intro = room >= 1
73 const tip = showFontTip && room >= 2
74 const gear = bare(look.glyph.gear)
75 // A Button, not an entry: in a narrow terminal Claude Code opens a pane only with a person's press behind it.
76 const open = pill(
77 { Box, Text },
78 look.nerd,
79 THEME.surface3,
80 Button ? <Button key="card-welcome-open" label={look.nerd ? `${gear} ${text.cardOpenSetup}` : ` ${gear} ${text.cardOpenSetup} `} plain hover={{ scope: OPEN_SETUP_SCOPE, color: THEME.accent, inverse: true }} onPress={onOpen} /> : null,
81 OPEN_SETUP_SCOPE,
82 cardFill(),
83 THEME.accent,
84 )
85 const skip: ButtonSpec = { id: 'card-welcome-skip', label: text.cardNotNow, tip: { glyph: bare(look.glyph.close), label: text.cardNotNow, text: text.tipCardWelcomeSkip } }
86 return (
87 <Box key="card-welcome-block" flexDirection="column">
88 <Box key="card-welcome" flexDirection="column" paddingX={1} borderStyle="round" borderColor={THEME.border} backgroundColor={cardFill()}>
89 {intro ? <Text key="card-welcome-intro" color={THEME.muted} wrap="wrap">{text.cardWelcomeBody}</Text> : null}
90 {tip ? (
91 <Text key="card-welcome-font" color={THEME.muted} wrap="wrap">
92 {`${text.cardNerdTip} `}
93 <Link href={text.nerdFontsUrl} label={text.cardNerdHow} />
94 </Text>
95 ) : null}
96 <Box key="card-welcome-buttons" flexDirection="row" columnGap={1}>
97 {open}
98 {button(elements, look, still, skip)}
99 </Box>
100 </Box>
101 {titleEdge(elements, look, 'card-welcome', 'welcome', text.cardWelcomeTitle, '', 0)}
102 </Box>
103 )
104}
105
106export type UpdatesInput = { updates: CatalogMod[]; shown: (name: string) => string; isWorking: boolean; isUpdating: boolean; still: boolean; maxRows: number }
107
108// Updates: one row per lagging mod, and the two buttons. A short band keeps the first rows and counts the rest.
109export function updatesCard(elements: Elements, look: Look, { updates, shown, isWorking, isUpdating, still, maxRows }: UpdatesInput) {
110 const { Box, Text } = elements
111 const arrow = look.glyph.icon.arrow ?? ''
112 const room = Math.max(1, maxRows - BAND_ROWS - CHROME_ROWS)
113 const isCut = updates.length > room
114 const listed = isCut ? updates.slice(0, Math.max(1, room - 1)) : updates
115 const width = Math.max(...updates.map(mod => shown(mod.name).length))
116 const title = fill(updates.length === 1 ? text.cardUpdatesOne : text.cardUpdatesTitle, { n: updates.length })
117 const label = fill(text.cardUpdateAction, { n: updates.length })
118 const buttons: ButtonSpec[] = [
119 {
120 id: 'card-update-run',
121 label: isUpdating ? text.cardUpdating : label,
122 icon: bare(look.glyph.cycle),
123 isOpen: !isWorking,
124 isBusy: isUpdating,
125 spinner: isUpdating,
126 tip: { glyph: bare(look.glyph.cycle), label, text: text.tipCardUpdate },
127 },
128 { id: 'card-update-skip', label: text.cardNotNow, tip: { glyph: bare(look.glyph.close), label: text.cardNotNow, text: text.tipCardUpdateSkip } },
129 ]
130 return (
131 <Box key="card-updates-block" flexDirection="column">
132 <Box key="card-updates" flexDirection="column" paddingX={1} borderStyle="round" borderColor={THEME.border} backgroundColor={cardFill()}>
133 {listed.map((mod, at) => (
134 <Box key={`card-updates-row-${at}`} flexDirection="row">
135 <Box key={`card-updates-row-${at}-name`} flexShrink={0}>
136 <Text color={THEME.onSurface}>{`${shown(mod.name).padEnd(width)} `}</Text>
137 </Box>
138 <Text color={THEME.muted} wrap="truncate-end">
139 {fill(text.pickUpdateDetail, { from: mod.installedVersion, to: '', arrow })}
140 <Text key={`card-updates-row-${at}-to`} color={THEME.accent}>{mod.version}</Text>
141 </Text>
142 </Box>
143 ))}
144 {isCut ? <Text key="card-updates-more" color={THEME.muted}>{fill(text.cardMore, { n: updates.length - listed.length })}</Text> : null}
145 {buttonRow(elements, 'card-updates', buttons, look, still)}
146 </Box>
147 {titleEdge(elements, look, 'card-updates', 'updates', title, '', 0)}
148 </Box>
149 )
150}
151hooks/setup.tsx 118 lines1// mod-manager / hooks/setup.tsx, by v-amorim
2// Draws the setup rows: the tick lists of mods to install or update, the Checked list and the Apply lines.
3import type { ClientModule, RenderElement } from 'claude-code'
4
5import { HOVER_STEPS, HOVER_TICK_MS, hoverMix, mix, postTip, stepToward } from './entry'
6import { fadeLevels, isFading } from './list'
7import { cardFill } from './look'
8import type { Check, Step } from './plan'
9import { CAPS, MARK, THEME } from './theme'
10
11// A tick row is a catalog mod, named by its catalog key (cat:<name>), never by an index into the mod list.
12export type PickRow = { key: string; name: string; detail: string; ticked: boolean }
13
14// kind tells the pane which set the key belongs to: mods ticked for install, or updates left ticked.
15export type PickMessage = { type: 'pick'; key: string; kind: 'install' | 'update' }
16
17export type PickProps = { kind: 'install' | 'update'; rows: PickRow[]; nameWidth: number; on: string; nerd: boolean; tipGlyph: string; tip: string; still?: boolean }
18
19// A status tag keeps 15% of its colour: the share of the card fill it is washed into.
20const TAG_WASH = 0.85
21
22type State = { row: number; levels: Record<number, number> }
23
24const fades = new WeakMap<object, () => void>()
25
26// One row per catalog mod: a click anywhere on it ticks or unticks it; the row under the pointer lights as the list's rows do.
27const PickList: ClientModule<PickProps, State> = (props, surface) => {
28 const { Box, Text } = surface.elements
29 const { kind, rows, nameWidth, on, nerd, tipGlyph, tip, still } = props
30 const hovered = surface.state?.row ?? -1
31 const levels = surface.state?.levels ?? {}
32
33 surface.onPointer(event => {
34 if (!still && !fades.has(surface)) {
35 const stop = surface.every(HOVER_TICK_MS, () => {
36 const { state } = surface
37 if (state && isFading(state.levels, state.row)) {
38 surface.setState({ ...state, levels: fadeLevels(state.levels, state.row) })
39 return
40 }
41 stop()
42 fades.delete(surface)
43 })
44 fades.set(surface, stop)
45 }
46 const row = event.type !== 'leave' && event.y >= 0 && event.y < rows.length ? event.y : -1
47 const isClick = event.type === 'down' && event.button === 'left' && row >= 0
48 if (row !== hovered) {
49 surface.setState({ row, levels: still ? (row >= 0 ? { [row]: HOVER_STEPS } : {}) : fadeLevels(levels, row) })
50 if (!isClick) postTip(surface, row >= 0 ? `manager-pick-${rows[row]!.key}` : 'manager-pick', row >= 0 ? { glyph: tipGlyph, label: rows[row]!.name, text: tip } : null)
51 }
52 if (isClick) surface.post({ type: 'pick', key: rows[row]!.key, kind } satisfies PickMessage)
53 })
54
55 return (
56 <Box flexDirection="column">
57 {rows.map((row, at) => {
58 const level = levels[at] ?? 0
59 const bg = hoverMix(cardFill(), THEME.lifted, level)
60 const fill = row.ticked ? THEME.surface3 : THEME.surface2
61 const end = (side: string, key: string) => <Text key={key} color={fill} backgroundColor={nerd ? bg : fill}>{nerd ? side : ' '}</Text>
62 return (
63 <Box key={`pick-${kind}-${at}`} flexDirection="row" backgroundColor={bg}>
64 {/* Only the detail gives way: a squeezed Text wraps and makes the row two tall. */}
65 <Box key={`pick-${kind}-${at}-pill`} flexDirection="row" flexShrink={0}>
66 {end(CAPS.left, `pick-${kind}-${at}-left`)}
67 <Text key={`pick-${kind}-${at}-tick`} color={THEME.muted} backgroundColor={fill}>
68 <Text key={`pick-${kind}-${at}-mark`} backgroundColor={fill} color={row.ticked ? THEME.accent : THEME.muted}>{row.ticked ? MARK.stopOpen : MARK.stop}</Text>
69 {` ${on}`}
70 </Text>
71 {end(CAPS.right, `pick-${kind}-${at}-right`)}
72 </Box>
73 <Box key={`pick-${kind}-${at}-name`} flexShrink={0}>
74 <Text color={hoverMix(row.ticked ? THEME.onSurface : THEME.muted, THEME.accent, level)}>{` ${row.name.padEnd(nameWidth)} `}</Text>
75 </Box>
76 <Box key={`pick-${kind}-${at}-detail`} flexShrink={1}>
77 <Text color={THEME.muted} wrap="truncate-end">{row.detail}</Text>
78 </Box>
79 </Box>
80 )
81 })}
82 </Box>
83 )
84}
85
86type Elements = { Box: (props: any) => RenderElement; Text: (props: any) => RenderElement }
87
88// The Checked list (the Receipt): each line wrapped whole, its status tag on the right, square on a 15% tint of its colour.
89export function checkedRows({ Box, Text }: Elements, tags: { ok: string; warn: string }, checks: Check[]) {
90 return checks.map((check, at) => {
91 const hue = check.ok ? THEME.ok : THEME.warn
92 return (
93 <Box key={`checked-${at}`} flexDirection="row" columnGap={1}>
94 <Box key={`checked-${at}-line`} flexGrow={1} flexShrink={1}>
95 <Text color={THEME.muted} wrap="wrap">{check.line}</Text>
96 </Box>
97 <Box key={`checked-${at}-tag`} flexShrink={0}>
98 <Text color={hue} backgroundColor={mix(hue, cardFill(), TAG_WASH)}>{` ${check.ok ? tags.ok : tags.warn} `}</Text>
99 </Box>
100 </Box>
101 )
102 })
103}
104
105// Every pending line in full: wrapped under a hanging bullet, never cut with an ellipsis.
106export function planRows({ Box, Text }: Elements, steps: Step[]) {
107 return steps.map((step, at) => (
108 <Box key={`plan-${at}`} flexDirection="row" columnGap={1}>
109 <Box key={`plan-${at}-mark`} flexShrink={0}>
110 <Text color={THEME.accent}>{MARK.bullet}</Text>
111 </Box>
112 <Text color={THEME.onSurface} wrap="wrap">{step.line}</Text>
113 </Box>
114 ))
115}
116
117export default PickList
118hooks/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/text.ts 138 lines1// mod-manager / 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 "commandDescription": "Open the mod manager",
5 "paneTitle": "Mods",
6 "panelWaiting": "Window too narrow for Mods. Type /mod-manager to open it anyway.",
7 "pillTip": "Mods: turn each mod on or off and open its settings",
8 "onCount": "{on} on",
9 "cardEmpty": "Point at a mod to read what it does",
10 "listHint": "Click a switch to toggle {sep} a name to open its page",
11 "noMods": "No mods found.",
12 "sectionYours": "Yours",
13 "sectionInstalled": "Installed",
14 "sectionNotInstalled": "Not installed",
15 "sectionUpdates": "Updates",
16 "stateUpdate": "update {version}",
17 "tickAdd": "Add",
18 "tickUpdate": "Update",
19 "pickDetail": "{version} {sep} {description}",
20 "pickUpdateDetail": "{from} {arrow} {to}",
21 "noNerdFont": "No Nerd Font found",
22 "getOne": "Get one",
23 "nerdFontsUrl": "https://www.nerdfonts.com/font-downloads",
24 "noCatalog": "No marketplace listing found.",
25 "updatesNone": "Every mod from the marketplace is up to date",
26 "autoUpdateOff": "Auto-update is off by default for this marketplace",
27 "openPlugin": "Open /plugin",
28 "pluginHint": "Marketplaces tab, then {market}",
29 "pluginBusy": "Claude is working, {label} is available once it is idle",
30 "checkedTitle": "Checked",
31 "checkVersion": "Claude Code {version}",
32 "checkVersionUnknown": "Claude Code version not read",
33 "checkMarket": "Marketplace {market}",
34 "checkMarketMissing": "Marketplace {market} not found",
35 "checkFont": "Nerd Font found",
36 "checkNoFont": "No Nerd Font found, icons stay plain",
37 "planOn": "Turn on {name}",
38 "planOff": "Turn off {name}",
39 "planInstall": "Install {name} {version}",
40 "planInstallIcons": "Install {name} {version} with icons {mode}",
41 "planUpdate": "Update {name} {from} {arrow} {to}",
42 "applyAction": "Apply {n}",
43 "applyBusy": "{label} {sep} Claude is working, available once it is idle",
44 "applyRunning": "Applying {n}",
45 "applyRunningNote": "{sep} each change runs in turn",
46 "aboutTitle": "About to",
47 "tagOk": "ok",
48 "tagWarn": "warn",
49 "applyDone": "Applied {n}, plugins reloaded",
50 "installFailed": "Install of {name} failed: {error}",
51 "updateFailed": "Update of {name} failed: {error}",
52 "toggleFailed": "{line} failed: {error}",
53 "cliMissing": "the claude command is not on PATH; run the install line from the README in a terminal",
54 "tipPickInstall": "ticks this mod to install it",
55 "tipPickUpdate": "ticks or unticks this update",
56 "tipOpenPlugin": "opens /plugin to set auto-update",
57 "reloading": "Reloading plugins",
58 "reloadFailed": "Reload failed: {error}",
59 "howRestart": "new folder, restart with claude -c once to load it",
60 "settingSaved": "{label} saved",
61 "openInSettings": "Open in {name} settings",
62 "actionRan": "Ran /{command}",
63 "pageTitle": "{name} {sep} {version}",
64 "noDescription": "No description.",
65 "iconsLabel": "Icons",
66 "iconsAll": "Every mod",
67 "iconModes": { "auto": "Auto", "nerd": "Nerd", "safe": "Plain" },
68 "iconsMixed": "Mixed",
69 "iconsDescription": "Sets every mod's icons at once: Auto follows the terminal font.",
70 "iconsSaved": "Icons set to {mode} in {n} mods, each reloads",
71 "tipTabList": "shows the list of mods",
72 "tipTabBack": "goes back to the list of mods",
73 "tipTabPage": "shows this mod's page",
74 "tipIcons": "sets icons in every mod to {mode}",
75 "tipSwitch": "turns this mod on or off",
76 "costTitle": "Costs",
77 "costTip": "calls a model and spends tokens, its page says how to turn it off",
78 "spendTitle": "Spend",
79 "spendAbout": "about {amount}",
80 "spendTokens": "({in} in {sep} {out} out)",
81 "spendCall": "{n} call",
82 "spendCalls": "{n} calls",
83 "spendWindow": "last {n} days",
84 "spendNone": "No side-model calls yet",
85 "spendReset": "Reset",
86 "tipSpendReset": "clears this mod's spend counts",
87 "tipOpen": "opens this mod's page and settings",
88 "tipApply": "runs every line above, then reloads",
89 "stateOn": "On",
90 "stateOff": "Off",
91 "pageHint": "Click a setting to change it {sep} The mod reloads",
92 "pageHintBack": "Click the Mods tab to return to the list",
93 "turnOnFirst": "Turn {name} on to change its settings and run its actions.",
94 "sectionFeatures": "Features",
95 "noFeatures": "No features detected.",
96 "showAll": "Show all",
97 "showLess": "Show less",
98 "tipShowAll": "shows the whole description",
99 "tipShowLess": "cuts it back to two lines",
100 "featuresAll": "Show all",
101 "featuresLess": "Show less",
102 "tipFeaturesAll": "shows every feature line in full",
103 "tipFeaturesLess": "cuts each feature to one row",
104 "reactsTo": "Reacts to {event}.",
105 "blurbs": {
106 "session.start": "Runs once when a session starts, usually to register commands or start timers.",
107 "session.end": "Runs when the session ends.",
108 "turn.start": "Runs when a turn begins.",
109 "turn.complete": "Runs when a turn ends.",
110 "tool.call": "Sees every tool call before it runs and can deny or answer it.",
111 "tool.check": "Decides allow, ask or deny after the permission rules.",
112 "prompt.submit": "Sees the prompt before it is sent and can rewrite or drop it.",
113 "command.run": "Answers a slash command the mod registered.",
114 "ui.render": "Draws its own interface in a render site."
115 },
116 "refuseSelf": "the manager cannot disable itself",
117 "refuseUnreadable": "cannot read settings.json",
118 "refuseInvalid": "settings.json is not valid JSON, left untouched",
119 "refuseOtherSettings": "write would change other settings, refused",
120 "cardWelcomeTitle": "Claude Code mods are in",
121 "cardWelcomeBody": "Pick the mods to install, turn on updates, and more.",
122 "cardNerdTip": "A Nerd Font makes the icons sharper.",
123 "cardNerdHow": "How",
124 "cardOpenSetup": "Open setup",
125 "cardNotNow": "Not now",
126 "cardUpdatesTitle": "{n} mods have updates",
127 "cardUpdatesOne": "{n} mod has an update",
128 "cardUpdateAction": "Update {n}",
129 "cardUpdating": "Updating",
130 "cardMore": "+{n} more",
131 "cardUpdateBusy": "Claude is working, Update is available once it is idle",
132 "tipCardOpen": "opens the Mods pane to pick, turn on and update mods",
133 "tipCardWelcomeSkip": "hides this card, the gear still opens the pane",
134 "tipCardUpdate": "installs the new versions, then reloads plugins",
135 "tipCardUpdateSkip": "hides this card until another version comes out",
136 "tipToast": {"label":"Dismiss","text":"hides this message"}
137}
138hooks/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/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/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/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/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
297