SLOPSHOPPER

mod-manager

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

newpanebandcommandtoastprocess
★ 2v1.2.3MITupdated 2026-10-26v-amorim/moonlight-claude-mods/mod-manager
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mod-manager
│ ┃ Mods ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./entry.tsx │ ┃ Click a switch to toggle · a name to open… ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ No mods found. │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╭────────────────────────────────────────╮ │ ┃ │ ! No Nerd Font found · Get one │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ https://www.nerdfonts.com/font-downloa │ │ ┃ │ ds │ › /mod-manager │ ┃ ╰────────────────────────────────────────╯ │ ┃ ─ Icons │ ┃ │ ┃ ╭────────────────────────────────────────── │ ┃ │ No marketplace listing found. │ ┃ │ Auto-update is off by default for this │ ┃ │ marketplace │ ┃ │ ↵ Open /plugin Ma │ ┃ ╰────────────────────────────────────────── │ ┃ ─ Updates │ ┃ ╭────────────────────────────────────────── │ ┃ │ Point at a mod to read what it does ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────── │ Pick the mods to install, turn on updates, and more. │ A Nerd Font makes the icons sharper. How https://www.nerdfonts.com/font-downloads │ ⊛ Open setup ▣ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ─ Claude Code mods are in ⟨Claude Code's own drawing⟩ ▣ client module ./entry.tsx ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭─────────────────────────────────────────────────────────────────────────────────────────────────── │ Pick the mods to install, turn on updates, and more. │ A Nerd Font makes the icons sharper. How https://www.nerdfonts.com/font-downloads │ ⊛ Open setup ▣ ╰─────────────────────────────────────────────────────────────────────────────────────────────────── ─ Claude Code mods are in ⟨Claude Code's own drawing⟩ ▣ client module ./entry.tsx
Pane · Mods
▣ client module ./entry.tsx Click a switch to toggle · a name to open its page No mods found. ╭────────────────────────────────────────────────────────╮ │ ! No Nerd Font found · Get one │ │ https://www.nerdfonts.com/font-downloads │ ╰────────────────────────────────────────────────────────╯ ─ Icons ╭────────────────────────────────────────────────────────── │ No marketplace listing found. │ Auto-update is off by default for this marketplace │ ↵ Open /plugin Ma ╰────────────────────────────────────────────────────────── ─ Updates ╭────────────────────────────────────────────────────╮ │ Point at a mod to read what it does │ │ │ ╰────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────
README

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

mod-manager

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

Installs, turns on and off, and updates mods, and edits their settings.

Install

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

How to open

The gear pill, or /mod-manager.

See it

Icons on Auto (Nerd Font)Icons on PlainA 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

Features

  • Mods: Installs, turns on and off, and updates mods, and edits their settings, from one panel.
  • Hover cards: Pointing at a mod shows its description cut to two lines. Show all grows the card to the full description, and Show less cuts it back. The same switch opens a mod's whole feature list.

Settings

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

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

License

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

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

[img-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

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