SLOPSHOPPER

mod-skill-panel

Colors each skill in the slash list by the color-tag in its frontmatter, and /mod-skill-panel opens a sidebar of skills grouped by tag where pressing one adds…

newpanebandcommandtoastprocess
★ 2v1.0.5MITupdated 2026-10-26v-amorim/moonlight-claude-mods/mod-skill-panel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mod-skill-panel
│ ┃ Skills ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./entry.tsx │ ┃ Click a skill to add it · point at one to… ⏺ Read(src/auth.ts) │ ┃ No skills yet. Add one at ⎿ Read 6 lines │ ┃ ~/.claude/skills/<name>/SKILL.md ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ─────────────────────────────────────────── ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /mod-skill-panel │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ⟨Claude Code's own drawing⟩ ▣ client module ./entry.tsx ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ▣ client module ./entry.tsx
Pane · Skills
▣ client module ./entry.tsx Click a skill to add it · point at one to read it No skills yet. Add one at ~/.claude/skills/<name>/SKILL.md ────────────────────────────────────────────────────────
README

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

mod-skill-panel

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

Lists your skills and agents; a click adds one to the prompt.

Install

Inside Claude Code:

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

Skills pill.

See it

PanelTags on slash commandsAdded to the prompt
![][img-skill-panel]![][img-skill-panel-slash]![][img-skill-panel-added]
Settings
![][img-skill-panel-settings]
Tags while typing /, add a skill, then the settings
https://github.com/user-attachments/assets/9dc338ca-887f-4cc3-881e-e3967c364d59

Features

  • Skills and agents: Lists your skills and agents, personal and the open project's: skills grouped by tag, agents by model. A click adds one to the prompt.
  • Hover cards: Pointing at a skill or agent shows its description cut to six lines. Show all grows the card to the full description, and Show less cuts it back.
  • Color tags: Give a skill a color-tag: <tag> line in its SKILL.md frontmatter to group it under that tag and badge it in the slash typeahead. The tags are router, start, debug, review, interface, writing, utility and mods; a skill without one, or with another name, lands under personal.

Settings

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

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

License

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

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

[img-skill-panel-added]: https://github.com/user-attachments/assets/f23354f8-77f8-41ee-bbde-1d37c1582dd9 [img-skill-panel-settings]: https://github.com/user-attachments/assets/30559e4a-e9b7-4d27-ac4a-6d36dbf37c99 [img-skill-panel-slash]: https://github.com/user-attachments/assets/ac6c844b-8638-473e-807d-2f31b992b9c9 [img-skill-panel]: https://github.com/user-attachments/assets/c11fe973-679a-49a3-8457-0f0ddd3d1c52

Source 14 files
hooks/register.tsx 543 lines
1// mod-skill-panel / hooks/register.tsx, by v-amorim
2// Registers the mod: wires the skills and agents tabs, settings pane, command and footer entry.
3import type { ConfigValue, EngineInterface, Register } from 'claude-code'
4
5import { layoutCloud, type CloudGroup, type CloudMessage, type CloudProps } from './cloud'
6import { groupSkills, markDescription, readFrontmatter, type Skill } from './tags'
7import { fill, MARK, statusColor, THEME } from './theme'
8import text from './text'
9import { tagName } from './tags'
10import { groupAgents, modelHue, readAgent, type Agent } from './agents'
11import { cardSpace, HEAD_ROWS, stickyCard } from './card'
12import { isTipMessage, type EntryMessage, type EntryProps, type Tip } from './entry'
13import { cardFill, detectNerd, FOOTER_ROWS, lookFor, paneFooter, sepText, section, titleEdge, withMark, type IconMode } from './look'
14import { labelsOf, menuEntry, pillRow, splitRow, tabRow, tipLine } from './pills'
15import { settingChoice, settingsBody } from './settings'
16import { TOAST_MS, ToastList, toastCards } from './toasts'
17
18const homeOf = async ($: EngineInterface) => ((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '').replace(/\\/g, '/')
19
20const configDir = async ($: EngineInterface) => ((await $.env.get('CLAUDE_CONFIG_DIR')) || `${await homeOf($)}/.claude`).replace(/\\/g, '/')
21
22// The icons setting (userConfig), fixed per activation: a change reloads the mod.
23let iconMode: IconMode = 'auto'
24let look = lookFor(false)
25
26async function detectLook($: EngineInterface) {
27  if (iconMode !== 'auto') return lookFor(iconMode === 'nerd')
28  const env = {
29    WT_SESSION: await $.env.get('WT_SESSION'),
30    TERM_PROGRAM: await $.env.get('TERM_PROGRAM'),
31    ITERM_PROFILE: await $.env.get('ITERM_PROFILE'),
32    KITTY_WINDOW_ID: await $.env.get('KITTY_WINDOW_ID'),
33    ALACRITTY_WINDOW_ID: await $.env.get('ALACRITTY_WINDOW_ID'),
34    GNOME_TERMINAL_SCREEN: await $.env.get('GNOME_TERMINAL_SCREEN'),
35    KONSOLE_VERSION: await $.env.get('KONSOLE_VERSION'),
36  }
37  const host = {
38    localAppData: ((await $.env.get('LOCALAPPDATA')) ?? '').replace(/\\/g, '/'),
39    home: ((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '').replace(/\\/g, '/'),
40    read: (path: string) => $.fs.read(path).then(String),
41    run: async (argv: string[]) => {
42      const result = await $.process.run(argv, { timeoutMs: 5000 })
43      if (result.exitCode !== 0) throw new Error(result.stderr)
44      return result.stdout
45    },
46  }
47  return lookFor(await detectNerd(env, host))
48}
49
50// Reading the terminal's settings takes a while, so on auto the last answer, kept in this mod's store, draws until it returns.
51async function savedLook($: EngineInterface) {
52  if (iconMode !== 'auto') return look
53  try {
54    const nerd = await $.store.get('look-nerd')
55    return typeof nerd === 'boolean' ? lookFor(nerd) : look
56  } catch {
57    return look
58  }
59}
60
61async function saveLook($: EngineInterface) {
62  try {
63    await $.store.set('look-nerd', look.nerd)
64  } catch {
65    // Without a store the mod still draws; it only starts from safe again next time.
66  }
67}
68
69// Hooks of one event run in turn, so the slow font check runs beside them instead of holding up every mod after this one.
70let lookLoad: Promise<void> | null = null
71
72function loadLook($: EngineInterface) {
73  lookLoad ??= (async () => {
74    look = await savedLook($)
75    void detectLook($).then(async found => {
76      const isChanged = found.nerd !== look.nerd
77      look = found
78      await saveLook($)
79      if (isChanged) $.ui.invalidate('ui.render')
80    }).catch(() => {
81      // A failed check keeps the icons drawn now.
82    })
83  })()
84  return lookLoad
85}
86
87const PANE = 'skill-panel'
88const SETTINGS = 'skill-panel-settings'
89
90let skills: Skill[] = []
91let mods = new Set<string>()
92let note = ''
93let selected = 0
94let agents: Agent[] = []
95let selectedAgent = 0
96// The folders the last scan looked in, for the empty-list text.
97let agentFolders: string[] = []
98// Which list the panel shows; its settings are a pane of their own.
99let view: 'skills' | 'agents' = 'skills'
100
101// Skills the panel lists: model-invocable ones, plus routers, which are the entry points even when flagged.
102const listed = () => skills.filter(skill => !skill.hidden || skill.tag === 'router')
103const orderedAgents = () => groupAgents(agents).flatMap(group => group.agents)
104
105// Appends `word ` to the prompt unless it is already there.
106async function addToPrompt($: EngineInterface, word: string, added: string) {
107  const { text: current } = await $.prompt.read()
108  if (!current.split(/\s+/).includes(word)) {
109    const gap = current === '' || /\s$/.test(current) ? '' : ' '
110    const filled = await $.prompt.fill({ text: `${gap}${word} `, mode: 'append' })
111    note = filled.isFilled ? `${MARK.ok} ${added}` : `${MARK.warn} ${text.promptBusy}`
112  }
113  $.ui.invalidate('ui.render')
114}
115
116async function addSkill($: EngineInterface, index: number) {
117  const skill = groupSkills(listed()).flatMap(group => group.skills)[index]
118  if (skill) await addToPrompt($, `/${skill.name}`, fill(text.added, { name: skill.name }))
119}
120
121// An @agent- mention asks Claude to hand the work to that agent.
122async function addAgent($: EngineInterface, index: number) {
123  const agent = orderedAgents()[index]
124  if (agent) await addToPrompt($, `@agent-${agent.name}`, fill(text.agentAdded, { name: agent.name }))
125}
126
127const isOpen = async ($: EngineInterface, id = PANE) => (await $.ui.panes()).some(pane => pane.id === id && pane.isPlaced)
128
129// The command and the bar entry both toggle. An unplaced pane is waiting, not open.
130async function togglePane($: EngineInterface) {
131  if (await isOpen($)) {
132    await $.ui.close({ id: PANE })
133    await $.state.set({ plugin: 'mod-skill-panel', key: 'pane' }, false)
134    return
135  }
136  if (await isOpen($, SETTINGS)) await $.ui.close({ id: SETTINGS })
137  note = ''
138  const opened = await $.ui.open({ id: PANE, title: text.paneTitle, closeOnEscape: true })
139  if (!opened.isPlaced) $.ui.toast(fill(text.skillsWaiting, { reason: opened.reason }))
140  await $.state.set({ plugin: 'mod-skill-panel', key: 'pane' }, opened.isPlaced)
141}
142
143// `/mod-skill-panel settings` and the title row's pill toggle the settings panel, which takes the sidebar's place.
144async function toggleSettings($: EngineInterface) {
145  if (await isOpen($, SETTINGS)) {
146    await $.ui.close({ id: SETTINGS })
147    await $.state.set({ plugin: 'mod-skill-panel', key: 'pane' }, false)
148    return
149  }
150  if (await isOpen($)) await $.ui.close({ id: PANE })
151  note = ''
152  const opened = await $.ui.open({ id: SETTINGS, title: text.settingsPaneTitle, closeOnEscape: true })
153  if (!opened.isPlaced) $.ui.toast(fill(text.settingsWaiting, { reason: opened.reason }))
154  await $.state.set({ plugin: 'mod-skill-panel', key: 'pane' }, opened.isPlaced)
155}
156
157
158// A saved change reloads this mod, which redraws the panel with the new value.
159async function saveSetting($: EngineInterface, key: string, value: ConfigValue) {
160  const row = (await $.config.list()).find(each => each.key === key)
161  const result = await $.config.set({ key, value })
162  note = 'deny' in result && result.deny ? `${MARK.warn} ${result.deny}` : `${MARK.ok} ${fill(text.settingSaved, { label: row?.label ?? key })}`
163  $.ui.invalidate('ui.render')
164}
165
166// The panel's title line: skills, agents and settings as tabs, the shown one open.
167const tabs = (open: 'skills' | 'agents' | 'settings') => [
168  { id: 'skills-tab', label: text.paneTitle, icon: look.glyph.icon.skill ?? look.glyph.title, isOpen: open === 'skills', count: listed().length, tip: { glyph: look.glyph.icon.skill ?? look.glyph.title, label: text.paneTitle, text: text.skillsTabTip } },
169  { id: 'skills-agents', label: text.agentsTab, icon: look.glyph.icon.Agent ?? look.glyph.mark.agent ?? look.glyph.title, isOpen: open === 'agents', count: agents.length, tip: { glyph: look.glyph.icon.Agent ?? look.glyph.mark.agent ?? look.glyph.title, label: text.agentsTab, text: text.agentsTabTip } },
170  { id: 'skills-settings', label: text.settingsTab, icon: look.glyph.gear, isOpen: open === 'settings', tip: { glyph: look.glyph.gear, label: text.settingsTab, text: text.settingsTabTip } },
171]
172
173// A list tab: switch the list, opening the sidebar first when its settings are shown.
174async function showView($: EngineInterface, next: typeof view) {
175  view = next
176  note = ''
177  if (!(await isOpen($))) await togglePane($)
178  $.ui.invalidate('ui.render')
179}
180
181// Border, title and six lines of description.
182const CARD_ROWS = 8
183const TEXT_ROWS = CARD_ROWS - 2
184const MORE = 'card-more'
185// The card whose description shows every line, as `view:index`; a click on its control flips it, and pointing at another chip clears it.
186let fullCard = ''
187
188// The rows a text takes at this width, wrapped at spaces, a word wider than the width breaking across rows.
189const wrapRows = (value: string, width: number) =>
190  value.split('\n').flatMap(line => {
191    const rows = ['']
192    let used = 0
193    for (const word of line.split(' ')) {
194      const chars = [...word]
195      if (used > 0 && used + 1 + chars.length <= width) {
196        rows[rows.length - 1] += ` ${word}`
197        used += 1 + chars.length
198        continue
199      }
200      if (used > 0) rows.push('')
201      rows[rows.length - 1] = chars.slice(0, width).join('')
202      used = Math.min(chars.length, width)
203      for (let at = width; at < chars.length; at += width) {
204        rows.push(chars.slice(at, at + width).join(''))
205        used = Math.min(chars.length - at, width)
206      }
207    }
208    return rows
209  })
210
211
212// True while the pointer is on this mod's pill entry, which then shows its tip under the row.
213let tipShown = false
214// The control under the pointer in the pane, which the footer describes.
215let hoveredTip: Tip | null = null
216// The toast × under the pointer, shown in the band's tip row.
217let toastTip = ''
218
219// A band redraw can replace a hovered entry, whose leave and quiet timer then never come, so a band tip ends on its own.
220const BAND_TIP_MS = 5000
221let bandTipAt = 0
222
223function expireBandTip($: EngineInterface) {
224  const at = ++bandTipAt
225  $.clock.after(BAND_TIP_MS, () => {
226    if (at !== bandTipAt || (!tipShown && !toastTip)) return
227    tipShown = false
228    toastTip = ''
229    $.ui.invalidate('ui.render')
230  })
231}
232
233const toasts = new ToastList('skills')
234// The animations setting, fixed per activation: off, every entry draws still.
235let still = false
236
237// The entry shimmers from the click until the pane draws, so a slow open never looks ignored.
238const OPEN_WAIT_MS = 8000
239let isOpening = false
240
241function endOpening($: EngineInterface) {
242  if (!isOpening) return
243  isOpening = false
244  $.ui.invalidate('ui.render')
245}
246
247async function openingPane($: EngineInterface) {
248  if (!(await isOpen($))) {
249    isOpening = true
250    $.ui.invalidate('ui.render')
251    $.clock.after(OPEN_WAIT_MS, () => endOpening($))
252  }
253  await togglePane($)
254  if (!(await isOpen($))) endOpening($)
255}
256
257export const register: Register = (on, options) => {
258  still = options.animations === false
259  iconMode = (options.icons as IconMode | undefined) ?? 'auto'
260  // A forced mode needs no detection; auto loads the saved answer, then checks the terminal font beside the hooks.
261  look = lookFor(iconMode === 'nerd')
262  toasts.isStill = still
263  // This mod's toasts draw as cards in the band; Claude Code's own box is narrow and cuts them.
264  on('ui.toast', async ($, e) => {
265    const id = toasts.add(e.text)
266    $.ui.invalidate('ui.render')
267    $.clock.after(e.timeoutMs ?? TOAST_MS, () => {
268      toasts.drop(id)
269      $.ui.invalidate('ui.render')
270    })
271    return { value: undefined }
272  })
273
274  on('session.start', async ($, e, next) => {
275    await loadLook($)
276    await $.command.register({ name: 'mod-skill-panel', description: text.commandDescription, argumentHint: '[settings]' })
277    const config = await configDir($)
278    const projectClaude = `${String(e.cwd ?? '').replace(/\\/g, '/')}/.claude`
279    // The person's skills, then the project's, which win over a same-named one as they do in Claude Code.
280    const foundSkills = new Map<string, Skill>()
281    for (const root of [`${config}/skills`, `${projectClaude}/skills`]) {
282      for (const entry of await $.fs.list(root).catch(() => [])) {
283        if (entry.kind !== 'dir') continue
284        const text = await $.fs.read(`${root}/${entry.name}/SKILL.md`).catch(() => '')
285        const meta = readFrontmatter(String(text))
286        if (!meta) continue
287        const name = meta.name ?? entry.name
288        foundSkills.set(name, { name, tag: meta.tag, description: meta.description ?? '', hidden: meta.hidden })
289      }
290    }
291    skills = [...foundSkills.values()]
292    // The person's agents, then the project's, which win over a same-named one as they do in Claude Code.
293    const foundAgents = new Map<string, Agent>()
294    agentFolders = [`${config}/agents`, `${projectClaude}/agents`]
295    for (const agentRoot of agentFolders) {
296      for (const entry of await $.fs.list(agentRoot).catch(() => [])) {
297        if (entry.kind !== 'file' || !entry.name.endsWith('.md')) continue
298        const agent = readAgent(String(await $.fs.read(`${agentRoot}/${entry.name}`).catch(() => '')), entry.name.slice(0, -3))
299        if (agent) foundAgents.set(agent.name, agent)
300      }
301    }
302    agents = [...foundAgents.values()]
303    const modDirs = await $.fs.list(`${config}/mods`).catch(() => [])
304    mods = new Set(modDirs.filter(entry => entry.kind === 'dir').map(entry => entry.name))
305    // The typeahead caches each answer for the session, so ask it to list again.
306    $.ui.invalidate('command.describe')
307    // The bar entry reads this; an unload drops the pane without raising ui.close here.
308    await $.state.set({ plugin: 'mod-skill-panel', key: 'pane' }, await isOpen($))
309    // A hot reload leaves an open pane on its old drawing until something redraws it.
310    $.ui.invalidate('ui.render')
311
312    return next(e)
313  })
314
315  on('command.describe', async ($, e, next) => {
316    const answer = await next(e)
317    // A mod is a folder under ~/.claude/mods or an install from this repo's marketplace.
318    const [plugin, market] = e.provider.plugin.split('@')
319    const tag = skills.find(skill => skill.name === e.command)?.tag ?? (mods.has(plugin!) || market === 'claude-mods' ? 'mods' : undefined)
320    if (!tag) return answer
321
322    return { ...answer, description: markDescription(answer.description, tag, look.nerd) }
323  })
324
325  on('command.run', { command: 'mod-skill-panel' }, async ($, e) => {
326    if ((e.args ?? '').trim() === 'settings') await toggleSettings($)
327    else await togglePane($)
328
329    return { text: '' }
330  })
331
332  on('ui.close', async ($, e, next) => {
333    if (e.id === PANE || e.id === SETTINGS) await $.state.set({ plugin: 'mod-skill-panel', key: 'pane' }, await isOpen($, e.id === PANE ? SETTINGS : PANE))
334
335    return next(e)
336  })
337
338  // One panel at a time: another mod's pane opening closes this one.
339  on('state.set', async ($, e, next) => {
340    // Deferred past this dispatch, so this mod's own state write is not nested in another plugin's.
341    if (e.key === 'pane' && e.plugin !== 'mod-skill-panel' && (e as { value?: unknown }).value === true) {
342      $.clock.after(0, async () => {
343        const wasOpen = (await isOpen($)) || (await isOpen($, SETTINGS))
344        if (!wasOpen) return
345        await $.ui.close({ id: PANE })
346        await $.ui.close({ id: SETTINGS })
347        await $.state.set({ plugin: 'mod-skill-panel', key: 'pane' }, false)
348      })
349    }
350
351    return next(e)
352  })
353
354  // The bar entry lives here, not in mod-menu: only a press in this mod counts as the person asking for this pane.
355  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
356    await loadLook($)
357    const hidden = await $.state.get({ plugin: 'mod-menu', key: 'hidden' })
358    const labels = labelsOf((await $.state.get({ plugin: 'mod-menu', key: 'labels' })).value)
359    if (e.surface !== 'terminal' || e.props.hasSurvey) return next(e)
360    if (hidden.value === true) {
361      if (toasts.items.length === 0) return next(e)
362      const { Box, Client, Text } = $.ui.resolve(e)
363      return <Box key="toasts-skills" flexDirection="column">{toastCards({ Box, Client, Text }, toasts, look, text.tipToast)}{await next(e)}</Box>
364    }
365    const nerd = look.nerd
366
367    const { Box, Client, Text } = $.ui.resolve(e)
368    // Reading the value subscribes the bar to redraws; the look trusts the pane list itself.
369    await $.state.get({ plugin: 'mod-skill-panel', key: 'pane' })
370    const shown = (await isOpen($)) || (await isOpen($, SETTINGS))
371    const { group, row, rest, tip } = splitRow(await next(e))
372    // Opening the pane from here queues nothing, so the entry stays live mid-turn.
373    const entry = menuEntry({ Box, Client, Text }, nerd, 'skills', { label: text.pillLabel, isOpen: shown, idle: THEME.pill, isBusy: isOpening, still, labels })
374
375    return pillRow({ Box, Client, Text }, nerd, [entry, ...group], row, [...toastCards({ Box, Client, Text }, toasts, look, text.tipToast), ...rest], toastTip ? tipLine({ Box, Text }, toastTip) : tipShown ? tipLine({ Box, Text }, text.pillTip) : tip, e.props.bodyColumns)
376  })
377
378  on('ui.message', async ($, e, next) => {
379    const clicked = e.data as EntryMessage
380    const dismissed = clicked?.type === 'menu-entry' ? toasts.idOf(clicked.id) : undefined
381    if (dismissed !== undefined) {
382      toasts.drop(dismissed)
383      $.ui.invalidate('ui.render')
384      return next(e)
385    }
386    if (isTipMessage(e.data)) {
387      const { id, glyph, label, text: tipText } = e.data
388      const isToast = Boolean(id?.startsWith('toast-skills-'))
389      toastTip = isToast ? `${label}: ${tipText}` : ''
390      if (toastTip) expireBandTip($)
391      hoveredTip = !isToast && id && glyph !== undefined && label && tipText ? { glyph, label, text: tipText } : null
392      const chip = id?.startsWith('chip:') ? Number(id.slice(5)) : -1
393      if (chip >= 0) {
394        if (`${view}:${chip}` !== fullCard) fullCard = ''
395        if (view === 'agents') selectedAgent = chip
396        else selected = chip
397      }
398      $.ui.invalidate('ui.render')
399      return next(e)
400    }
401    const hover = e.data as { type?: string; id?: string; isOver?: boolean }
402    if (hover?.type === 'menu-hover' && hover.id === 'skills') {
403      tipShown = hover.isOver === true
404      if (tipShown) expireBandTip($)
405      $.ui.invalidate('ui.render')
406    }
407    const message = e.data as EntryMessage
408    if (message?.type === 'menu-entry' && message.id === 'skills') await openingPane($)
409    // The title line's tabs swap the lists and the settings; the open tab does nothing.
410    if (message?.type === 'menu-entry' && message.id === 'skills-settings' && !(await isOpen($, SETTINGS))) await toggleSettings($)
411    if (message?.type === 'menu-entry' && message.id === MORE) {
412      const here = `${view}:${view === 'agents' ? selectedAgent : selected}`
413      fullCard = fullCard === here ? '' : here
414      $.ui.invalidate('ui.render')
415    }
416    if (message?.type === 'menu-entry' && message.id === 'skills-tab') await showView($, 'skills')
417    if (message?.type === 'menu-entry' && message.id === 'skills-agents') await showView($, 'agents')
418    const change = settingChoice(e.data)
419    if (change) await saveSetting($, change.key, change.value)
420
421    return next(e)
422  })
423
424  // The cloud reports a click, which adds the chip to the prompt; the chip under the pointer arrives as a tip message.
425  on('ui.message', { requestId: 'skill-panel' }, async ($, e, next) => {
426    const message = e.data as CloudMessage
427    if (message?.type === 'add') await (view === 'agents' ? addAgent($, message.index) : addSkill($, message.index))
428
429    return next(e)
430  })
431
432  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
433    await loadLook($)
434    if (e.requestId !== PANE && e.requestId !== SETTINGS) return next(e)
435    if (isOpening) $.clock.after(0, () => endOpening($))
436
437    // @ts-expect-error The host's types omit Client from a Pane's elements, yet a Pane draws it.
438    const { Box, Button, Client, Text } = $.ui.resolve(e)
439
440    if (e.requestId === SETTINGS) {
441      const rows = (await $.config.list()).filter(row => row.key.startsWith('mod-skill-panel.'))
442      return (
443        <Box flexDirection="column" backgroundColor={THEME.bg} paddingX={1} minHeight={e.props.scroll.bodyRows}>
444          {tabRow({ Box, Client, Text }, look.nerd, tabs('settings'), still)}
445          {sepText({ Text }, withMark(look, note) || fill(text.settingsHint, { sep: MARK.sep }), statusColor(note), 'settings-status', { wrap: 'truncate-end' })}
446          <Box marginTop={0}>{settingsBody({ Box, Button, Client, Text }, look, { rows, actions: [], working: false, run: async () => {}, still })}</Box>
447          <Box key="footer-space" height={FOOTER_ROWS} flexShrink={0} />
448          {paneFooter({ Box, Text }, hoveredTip, e.props.bodyColumns, e.props.scroll)}
449        </Box>
450      )
451    }
452
453    // Both lists draw the same way: a cloud of groups and the card for the pointed entry.
454    let index = 0
455    let cloud: CloudGroup[]
456    let card: { icon: string; hex: string; title: string; tag: string; extra: string; description: string } | undefined
457    if (view === 'agents') {
458      const agentIcon = look.glyph.icon.Agent ?? look.glyph.mark.agent ?? look.glyph.title
459      cloud = groupAgents(agents).map(group => ({
460        tag: group.model,
461        label: group.model,
462        icon: agentIcon,
463        hex: modelHue(group.model),
464        tip: fill(text.tagTip, { count: group.agents.length }),
465        skills: group.agents.map(agent => ({ index: index++, name: agent.name, tip: fill(text.agentChipTip, { name: agent.name }) })),
466      }))
467      const agent = orderedAgents()[selectedAgent]
468      if (agent) {
469        const extra = agent.tools ? fill(text.agentTools, { count: agent.tools }) : text.agentAllTools
470        card = { icon: agentIcon, hex: modelHue(agent.model), title: `@agent-${agent.name}`, tag: agent.model, extra, description: agent.description }
471      }
472    } else {
473      const groups = groupSkills(listed())
474      cloud = groups.map(group => ({
475        tag: group.tag,
476        label: tagName(group.tag),
477        icon: group.swatch.icon,
478        hex: group.swatch.hex,
479        tip: fill(text.tagTip, { count: group.skills.length }),
480        skills: group.skills.map(skill => ({ index: index++, name: skill.name, tip: fill(text.chipTip, { name: skill.name }) })),
481      }))
482      const entry = groups.flatMap(group => group.skills.map(skill => ({ skill, group })))[selected]
483      if (entry) card = { icon: entry.group.swatch.icon, hex: entry.group.swatch.hex, title: `/${entry.skill.name}`, tag: tagName(entry.group.tag), extra: '', description: entry.skill.description }
484    }
485    // Pane padding, then the section's border and padding.
486    const width = e.props.bodyColumns - 2 - 4
487    const empty = view === 'agents' && agents.length === 0
488    const noSkills = view === 'skills' && listed().length === 0
489
490    // 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.
491    const wrapped = card ? wrapRows(card.description || (view === 'agents' ? text.noAgentDescription : text.noDescription), Math.max(10, e.props.bodyColumns - 6)) : []
492    const isClipped = wrapped.length > TEXT_ROWS
493    const isFull = isClipped && fullCard === `${view}:${view === 'agents' ? selectedAgent : selected}`
494    // Opened, the card grows to every row, as far as the pane has room for it.
495    const roomRows = Math.max(TEXT_ROWS, e.props.scroll.bodyRows - HEAD_ROWS - FOOTER_ROWS - 4)
496    const textShown = isFull ? Math.min(wrapped.length, roomRows) : TEXT_ROWS
497    const cardRows = CARD_ROWS + (textShown - TEXT_ROWS)
498    const shownRows = wrapped.slice(0, textShown)
499    const moreLabel = isFull ? text.showLess : text.showAll
500    const moreArrow = isFull ? look.glyph.less : look.glyph.more
501    const moreControl = (
502      <Client key={MORE} module="./entry.tsx" width={[...moreLabel].length + 4} height={1} props={{ id: 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} />
503    )
504
505    return (
506      <Box flexDirection="column" backgroundColor={THEME.bg} paddingX={1} minHeight={e.props.scroll.bodyRows}>
507        {tabRow({ Box, Client, Text }, look.nerd, tabs(view), still)}
508        {sepText({ Text }, withMark(look, note) || fill(view === 'agents' ? text.agentsHint : text.hint, { sep: MARK.sep }), statusColor(note), 'status', { wrap: 'truncate-end' })}
509        {empty ? <Text color={THEME.muted}>{fill(text.noAgents, { folders: agentFolders.join(' or ') })}</Text> : null}
510        {noSkills ? <Text color={THEME.muted}>{text.noSkills}</Text> : null}
511        {card ? cardSpace({ Box }, cardRows) : null}
512        {noSkills ? null : section({ Box, Text }, look, 'cloud', '', '', (
513          <Client key={`${view}-cloud`} module="./cloud.tsx" height={layoutCloud(cloud, width).rows.length} props={{ groups: cloud, width, selected: view === 'agents' ? selectedAgent : selected, nerd: look.nerd, still } satisfies CloudProps} />
514        ))}
515        {card
516          ? [stickyCard({ Box, Text }, e.props.scroll.offset, e.props.bodyColumns, cardRows, (
517              <Box key="card-body" flexDirection="column">
518                <Box key="card-text" height={shownRows.length} flexDirection="column" overflow="hidden" flexShrink={0}>
519                  {shownRows.map((row, at) =>
520                    isClipped && at === shownRows.length - 1 ? (
521                      <Box key={`card-row-${at}`} flexDirection="row">
522                        <Box key="card-row-text" flexShrink={1}><Text color={THEME.muted} wrap="truncate-end">{row || ' '}</Text></Box>
523                        <Box key="card-row-gap" flexGrow={1} />
524                        <Box key="card-row-more" flexShrink={0}>{moreControl}</Box>
525                      </Box>
526                    ) : (
527                      <Text key={`card-row-${at}`} color={THEME.muted} wrap="truncate-end">{row || ' '}</Text>
528                    ),
529                  )}
530                </Box>
531              </Box>
532            )),
533              <Box key="card-edge-wrap" position="absolute" top={Math.max(HEAD_ROWS, e.props.scroll.offset)} left={1} width={Math.max(10, e.props.bodyColumns - 2)} height={1}>
534                {titleEdge({ Box, Text }, look, 'card', view === 'agents' ? 'agent' : 'skill', card.title, [card.tag, card.extra].filter(Boolean).join(` ${MARK.sep} `), 0)}
535              </Box>]
536          : null}
537        <Box key="footer-space" height={FOOTER_ROWS} flexShrink={0} />
538        {paneFooter({ Box, Text }, hoveredTip, e.props.bodyColumns, e.props.scroll)}
539      </Box>
540    )
541  })
542}
543
hooks/cloud.tsx 154 lines
1// mod-skill-panel / hooks/cloud.tsx, by v-amorim
2// Lays out and draws the tag cloud of skill chips with hover and click.
3import type { ClientModule, RenderChildren } from 'claude-code'
4
5import { HOVER_STEPS, HOVER_TICK_MS, hoverMix, mix, postTip, stepToward } from './entry'
6import { subpill } from './look'
7import { TAG_WASH } from './tags'
8import { CAPS, THEME } from './theme'
9
10export type CloudGroup = { tag: string; label: string; icon: string; hex: string; tip: string; skills: { index: number; name: string; tip: string }[] }
11
12// still: the animations setting is off, so a hover switches at once.
13export type CloudProps = { groups: CloudGroup[]; width: number; selected: number; nerd: boolean; still?: boolean }
14
15// Pointing at a chip is a tip message with id `chip:<index>` (one post per frame lands, so no separate hover); a click posts `add`.
16export type CloudMessage = { type: 'add'; index: number }
17
18type Placed = { x: number; width: number; index: number; name: string; tip: string }
19
20// Cells a pill takes around its text: both caps in Nerd mode, a space each side in safe mode.
21const ENDS = 2
22
23// A tag's icon as a subpill: its cap, the icon and its cap, or the icon padded by a space each side.
24const ICON_CELLS = 3
25
26// The pointer's target: a chip is its index, a tag pill is -2 minus its group, nothing is -1.
27const tagTarget = (group: number) => -2 - group
28
29// The tag pill column, then each group's skills as chips that wrap under it to the next row; a faint rule between groups.
30export function layoutCloud(groups: CloudGroup[], width: number) {
31  const lead = Math.max(0, ...groups.map(group => ENDS + ICON_CELLS + 1 + group.label.length)) + 1
32  const rows: { group: number; chips: Placed[]; isRule?: boolean }[] = []
33  groups.forEach((group, at) => {
34    if (at > 0) rows.push({ group: -1, chips: [], isRule: true })
35    let row = { group: at, chips: [] as Placed[] }
36    rows.push(row)
37    let x = lead
38    for (const skill of group.skills) {
39      const chip = ENDS + 2 + skill.name.length
40      if (row.chips.length > 0 && x + chip > width) {
41        row = { group: -1, chips: [] }
42        rows.push(row)
43        x = lead
44      }
45      row.chips.push({ x, width: chip, index: skill.index, name: skill.name, tip: skill.tip })
46      x += chip + 1
47    }
48  })
49  return { lead, rows }
50}
51
52// levels: each target's hover fade, kept only while above 0.
53type State = { hovered: number; levels: Record<number, number> }
54
55const fadeLevels = (levels: Record<number, number>, hovered: number) => {
56  const next: Record<number, number> = {}
57  for (const target of new Set([...Object.keys(levels).map(Number), hovered])) {
58    const level = stepToward(levels[target] ?? 0, target === hovered)
59    if (target !== -1 && level > 0) next[target] = level
60  }
61  return next
62}
63
64const isFading = (levels: Record<number, number>, hovered: number) => Object.entries(levels).some(([target, level]) => level !== (Number(target) === hovered ? HOVER_STEPS : 0)) || (hovered !== -1 && !levels[hovered])
65
66// Each cloud's fade timer runs only while a hover is moving: an idle cloud ticking costs a wakeup.
67const fades = new WeakMap<object, () => void>()
68
69// Every skill as a chip under its tag: the pointer on one lights it as a lifted chip with accent text and shows it in the panel's card; a click adds it to the prompt.
70const Cloud: ClientModule<CloudProps, State> = (props, surface) => {
71  const { Box, Text } = surface.elements
72  const { groups, width, selected, nerd, still } = props
73  const { lead, rows } = layoutCloud(groups, width)
74  // A Client takes no hover prop, so it tracks the target under the pointer itself.
75  const hovered = surface.state?.hovered ?? -1
76  const levels = surface.state?.levels ?? {}
77
78  surface.onPointer(event => {
79    if (!still && !fades.has(surface)) {
80      const stop = surface.every(HOVER_TICK_MS, () => {
81        const { state } = surface
82        if (state && isFading(state.levels, state.hovered)) {
83          surface.setState({ ...state, levels: fadeLevels(state.levels, state.hovered) })
84          return
85        }
86        stop()
87        fades.delete(surface)
88      })
89      fades.set(surface, stop)
90    }
91    const row = event.type === 'leave' ? undefined : rows[event.y]
92    const chip = row?.chips.find(each => event.x >= each.x && event.x < each.x + each.width)
93    const tag = row && row.group >= 0 && event.x < lead - 1 ? row.group : -1
94    const target = chip ? chip.index : tag >= 0 ? tagTarget(tag) : -1
95    const isClick = event.type === 'down' && event.button === 'left' && chip !== undefined
96    if (target !== hovered) {
97      surface.setState({ hovered: target, levels: still ? (target === -1 ? {} : { [target]: HOVER_STEPS }) : fadeLevels(levels, target) })
98      const group = chip ? groups.find(each => each.skills.some(skill => skill.index === chip.index)) : tag >= 0 ? groups[tag] : undefined
99      if (!isClick) postTip(surface, chip ? `chip:${chip.index}` : tag >= 0 ? `tag:${tag}` : 'chip', group ? { glyph: group.icon, label: chip ? chip.name : group.label, text: chip ? chip.tip : group.tip } : null)
100    }
101    if (isClick) surface.post({ type: 'add', index: chip.index } satisfies CloudMessage)
102  })
103
104  const pill = (key: string, fill: string, body: RenderChildren) => (
105    <Box key={key} flexDirection="row" flexShrink={0}>
106      <Text key={`${key}-left`} color={fill} {...(nerd ? {} : { backgroundColor: fill })}>{nerd ? CAPS.left : ' '}</Text>
107      <Box key={`${key}-body`} flexDirection="row" backgroundColor={fill}>{body}</Box>
108      <Text key={`${key}-right`} color={fill} {...(nerd ? {} : { backgroundColor: fill })}>{nerd ? CAPS.right : ' '}</Text>
109    </Box>
110  )
111
112  return (
113    <Box flexDirection="column">
114      {rows.map((row, at) => {
115        if (row.isRule) return <Text key={`row-${at}`} color={THEME.border}>{'─'.repeat(width)}</Text>
116        const group = groups[row.group]
117        const tagLevel = levels[tagTarget(row.group)] ?? 0
118        const tagFill = group ? hoverMix(mix(group.hex, THEME.surface1, TAG_WASH), THEME.lifted, tagLevel) : THEME.surface1
119        return (
120          <Box key={`row-${at}`} flexDirection="row">
121            <Box key={`row-${at}-lead`} width={lead} flexShrink={0}>
122              {group
123                ? pill(`tag-${at}`, tagFill, (
124                    <Box key={`tag-${at}-label`} flexDirection="row">
125                      {subpill({ Box, Text }, nerd, tagFill, group.icon, `tag-${at}-icon`, group.hex)}
126                      <Text color={hoverMix(group.hex, THEME.accent, tagLevel)}>{` ${group.label}`}</Text>
127                    </Box>
128                  ))
129                : null}
130            </Box>
131            <Box key={`row-${at}-chips`} flexDirection="row" columnGap={1}>
132              {row.chips.map(chip => {
133                const level = levels[chip.index] ?? 0
134                const isSelected = chip.index === selected
135                const hex = groups.find(each => each.skills.some(skill => skill.index === chip.index))?.hex ?? THEME.muted
136                // Hover lifts the chip and turns its name accent; the selected chip keeps its name accent and bold without the lift.
137                const name = hoverMix(isSelected ? THEME.accent : THEME.secondary, THEME.accent, level)
138                return pill(`chip-${chip.index}`, hoverMix(THEME.surface3, THEME.lifted, level), (
139                  <Text key={`chip-${chip.index}-text`}>
140                    <Text color={hex}>{'● '}</Text>
141                    <Text color={name} bold={isSelected}>{chip.name}</Text>
142                  </Text>
143                ))
144              })}
145            </Box>
146          </Box>
147        )
148      })}
149    </Box>
150  )
151}
152
153export default Cloud
154
hooks/tags.ts 71 lines
1// mod-skill-panel / hooks/tags.ts, by v-amorim
2// Reads skill frontmatter, groups skills by tag and builds colored tag badges.
3import style from './style'
4import text from './text'
5import { mix } from './entry'
6import { CAPS, THEME } from './theme'
7
8export const PERSONAL = 'personal'
9
10type Swatch = { hex: string; icon: string }
11
12// Hues spread away from the theme blue, each over 9:1 on bg; icons are single-width Unicode, no Nerd Font.
13export const SWATCHES: Record<string, Swatch> = Object.fromEntries(Object.entries(style.tags).map(([tag, { color, icon }]) => [tag, { hex: color, icon }]))
14
15const FALLBACK = SWATCHES[PERSONAL]!
16
17export const tagName = (tag: string) => (text.tagNames as Record<string, string>)[tag] ?? tag
18
19export const swatchFor = (tag: string) => SWATCHES[tag] ?? FALLBACK
20
21export function readFrontmatter(text: string): { name?: string; tag: string; description?: string; hidden: boolean } | null {
22  const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1]
23  if (block === undefined) return null
24  const field = (key: string) => {
25    const raw = new RegExp(`^${key}:[ \\t]*(.+?)[ \\t]*$`, 'm').exec(block)?.[1]
26    return raw?.replace(/^(["'])(.*)\1$/, '$2')
27  }
28  return { name: field('name'), tag: field('color-tag')?.toLowerCase() ?? PERSONAL, description: field('description'), hidden: field('disable-model-invocation') === 'true' }
29}
30
31export type Skill = { name: string; tag: string; description: string; hidden: boolean }
32
33export const TAG_ORDER = ['router', 'start', 'debug', 'review', 'interface', 'writing', 'utility', 'mods', PERSONAL]
34
35export const groupSkills = (skills: Skill[]) => {
36  const known = skills.map(skill => ({ skill, tag: TAG_ORDER.includes(skill.tag) ? skill.tag : PERSONAL }))
37  known.sort((a, b) => TAG_ORDER.indexOf(a.tag) - TAG_ORDER.indexOf(b.tag) || a.skill.name.localeCompare(b.skill.name))
38  const groups = new Map<string, Skill[]>()
39  for (const { skill, tag } of known) groups.set(tag, [...(groups.get(tag) ?? []), skill])
40  return [...groups].map(([tag, members]) => ({ tag, swatch: swatchFor(tag), skills: members }))
41}
42
43const rgb = (hex: string) => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16)).join(';')
44const fgOf = (hex: string) => `\x1b[38;2;${rgb(hex)}m`
45const bgOf = (hex: string) => `\x1b[48;2;${rgb(hex)}m`
46
47// The share of the tag's own hue left in the badge, as the panel's tag pills tint theirs.
48export const TAG_WASH = 0.72
49
50export const knownTag = (tag: string) => (TAG_ORDER.includes(tag) ? tag : PERSONAL)
51
52// The typeahead collapses whitespace runs (a no-break space too), so the padding is the blank braille cell.
53const PAD = '⠀'
54
55// Every badge pads to the longest tag name, so the descriptions after it line up.
56const nameWidth = () => Math.max(...TAG_ORDER.map(tag => tagName(tag).length))
57
58// A tinted pill with the tag's icon and name in its hue, on the typeahead's own background; rounded where the font has the caps.
59export function badge(tag: string, nerd: boolean) {
60  const known = knownTag(tag)
61  const { hex, icon } = swatchFor(known)
62  const wash = mix(hex, THEME.bg, TAG_WASH)
63  const name = tagName(known)
64  const body = `${bgOf(wash)}${fgOf(hex)}${nerd ? '' : ' '}${icon} ${name}${nerd ? '' : ' '}\x1b[39m\x1b[49m`
65  const caps = nerd ? [`${fgOf(wash)}${CAPS.left}\x1b[39m`, `${fgOf(wash)}${CAPS.right}\x1b[39m`] : ['', '']
66  return `${caps[0]}${body}${caps[1]}${PAD.repeat(nameWidth() - name.length)}`
67}
68
69// The typeahead draws the name and description only, so the badge rides on the description.
70export const markDescription = (description: string, tag: string, nerd: boolean) => `${badge(tag, nerd)} ${description}`
71
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 49 lines
1// mod-skill-panel / 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  "pillLabel": "Skills",
5  "pillTip": "Skills: browse your skills and add one to the prompt",
6  "paneTitle": "Skills",
7  "settingsPaneTitle": "mod-skill-panel",
8  "settingsTab": "Settings",
9  "agentsTab": "Agents",
10  "skillsTabTip": "lists your skills",
11  "agentsTabTip": "lists your agents",
12  "settingsTabTip": "opens this mod's settings",
13  "chipTip": "adds /{name} to the prompt",
14  "tagTip": "{count} in this group",
15  "agentChipTip": "mentions @agent-{name} in the prompt",
16  "agentsHint": "Click an agent to mention it {sep} point at one to read it",
17  "agentAdded": "Mentioned @agent-{name}",
18  "agentTools": "{count} tools",
19  "agentAllTools": "all tools",
20  "noAgents": "No agents in {folders} yet.",
21  "noSkills": "No skills yet. Add one at ~/.claude/skills/<name>/SKILL.md",
22  "noAgentDescription": "This agent has no description.",
23  "hint": "Click a skill to add it {sep} point at one to read it",
24  "settingsHint": "Click a setting to change it {sep} The mod reloads",
25  "added": "Added /{name}",
26  "promptBusy": "The prompt is busy, try again",
27  "settingSaved": "{label} saved",
28  "skillsWaiting": "Skills panel waiting: {reason}",
29  "settingsWaiting": "Settings panel waiting: {reason}",
30  "noDescription": "This skill has no description.",
31  "showAll": "Show all",
32  "showLess": "Show less",
33  "tipShowAll": "shows the whole description",
34  "tipShowLess": "cuts it back to six lines",
35  "commandDescription": "Open the skills sidebar; `settings` opens its settings panel",
36  "tagNames": {
37    "router": "router",
38    "start": "start",
39    "debug": "debug",
40    "review": "review",
41    "interface": "interface",
42    "writing": "writing",
43    "utility": "utility",
44    "mods": "mods",
45    "personal": "personal"
46  },
47  "tipToast": {"label":"Dismiss","text":"hides this message"}
48}
49
hooks/agents.ts 31 lines
1// mod-skill-panel / hooks/agents.ts, by v-amorim
2// Reads agent files and groups agents by model with a color for each.
3import style from './style'
4
5export type Agent = { name: string; model: string; description: string; tools: number }
6
7// Models in the order the tab groups them; an agent without a model runs on the session's, so it is "inherit".
8export const MODEL_ORDER = ['opus', 'sonnet', 'haiku', 'inherit']
9
10const MODELS = style.colors.models as Record<string, string>
11
12export const modelHue = (model: string) => MODELS[model] ?? MODELS.inherit!
13
14// An agent file's name, model, description and tool count (0 when it lists none, so it gets every tool).
15export function readAgent(text: string, fallbackName: string): Agent | null {
16  const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1]
17  if (block === undefined) return null
18  const field = (key: string) => new RegExp(`^${key}:[ \\t]*(.+?)[ \\t]*$`, 'm').exec(block)?.[1]?.replace(/^(["'])(.*)\1$/, '$2')
19  const model = field('model')?.toLowerCase()
20  const tools = field('tools')
21  return {
22    name: field('name') ?? fallbackName,
23    model: model && MODEL_ORDER.includes(model) ? model : 'inherit',
24    description: field('description') ?? '',
25    tools: tools ? tools.split(',').filter(tool => tool.trim()).length : 0,
26  }
27}
28
29export const groupAgents = (agents: Agent[]) =>
30  MODEL_ORDER.map(model => ({ model, agents: agents.filter(agent => agent.model === model).sort((a, b) => a.name.localeCompare(b.name)) })).filter(group => group.agents.length > 0)
31
hooks/card.tsx 37 lines
1// hooks/card.tsx, by v-amorim
2// Shared sticky card; every mod whose pane has a card carries the same copy.
3import type { RenderElement } from 'claude-code'
4
5import { cardFill } from './look'
6import { THEME } from './theme'
7
8// Rows above the card: the heading, the status line and the gap under them.
9export const HEAD_ROWS = 3
10
11type Elements = { Box: (props: any) => RenderElement; Text: (props: any) => RenderElement }
12
13// The card's space in the pane's flow; the section under it brings its own blank row, so the list starts one row clear of the border.
14export const cardSpace = ({ Box }: Pick<Elements, 'Box'>, rows: number) => <Box key="card-space" height={rows} marginTop={1} flexShrink={0} />
15
16// Drawn last over the list at the top of the view; the painted row under it keeps a scrolled row off the border.
17export function stickyCard(elements: Elements, offset: number, columns: number, rows: number, body: unknown, hue: string = THEME.accent) {
18  const { Box } = elements
19  return (
20    <Box key="card-wrap" position="absolute" top={Math.max(HEAD_ROWS, offset)} left={1} width={Math.max(10, columns - 2)} height={rows + 1} flexDirection="column">
21      <Box
22        key="card"
23        height={rows}
24        flexDirection="column"
25        paddingX={1}
26        overflow="hidden"
27        borderStyle="round"
28        borderColor={THEME.border}
29        backgroundColor={cardFill(hue)}
30      >
31        {body}
32      </Box>
33      <Box key="card-gap" height={1} backgroundColor={THEME.bg} />
34    </Box>
35  )
36}
37
hooks/entry.tsx 241 lines
1// hooks/entry.tsx, by v-amorim
2// Shared pill entry; every mod that draws a menu entry carries the same copy.
3import type { ClientModule, ClientSurface } from 'claude-code'
4
5import style from './style'
6import { CAPS, mix, THEME, WASH } from './theme'
7
8// color: open text (a red Run); idle: closed text, muted unless set; isBusy shimmers the label until the click's result lands.
9// still: the mod's animations setting is off, so nothing shimmers and the hover color switches at once.
10// lead: a bare glyph before the label in leadColor (after a space, none when tight: the cap is the gap); subLead: an icon before it as a rounded subpill in subLeadColor;
11// badge: a count after it in its own pill. Subpills are capped where `nerd` has the caps.
12// tight: no label padding; tip: posts `tip` instead of `menu-hover`; rail: a divider (a Text between Clients squeezes them); rule: a full-width hairline.
13// spinner: the braille dots lead the label in accent while work runs (write the label without its …); still holds the first frame.
14export type EntryProps = {
15  id: string
16  label: string
17  isOpen: boolean
18  bg: string
19  color?: string
20  idle?: string
21  isBusy?: boolean
22  still?: boolean
23  lead?: string
24  leadColor?: string
25  subLead?: string
26  subLeadColor?: string
27  badge?: { text: string; color: string; bg: string }
28  nerd?: boolean
29  tight?: boolean
30  tip?: Tip
31  rail?: boolean
32  rule?: boolean
33  spinner?: boolean
34  compact?: boolean // false: the pill row may cut the entry to its lead when the row is wider than the band
35}
36
37const entryText = (props: EntryProps) => {
38  const pad = props.tight ? (props.isOpen && !props.nerd ? ' ' : '') : ' '
39  return `${pad}${props.compact && props.lead ? '' : props.label}${props.tight && props.badge ? ' ' : pad}`
40}
41
42// The cells an entry takes; subpills add their two caps, or their two padding spaces in safe mode.
43export const entryCells = (props: EntryProps) => {
44  if (props.rail) return 1
45  const cells = (text: string) => [...text].length
46  const leadText = props.lead ?? (props.spinner ? SPIN_FRAMES[0]! : '')
47  const lead = leadText ? cells(leadText) + (props.tight ? 0 : 1) : 0
48  return lead + (props.subLead ? cells(props.subLead) + 2 : 0) + cells(entryText(props)) + (props.badge ? cells(props.badge.text) + 2 : 0)
49}
50
51// A hovered control's glyph, label and what it does; the pane footer and the band's tip row both show it.
52export type Tip = { glyph: string; label: string; text: string }
53
54// Posted to the owning mod's ui.message when the pointer enters a control (id set) or leaves it (id null).
55export type TipMessage = { type: 'tip'; id: string | null; glyph?: string; label?: string; text?: string }
56
57export const isTipMessage = (data: unknown): data is TipMessage => (data as TipMessage | undefined)?.type === 'tip'
58
59// The band's tip row text for a tip message: "Label: what it does", or '' once the pointer left.
60export const tipText = (message: TipMessage | null | undefined) => (message?.id && message.label ? `${message.label}: ${message.text ?? ''}` : '')
61
62// A control calls this from onPointer, with its tip on enter and null on leave; never also `menu-hover`, one post per frame lands.
63export const postTip = (surface: { post: (data: any) => void }, id: string, tip: Tip | null) => {
64  surface.post((tip ? { type: 'tip', id, glyph: tip.glyph, label: tip.label, text: tip.text } : { type: 'tip', id: null }) satisfies TipMessage)
65}
66
67// menu-hover tells the owning mod the pointer arrived or left, so it can show its tip under the row.
68export type EntryMessage = { type: 'menu-entry'; id: string } | { type: 'menu-hover'; id: string; isOver: boolean }
69
70// A dialog opening under the pointer can swallow the leave event, so a hover with no pointer event for this long ends on its own.
71const HOVER_QUIET_MS = 5000
72
73export const TICK_MS = 140
74
75// The loading shimmer: a soft primary-70 band sweeps the dim text, then a pause.
76const BAND = style.colors.shimmer
77const SWEEP_PAUSE = 8
78
79export const shimmerColor = (at: number, phase: number, length: number) => {
80  const period = length + BAND.length * 2 + SWEEP_PAUSE
81  const head = (phase % period) - BAND.length
82  return BAND[Math.abs(at - head)] ?? style.colors.pills.readout
83}
84
85// The braille dots spinner, one frame every SPIN_MS, in accent.
86export const SPIN_FRAMES = [...style.marks.spinner]
87export const SPIN_MS = 80
88
89// The pointer lights a control at once and its color fades out over this many steps, one every HOVER_TICK_MS.
90export const HOVER_STEPS = 2
91
92export const HOVER_TICK_MS = 60
93
94export { mix }
95
96// One step of a hover fade: full at once while the pointer is on, one step toward 0 once it left.
97export const stepToward = (level: number, isOver: boolean) => (isOver ? HOVER_STEPS : Math.max(0, level - 1))
98
99// Even steps: an eased last step barely moves the color, so the fade seemed to hang at its end.
100export const hoverMix = (from: string, to: string, level: number) => mix(from, to, level / HOVER_STEPS)
101
102// A click shimmers the entry until the mod answers with new props, or this long at most.
103const PRESS_MS = 1200
104
105// pressed: the props a click saw and when it came, cleared once the mod answers.
106type State = { isOver: boolean; at: number; phase: number; level?: number; spin?: number; pressed?: { props: string; at: number } }
107
108const isPressed = (state: State | undefined, props: EntryProps | undefined) =>
109  Boolean(state?.pressed && Date.now() - state.pressed.at < PRESS_MS && state.pressed.props === JSON.stringify(props))
110
111// Each instance's running fade-out timer, started by the leave event and stopped at 0 or when the pointer returns.
112const fades = new WeakMap<object, () => void>()
113
114const startFade = (surface: ClientSurface<State>) => {
115  if (fades.has(surface)) return
116  const stop = surface.every(HOVER_TICK_MS, () => {
117    const { state } = surface
118    if (!state) return
119    const level = stepToward(state.level ?? 0, state.isOver)
120    if (level !== (state.level ?? 0)) surface.setState({ ...state, level })
121    if (state.isOver || level === 0) {
122      stop()
123      fades.delete(surface)
124    }
125  })
126  fades.set(surface, stop)
127}
128
129// The ticker reads the latest props from here, since its closure keeps the first ones.
130const latest = new WeakMap<object, EntryProps>()
131
132// Each instance's running tick: it runs only while a shimmer moves or a hover is watched, so idle pills cost no wakeups.
133const ticks = new WeakMap<object, () => void>()
134
135const startTick = (surface: ClientSurface<State>) => {
136  if (ticks.has(surface)) return
137  const stop = surface.every(TICK_MS, () => {
138    const { state } = surface
139    if (!state) return
140    const now = latest.get(surface)
141    if (!now?.still && (now?.isBusy || isPressed(state, now))) surface.setState({ ...state, phase: state.phase + 1 })
142    else if (state.pressed) surface.setState({ ...state, pressed: undefined })
143    else if (state.isOver && Date.now() - state.at >= HOVER_QUIET_MS) {
144      surface.setState({ ...state, isOver: false, at: Date.now() })
145      if (!now?.still) startFade(surface)
146      const id = now?.id ?? ''
147      if (now?.tip) postTip(surface, id, null)
148      else surface.post({ type: 'menu-hover', id, isOver: false } satisfies EntryMessage)
149    } else if (!state.isOver) {
150      stop()
151      ticks.delete(surface)
152    }
153  })
154  ticks.set(surface, stop)
155}
156
157// Each spinning instance's frame clock, stopped once the entry stops spinning.
158const spins = new WeakMap<object, () => void>()
159
160const startSpin = (surface: ClientSurface<State>) => {
161  if (spins.has(surface)) return
162  const stop = surface.every(SPIN_MS, () => {
163    const { state } = surface
164    const now = latest.get(surface)
165    if (!state || !now?.spinner || now.still) {
166      stop()
167      spins.delete(surface)
168      return
169    }
170    surface.setState({ ...state, spin: (state.spin ?? 0) + 1 })
171  })
172  spins.set(surface, stop)
173}
174
175// A Button inverts under the pointer, so the entry is plain text that lights up like a sidebar row.
176const Entry: ClientModule<EntryProps, State> = (props, surface) => {
177  const { Box, Text } = surface.elements
178  if (props.rail) return <Text color={style.colors.pills.separator} backgroundColor={props.bg}>{style.marks.rail}</Text>
179  if (props.rule) return <Text color={THEME.border} backgroundColor={props.bg}>{style.marks.rule.repeat(Math.max(1, surface.columns))}</Text>
180  const hovered = surface.state?.isOver === true
181  latest.set(surface, props)
182
183  if (!surface.state) surface.setState({ isOver: false, at: 0, phase: 0 })
184  if (props.isBusy && !props.still) startTick(surface)
185  if (props.spinner && !props.still) startSpin(surface)
186
187  surface.onPointer(event => {
188    startTick(surface)
189    const over = event.type !== 'leave'
190    if (over !== hovered) {
191      if (props.tip) postTip(surface, props.id, over ? props.tip : null)
192      else surface.post({ type: 'menu-hover', id: props.id, isOver: over } satisfies EntryMessage)
193    }
194    const isClick = event.type === 'down' && event.button === 'left'
195    const base = surface.state ?? { phase: 0, isOver: false, at: 0 }
196    const level = over === base.isOver ? base.level : over ? HOVER_STEPS : props.still ? 0 : base.level
197    if (!over && !props.still) startFade(surface)
198    surface.setState({ ...base, isOver: over, at: Date.now(), level, pressed: isClick ? { props: JSON.stringify(props), at: Date.now() } : base.pressed })
199    if (isClick) surface.post({ type: 'menu-entry', id: props.id } satisfies EntryMessage)
200  })
201
202  const text = entryText(props)
203  const isShimmering = !props.still && (props.isBusy || isPressed(surface.state, props))
204  const phase = surface.state?.phase ?? 0
205  const label = isShimmering ? (
206    <Box key="label" flexDirection="row">
207      {[...text].map((char, at) => (
208        <Text key={`c${at}`} color={shimmerColor(at, phase, text.length)} backgroundColor={props.bg}>
209          {char}
210        </Text>
211      ))}
212    </Box>
213  ) : (
214    <Text key="label" color={hoverMix(props.isOpen ? (props.color ?? THEME.onSurface) : (props.idle ?? THEME.muted), THEME.accent, surface.state?.level ?? 0)} bold={props.isOpen && Boolean(props.idle)} backgroundColor={props.bg}>
215      {text.split(style.marks.sep).flatMap((part, at) => (at === 0 ? [part] : [<Text key={`sep-${at}`} color={style.colors.pills.separator}>{style.marks.sep}</Text>, part]))}
216    </Text>
217  )
218  const lead = props.spinner ? SPIN_FRAMES[props.still ? 0 : (surface.state?.spin ?? 0) % SPIN_FRAMES.length] : props.lead
219  if (!lead && !props.subLead && !props.badge) return isShimmering ? <Box flexDirection="row">{label}</Box> : label
220
221  const sub = (key: string, text: string, color: string, bg: string) => (
222    <Box key={key} flexDirection="row">
223      {props.nerd ? <Text key={`${key}-left`} color={bg} backgroundColor={props.bg}>{CAPS.left}</Text> : null}
224      <Text key={`${key}-text`} color={color} backgroundColor={bg} bold>{props.nerd ? text : ` ${text} `}</Text>
225      {props.nerd ? <Text key={`${key}-right`} color={bg} backgroundColor={props.bg}>{CAPS.right}</Text> : null}
226    </Box>
227  )
228  const subHue = props.subLeadColor ?? THEME.accent
229  const { badge } = props
230  return (
231    <Box flexDirection="row">
232      {lead ? <Text key="lead" color={props.spinner ? THEME.accent : (props.leadColor ?? THEME.accent)} backgroundColor={props.bg}>{props.tight ? lead : ` ${lead}`}</Text> : null}
233      {props.subLead ? sub('sub-lead', props.subLead, subHue, mix(subHue, props.bg, WASH)) : null}
234      {label}
235      {badge ? sub('badge', badge.text, badge.color, badge.bg) : null}
236    </Box>
237  )
238}
239
240export default Entry
241
hooks/look.tsx 279 lines
1// hooks/look.tsx, by v-amorim
2// Shared look; every mod carries the same copy.
3import type { RenderElement } from 'claude-code'
4
5import style from './style'
6import type { Tip } from './entry'
7import { CAPS, decodeAll, MARK, mix, THEME, WASH } from './theme'
8
9type Glyphs = Omit<typeof style.glyphs.nerd, 'icon' | 'mark'> & { icon: Record<string, string>; mark: Record<string, string> }
10
11// Safe mode: common Unicode that every modern terminal font draws. Headings and rows carry no icon.
12const SAFE: Glyphs = decodeAll(style.glyphs.safe)
13
14// Nerd mode: Font Awesome, Material and VS Code glyphs from any Nerd Font, controls padded to the same three cells.
15const NERD: Glyphs = decodeAll(style.glyphs.nerd)
16
17export type Look = { nerd: boolean; glyph: typeof SAFE }
18
19export const lookFor = (nerd: boolean): Look => ({ nerd, glyph: nerd ? NERD : SAFE })
20
21// The icons setting every drawing mod declares in its userConfig.
22export type IconMode = 'auto' | 'nerd' | 'safe'
23
24export const terminalSettings = (localAppData: string) => [
25  `${localAppData}/Packages/Microsoft.WindowsTerminal_8wekyb3d8bbwe/LocalState/settings.json`,
26  `${localAppData}/Microsoft/Windows Terminal/settings.json`,
27]
28
29// Matches "Fira Code Nerd Font Mono" and the patched suffixes NF, NFM and NFP, as in "FiraCodeNFM-Reg".
30export const isNerdName = (face: string) => /nerd|\bNFM?\b/i.test(face) || /NF[MP]?(?![A-Za-z])/.test(face)
31
32export const isNerdFace = (settingsText: string) => [...settingsText.matchAll(/"face"\s*:\s*"([^"]*)"/g)].some(match => isNerdName(match[1]!))
33
34// The variables that name the terminal: WT_SESSION from Windows Terminal, TERM_PROGRAM from programs such as mintty and iTerm2.
35export type TerminalEnv = Partial<
36  Record<'WT_SESSION' | 'TERM_PROGRAM' | 'ITERM_PROFILE' | 'KITTY_WINDOW_ID' | 'ALACRITTY_WINDOW_ID' | 'GNOME_TERMINAL_SCREEN' | 'KONSOLE_VERSION', string | undefined>
37>
38
39// What detection reads: files by path, and the stdout of a command that exits 0 (it throws otherwise).
40export type Host = { localAppData: string; home: string; read: (path: string) => Promise<string>; run: (argv: string[]) => Promise<string> }
41
42// mintty inherits WT_SESSION from a Windows Terminal tab, so only an unset or Windows Terminal TERM_PROGRAM counts.
43export const isWindowsTerminal = ({ WT_SESSION, TERM_PROGRAM }: TerminalEnv) =>
44  Boolean(WT_SESSION) && (!TERM_PROGRAM || /windows\s*terminal/i.test(TERM_PROGRAM))
45
46// Terminals whose font sits in a text config: the first file that names a font wins, and within a file the first pattern.
47const CONFIG_TERMINALS: { is: (env: TerminalEnv) => boolean; files: (host: Host) => string[]; faces: RegExp[] }[] = [
48  {
49    is: env => env.TERM_PROGRAM === 'vscode',
50    files: ({ home, localAppData }) => [
51      `${home}/Library/Application Support/Code/User/settings.json`,
52      `${home}/.config/Code/User/settings.json`,
53      `${localAppData.replace(/\/Local$/, '/Roaming')}/Code/User/settings.json`,
54    ],
55    faces: [/"terminal\.integrated\.fontFamily"\s*:\s*"([^"]*)"/, /"editor\.fontFamily"\s*:\s*"([^"]*)"/],
56  },
57  {
58    is: env => env.TERM_PROGRAM === 'ghostty',
59    files: ({ home }) => [`${home}/.config/ghostty/config`, `${home}/Library/Application Support/com.mitchellh.ghostty/config`],
60    faces: [/^\s*font-family\s*=\s*"?([^"\n#]+)/m],
61  },
62  {
63    is: env => Boolean(env.KITTY_WINDOW_ID),
64    files: ({ home }) => [`${home}/.config/kitty/kitty.conf`],
65    faces: [/^\s*font_family\s+(.+)$/m],
66  },
67  {
68    is: env => env.TERM_PROGRAM === 'WezTerm',
69    files: ({ home }) => [`${home}/.wezterm.lua`, `${home}/.config/wezterm/wezterm.lua`],
70    faces: [/wezterm\.font(?:_with_fallback)?\(\s*\{?\s*["']([^"']+)["']/],
71  },
72  {
73    is: env => Boolean(env.ALACRITTY_WINDOW_ID),
74    files: ({ home }) => [`${home}/.config/alacritty/alacritty.toml`],
75    faces: [/\[font\.normal\][^[]*?family\s*=\s*"([^"]+)"/],
76  },
77]
78
79// iTerm2 keeps its profiles in a plist; plutil prints the part asked for. The profile is the one this window runs, else the default.
80async function itermFace(env: TerminalEnv, host: Host) {
81  const plist = `${host.home}/Library/Preferences/com.googlecode.iterm2.plist`
82  const profiles: { Name?: string; Guid?: string; 'Normal Font'?: string }[] = JSON.parse(await host.run(['plutil', '-extract', 'New Bookmarks', 'json', '-o', '-', plist]))
83  const named = profiles.find(profile => profile.Name === env.ITERM_PROFILE)
84  if (named) return named['Normal Font']
85  const guid = (await host.run(['plutil', '-extract', 'Default Bookmark Guid', 'raw', '-o', '-', plist])).trim()
86  return profiles.find(profile => profile.Guid === guid)?.['Normal Font']
87}
88
89// GNOME Terminal keeps the default profile's font in dconf, and ignores it while the profile uses the system font.
90async function gnomeFace(host: Host) {
91  const unquote = (text: string) => text.trim().replace(/^'|'$/g, '')
92  const schema = `org.gnome.Terminal.Legacy.Profile:/org/gnome/terminal/legacy/profiles:/:${unquote(await host.run(['gsettings', 'get', 'org.gnome.Terminal.ProfilesList', 'default']))}/`
93  if (unquote(await host.run(['gsettings', 'get', schema, 'use-system-font'])) === 'true') return undefined
94  return unquote(await host.run(['gsettings', 'get', schema, 'font']))
95}
96
97// Konsole names its profile in konsolerc, and the profile file holds `Font=Name,size,...`.
98async function konsoleFace(host: Host) {
99  const profile = /^DefaultProfile=(.+)$/m.exec(await host.read(`${host.home}/.config/konsolerc`))?.[1]?.trim()
100  return profile ? /^Font=([^,\n]+)/m.exec(await host.read(`${host.home}/.local/share/konsole/${profile}`))?.[1] : undefined
101}
102
103async function configFace(terminal: (typeof CONFIG_TERMINALS)[number], host: Host) {
104  for (const path of terminal.files(host)) {
105    const text = await host.read(path).catch(() => '')
106    for (const pattern of terminal.faces) {
107      const face = pattern.exec(text)?.[1]
108      if (face) return face
109    }
110  }
111  return undefined
112}
113
114function terminalFace(env: TerminalEnv, host: Host) {
115  if (env.TERM_PROGRAM === 'iTerm.app') return itermFace(env, host)
116  if (env.GNOME_TERMINAL_SCREEN) return gnomeFace(host)
117  if (env.KONSOLE_VERSION) return konsoleFace(host)
118  const terminal = CONFIG_TERMINALS.find(candidate => candidate.is(env))
119  return terminal ? configFace(terminal, host) : Promise.resolve(undefined)
120}
121
122// Every mod looks up the font on start at once, so a lookup can fail by chance; a third failure throws and the caller keeps its saved answer.
123async function retried<T>(attempt: () => Promise<T>, tries = 3): Promise<T> {
124  for (let left = tries - 1; ; left--) {
125    try {
126      return await attempt()
127    } catch (error) {
128      if (left === 0) throw error
129    }
130  }
131}
132
133// Nerd glyphs only when the terminal this window runs in names a Nerd font; an unknown terminal stays safe, and a lookup that keeps failing throws.
134export async function detectNerd(env: TerminalEnv, host: Host) {
135  if (isWindowsTerminal(env)) {
136    for (const path of terminalSettings(host.localAppData)) {
137      if (isNerdFace(await host.read(path).catch(() => ''))) return true
138    }
139    return false
140  }
141  const face = await retried(() => terminalFace(env, host))
142  return face !== undefined && isNerdName(face)
143}
144
145// `text` led by its icon in Nerd mode; the text alone in safe mode or when the name has no icon.
146export const withIcon = (look: Look, name: string, text: string) => (look.glyph.icon[name] ? `${look.glyph.icon[name]} ${text}` : text)
147
148// A status note with its leading mark (MARK.warn or MARK.ok from theme.ts) drawn as this look's glyph.
149export function withMark(look: Look, note: string) {
150  if (note.startsWith('! ')) return `${look.glyph.warn} ${note.slice(2)}`
151  if (note.startsWith('✓ ')) return `${look.glyph.ok} ${note.slice(2)}`
152  return note
153}
154
155type Elements = { Box: (props: any) => RenderElement; Text: (props: any) => RenderElement }
156
157// A pane's title line: the name's icon in Nerd mode, the safe mark otherwise.
158export const paneTitle = ({ Text }: Elements, look: Look, icon: string, text: string) => (
159  <Text key="pane-title" color={THEME.accent} bold>{`${look.glyph.icon[icon] ?? look.glyph.title} ${text}`}</Text>
160)
161
162// One line of text in `color` with every separator dot in the separator colour, since a dot in the text's colour reads as part of the words.
163export function sepText({ Text }: Pick<Elements, 'Text'>, text: string, color: string, key: string, props: Record<string, unknown> = {}) {
164  return (
165    <Text key={key} {...props}>
166      {text.split(MARK.sep).flatMap((part, at) => [
167        ...(at > 0 ? [<Text key={`${key}-sep-${at}`} color={style.colors.pills.separator}>{MARK.sep}</Text>] : []),
168        <Text key={`${key}-part-${at}`} color={color}>{part}</Text>,
169      ])}
170    </Text>
171  )
172}
173
174// The footer rows every pane reserves at its bottom: a rule, then the hovered control's glyph, label and what it does.
175export const FOOTER_ROWS = 2
176
177// A Button cannot post a tip, so its tip is drawn hidden and shown by the hover group the Button's pill shares.
178export type Reveal = { scope: string; tip: Tip }
179
180const tipRow = ({ Box, Text }: Elements, key: string, tip: Tip | null, columns: number, extra: Record<string, unknown> = {}) => (
181  <Box key={key} flexDirection="row" paddingX={1} width={columns} height={1} backgroundColor={THEME.bg} {...extra}>
182    {tip ? <Text color={THEME.accent} backgroundColor={THEME.bg}>{tip.glyph}</Text> : null}
183    {tip ? <Box flexShrink={0}><Text color={THEME.onSurface} backgroundColor={THEME.bg}>{` ${tip.label}`}</Text></Box> : null}
184    {tip ? sepText({ Text }, ` ${MARK.sep} ${tip.text}`, THEME.muted, `${key}-text`, { backgroundColor: THEME.bg, wrap: 'truncate-end' }) : <Text backgroundColor={THEME.bg}> </Text>}
185  </Box>
186)
187
188// Pinned at the pane bottom (scroll offset plus body rows) on its own background, so no row shows through; the row under the rule stays blank with no tip, so nothing jumps.
189export function paneFooter(elements: Elements, tip: Tip | null, columns: number, scroll: { offset: number; bodyRows: number }, reveals: Reveal[] = []) {
190  const { Box, Text } = elements
191  return (
192    <Box key="pane-footer" position="absolute" top={Math.max(0, scroll.offset + scroll.bodyRows - FOOTER_ROWS)} left={0} width={columns} height={FOOTER_ROWS} flexDirection="column" backgroundColor={THEME.bg}>
193      <Text key="footer-rule" color={THEME.border} backgroundColor={THEME.bg}>{MARK.rule.repeat(Math.max(1, columns))}</Text>
194      {tipRow(elements, 'footer-tip', tip, columns)}
195      {reveals.map((reveal, at) =>
196        tipRow(elements, `footer-reveal-${at}`, reveal.tip, columns, { position: 'absolute', top: 1, left: 0, display: 'none', hover: { scope: reveal.scope, display: 'flex' } }),
197      )}
198    </Box>
199  )
200}
201
202// A pill's leading icon, mark or digit, or its count: a rounded subpill washed from `hue` into the `fill` it sits on.
203export function subpill({ Box, Text }: Elements, nerd: boolean, fill: string, text: string, key: string, hue: string = THEME.accent) {
204  const bg = mix(hue, fill, WASH)
205  return (
206    <Box key={key} flexDirection="row" flexShrink={0}>
207      {nerd ? <Text key={`${key}-left`} color={bg} backgroundColor={fill}>{CAPS.left}</Text> : null}
208      <Text key={`${key}-text`} color={hue} backgroundColor={bg} bold>{nerd ? text : ` ${text} `}</Text>
209      {nerd ? <Text key={`${key}-right`} color={bg} backgroundColor={fill}>{CAPS.right}</Text> : null}
210    </Box>
211  )
212}
213
214// A section's heading: a surface3 pill led by its icon as a subpill in Nerd mode, bold accent text in safe mode.
215export function heading(elements: Elements, look: Look, key: string, icon: string, title: string) {
216  const { Box, Text } = elements
217  if (!look.nerd) {
218    return (
219      <Box key={`${key}-head`}>
220        <Text color={THEME.accent} bold>{title}</Text>
221      </Box>
222    )
223  }
224  const glyph = look.glyph.icon[icon]
225  return (
226    <Box key={`${key}-head`} flexDirection="row">
227      <Text color={THEME.surface3}>{CAPS.left}</Text>
228      <Box key={`${key}-head-body`} flexDirection="row" backgroundColor={THEME.surface3}>
229        {glyph ? subpill(elements, true, THEME.surface3, glyph, `${key}-head-icon`) : null}
230        <Text color={THEME.onSurface} bold>{glyph ? ` ${title}` : title}</Text>
231      </Box>
232      <Text color={THEME.surface3}>{CAPS.right}</Text>
233    </Box>
234  )
235}
236
237// A card's fill: surface1 washed lightly toward its hue, under a neutral border. Pills on the card sit on it.
238export const cardFill = (hue: string = THEME.accent) => mix(THEME.surface1, hue, 0.06)
239
240// The row above a card, painted on bg so a scrolled card above never shows through the gap.
241export function cardGap({ Box }: Elements, key: string, bg: string = THEME.bg) {
242  return <Box key={`${key}-gap`} height={1} flexShrink={0} backgroundColor={bg} />
243}
244
245// A card's title laid over its top border after the corner (`top` is the border's row); Pane refuses borderTop, so the border stays whole underneath.
246export function titleEdge(elements: Elements, look: Look, key: string, icon: string, title: string, hint = '', top = 1, edge: string = THEME.border) {
247  const { Box, Text } = elements
248  const glyph = look.nerd ? look.glyph.icon[icon] : undefined
249  return (
250    <Box key={`${key}-edge`} position="absolute" top={top} left={1} flexDirection="row" height={1} backgroundColor={THEME.bg}>
251      <Text color={edge}>{`${MARK.rule} `}</Text>
252      {glyph ? <Text color={THEME.accent}>{`${glyph} `}</Text> : null}
253      {sepText(elements, title, THEME.onSurface, `${key}-title`, { bold: true })}
254      {hint ? sepText(elements, ` ${hint}`, THEME.muted, `${key}-hint`) : null}
255      <Text> </Text>
256    </Box>
257  )
258}
259
260// A round card tinted toward `hue`, its title (and muted `hint`) set into the top edge, `right` (such as Fold all) at that edge's end.
261export function section(elements: Elements, look: Look, key: string, icon: string, title: string, body: unknown, hue: string = THEME.accent, { hint = '', right }: { hint?: string; right?: unknown } = {}) {
262  const { Box } = elements
263  return (
264    <Box key={`${key}-block`} flexDirection="column">
265      {cardGap(elements, key)}
266      <Box key={key} flexDirection="column" borderStyle="round" borderColor={THEME.border} backgroundColor={cardFill(hue)} paddingX={1}>
267        {body}
268      </Box>
269      {title ? titleEdge(elements, look, key, icon, title, hint) : null}
270      {right ? (
271        <Box key={`${key}-edge-right`} position="absolute" top={1} right={1} flexDirection="row" height={1} backgroundColor={THEME.bg}>
272          {right}
273        </Box>
274      ) : null}
275    </Box>
276  )
277}
278
279
hooks/pills.tsx 167 lines
1// hooks/pills.tsx, by v-amorim
2// Shared pill row; every mod that draws in the row carries the same copy.
3import type { RenderElement } from 'claude-code'
4
5import { entryCells, mix, type EntryProps, type Tip } from './entry'
6import style from './style'
7import { CAPS, decodeAll, THEME, WASH } from './theme'
8
9// The menu group on Moonlight surface-3, readouts on surface-2.
10export const PILL = { primary: style.colors.pills.primary, secondary: style.colors.pills.secondary }
11
12// Menu entries left to right, by the id in their `menu-pad-<id>` Box key; an unknown id goes last.
13const ENTRY_ORDER = ['manager', 'skills', 'prompts', 'filetree', 'progress', 'headsup']
14
15type Node = { props?: Record<string, unknown>; children?: unknown[] } | null | undefined
16type Elements = { Box: (props: any) => RenderElement; Text: (props: any) => RenderElement }
17
18const keyOf = (node: unknown) => (node as Node)?.props?.key as string | undefined
19const childrenOf = (node: unknown) => (node as Node)?.children ?? []
20
21// Splits next(e) into the menu entries other mods drew, the rest of the row, what else the band holds, and the hover tip under the row.
22export function splitRow(others: unknown) {
23  if (keyOf(others) !== 'menu-rows') return { group: [] as unknown[], row: [] as unknown[], rest: [others], tip: null as unknown }
24  const children = childrenOf(others)
25  const rest = children.filter(child => keyOf(child) !== 'menu-row' && keyOf(child) !== 'menu-tip')
26  const pills = childrenOf(children.find(child => keyOf(child) === 'menu-row'))
27  const groupPill = pills.find(child => keyOf(child) === 'menu-group')
28  const body = childrenOf(groupPill).find(child => keyOf(child) === 'menu-group-body')
29  return {
30    // A mod still running an older copy may draw `menu-group-sep-*` separators between entries; the group drops them.
31    group: childrenOf(body).filter(child => !keyOf(child)?.startsWith('menu-group-sep')),
32    row: pills.filter(child => keyOf(child) !== 'menu-group'),
33    rest,
34    tip: children.find(child => keyOf(child) === 'menu-tip') ?? null,
35  }
36}
37
38// The one-line tip above the pill row, filled while the pointer is on an entry: its "Name:" in bold accent, the rest muted.
39export const tipLine = ({ Box, Text }: Elements, text: string) => {
40  const at = text.indexOf(': ')
41  return (
42    <Box key="menu-tip" paddingLeft={2} flexDirection="row">
43      {at < 0 ? null : <Text color={THEME.accent} bold>{text.slice(0, at + 1)}</Text>}
44      <Text color={THEME.muted}>
45        {(at < 0 ? text : text.slice(at + 1)).split(style.marks.sep).flatMap((part, index) => (index === 0 ? [part] : [<Text key={`sep-${index}`} color={style.colors.pills.separator}>{style.marks.sep}</Text>, part]))}
46      </Text>
47    </Box>
48  )
49}
50
51// One pill with its own caps where the font has them; `on` is what it sits on, `hover` lights the caps with a Button scoped `key`.
52export function pill({ Box, Text }: Elements, nerd: boolean, bg: string, body: unknown, key = 'menu-pill', on?: string, hover?: string) {
53  const under = on ? { backgroundColor: on } : {}
54  const lit = hover ? { hover: { scope: key, color: hover } } : {}
55  return (
56    <Box key={key} flexDirection="row">
57      {nerd ? <Text key={`${key}-cap-left`} color={bg} {...under} {...lit}>{CAPS.left}</Text> : null}
58      <Box key={`${key}-body`} flexDirection="row" backgroundColor={bg}>
59        {body}
60      </Box>
61      {nerd ? <Text key={`${key}-cap-right`} color={bg} {...under} {...lit}>{CAPS.right}</Text> : null}
62    </Box>
63  )
64}
65
66// mod-menu's `labels`: `always` icon and label, `never` the icon alone, `auto` the icon alone while the row overflows (Nerd only; safe keeps labels).
67export type LabelsMode = 'auto' | 'always' | 'never'
68
69// The value of `{ plugin: 'mod-menu', key: 'labels' }` as a mode; a missing or unknown value is `auto`, so a mod works without mod-menu.
70export const labelsOf = (value: unknown): LabelsMode => (value === 'always' || value === 'never' ? value : 'auto')
71
72type ClientNode = { type: 'Client'; props: { props: Record<string, unknown> } }
73const isClient = (node: unknown): node is ClientNode => (node as { type?: string } | null)?.type === 'Client'
74
75// The cells a drawn row takes with every label shown: a Client that may go compact says its own `cells`, or is an entry.tsx one.
76const cellsOf = (node: unknown): number => {
77  if (Array.isArray(node)) return node.reduce((sum: number, each) => sum + cellsOf(each), 0)
78  if (typeof node === 'string') return [...node].length
79  if (!node || typeof node !== 'object') return 0
80  if (isClient(node)) {
81    const { props } = node.props
82    return typeof props.cells === 'number' ? props.cells : entryCells({ ...(props as EntryProps), compact: false })
83  }
84  // A `menu-flash` word comes and goes with the pointer; counting it would flip the row under the pointer.
85  const kids = childrenOf(node).filter(each => each && !keyOf(each)?.startsWith('menu-flash'))
86  return kids.reduce((sum: number, each) => sum + cellsOf(each), Number((node as Node)?.props?.columnGap ?? 0) * Math.max(0, kids.length - 1))
87}
88
89const compacted = (node: unknown): unknown => {
90  if (Array.isArray(node)) return node.map(compacted)
91  if (!node || typeof node !== 'object') return node
92  if (isClient(node)) return node.props.props.compact === false ? { ...node, props: { ...node.props, props: { ...node.props.props, compact: true } } } : node
93  const kids = (node as Node)?.children
94  return kids ? { ...node, children: kids.map(compacted) } : node
95}
96
97// A panel's entry in the menu group, keyed `menu-pad-<id>` for the row's order; the open one is a pill of its own.
98export function menuEntry({ Box, Client, Text }: Elements & { Client: (props: any) => RenderElement }, nerd: boolean, id: string, { labels, ...props }: Omit<EntryProps, 'id' | 'bg' | 'tight'> & { labels?: LabelsMode }) {
99  // The open panel reads as selected, like an On switch: ok text on ok washed into the group fill.
100  const bg = props.isOpen ? mix(THEME.ok, PILL.primary, WASH) : PILL.primary
101  const glyph = (style.glyphs.nerd.icon as Record<string, string>)[`menu-${id}`]
102  const icon = nerd && !props.lead && glyph ? decodeAll(glyph) : undefined
103  // The icon sits tight against the cap, so the label that follows brings its own gap.
104  const shown = icon ? { lead: icon, label: labels === 'never' ? '' : ` ${props.label}`, ...(labels === 'auto' ? { compact: false } : {}) } : {}
105  // On Nerd the caps take the label's padding cells, so an entry is as wide as before the caps.
106  const entry = <Client key={`menu-entry-${id}`} module="./entry.tsx" height={1} props={{ id, bg, ...props, ...(props.isOpen ? { color: THEME.ok } : {}), ...shown, nerd, tight: nerd } satisfies EntryProps} />
107  return (
108    <Box key={`menu-pad-${id}`} flexDirection="row">
109      {/* A closed entry gets caps in the group's own color, so opening a panel never shifts its neighbours. */}
110      {pill({ Box, Text }, nerd, bg, entry, props.isOpen ? `menu-open-${id}` : `menu-closed-${id}`, PILL.primary)}
111    </Box>
112  )
113}
114
115// The band above the prompt: what else it holds (Claude Code's own effort line), then the pill row, so the pills sit on the input.
116export function pillRow(elements: Elements & { Client?: (props: any) => RenderElement }, nerd: boolean, group: unknown[], row: unknown[], rest: unknown[], tip: unknown = null, columns?: number) {
117  const { Box, Client, Text } = elements
118  // Load order changes with an install or a toggle, so entries sort by a fixed rank.
119  const rank = (entry: unknown) => {
120    const at = ENTRY_ORDER.indexOf(String(keyOf(entry) ?? '').replace(/^menu-pad-/, ''))
121    return at < 0 ? ENTRY_ORDER.length : at
122  }
123  // Like a choice bar: the entries side by side on one pill, the open panel's lifted into its own, a divider Client between neighbours.
124  const sorted = [...group].sort((a, b) => rank(a) - rank(b))
125  const entries = Client
126    ? sorted.flatMap((entry, at) => (at === 0 ? [entry] : [<Client key={`menu-group-sep-${at}`} module="./entry.tsx" height={1} props={{ id: `sep-${at}`, label: '', isOpen: false, bg: PILL.primary, rail: true } satisfies EntryProps} />, entry]))
127    : sorted
128  const pills = (
129    <Box key="menu-row" flexDirection="row" columnGap={1}>
130      {group.length > 0 ? pill(elements, nerd, PILL.primary, entries, 'menu-group') : null}
131      {row}
132    </Box>
133  )
134  // Measured with every label shown, so a row cut to icons never grows back into a row that does not fit.
135  const fits = columns === undefined || cellsOf(pills) <= columns
136  return (
137    <Box key="menu-rows" flexDirection="column">
138      {rest}
139      {/* Above the row, kept even when empty so nothing above jumps when a tip appears. */}
140      {tip ?? tipLine(elements, ' ')}
141      {fits ? pills : compacted(pills)}
142    </Box>
143  )
144}
145
146// One chip per view of a panel (its list, its settings), standing in for the title: id, label, icon glyph, an optional count and an optional hover tip.
147export type Tab = { id: string; label: string; icon: string; isOpen: boolean; count?: number; tip?: Tip }
148
149// A panel's title line: the open view on surface3 and bold, the others on surface2 and muted; a click swaps the view.
150export function tabRow({ Box, Client, Text }: Elements & { Client: (props: any) => RenderElement }, nerd: boolean, tabs: Tab[], still: boolean) {
151  return (
152    <Box key="tab-row" flexDirection="row" columnGap={1}>
153      {tabs.map(tab => {
154        const bg = tab.isOpen ? THEME.surface3 : THEME.surface2
155        // Client props refuse an undefined value, so a tab without a count or tip leaves the key out.
156        const countColor = tab.isOpen ? THEME.accent : style.colors.pills.readout
157        const badge = tab.count === undefined ? {} : { badge: { text: String(tab.count), color: countColor, bg: mix(countColor, bg, WASH) } }
158        const tip = tab.tip ? { tip: tab.tip } : {}
159        const entry = (
160          <Client key={tab.id} module="./entry.tsx" height={1} props={{ id: tab.id, label: tab.label, isOpen: tab.isOpen, bg, idle: THEME.muted, still, subLead: tab.icon, subLeadColor: THEME.accent, nerd, ...(tab.isOpen ? { color: THEME.secondary } : {}), ...badge, ...tip } satisfies EntryProps} />
161        )
162        return pill({ Box, Text }, nerd, bg, entry, `${tab.id}-pill`)
163      })}
164    </Box>
165  )
166}
167
hooks/settings.tsx 297 lines
1// hooks/settings.tsx, by v-amorim
2// Shared settings body; every mod carries the same copy.
3import type { ConfigRow, ConfigValue, RenderElement } from 'claude-code'
4
5import type { EntryProps, Tip } from './entry'
6import { cardFill, cardGap, section, sepText, type Look, type Reveal } from './look'
7import style from './style'
8import { CAPS, fill, MARK, THEME } from './theme'
9
10// A button a mod offers beside its settings: runs `/<command> <args>`, declared in its showcase.json.
11// isLocal marks the drawing mod's own command, which it calls directly, so Claude working does not hold it.
12export type Action = { label: string; command: string; args?: string; isLocal?: boolean }
13
14type Elements = { Box: (props: any) => RenderElement; Button: (props: any) => RenderElement; Client: (props: any) => RenderElement; Text: (props: any) => RenderElement }
15
16export type SettingsProps = {
17  // The mod's own /config rows (`<plugin>.<field>`), as $.config.list() returns them.
18  rows: ConfigRow[]
19  actions: Action[]
20  // Another mod's command run while Claude works only queues as a prompt, so those actions go inert.
21  working: boolean
22  run: (action: Action) => unknown
23  // The animations setting is off: a clicked control does not shimmer.
24  still?: boolean
25  // Hover tips by setting field (the key after the dot); a setting without one shows its description's first sentence.
26  tips?: Record<string, string>
27  // The mod's example cards, from its showcase.json, so its own panel and mod-manager draw the same ones.
28  previews?: Previews
29  // The mod's plugin.json userConfig, read by its own panel and by mod-manager alike, for the min and max a number setting declares.
30  fields?: Record<string, Range>
31  // Text settings (by field) another mod edits in its own panel: the card shows a pill running that panel's command in place of "edit it in /config".
32  editors?: Record<string, Action>
33}
34
35export type Range = { min?: number; max?: number }
36
37// A title's trailing "(unit)" is what its number reads in; the head drops it and the value carries it.
38export function splitTitle(label: string) {
39  const match = /^(.*?)\s*\(([^()]+)\)$/.exec(label)
40  return match ? { name: match[1]!, unit: match[2]! } : { name: label, unit: '' }
41}
42
43// Large round counts read short, in thousands or millions.
44const short = (n: number) => (n >= 1_000_000 && n % 100_000 === 0 ? `${n / 1_000_000}M` : n >= 10_000 && n % 1000 === 0 ? `${n / 1000}K` : String(n))
45
46export function showNumber(value: number, unit: string) {
47  if (!unit) return String(value)
48  if (unit === '%') return `${value}%`
49  return `${short(value)} ${value === 1 ? unit.replace(/s$/, '') : unit}`
50}
51
52// About twenty clicks from one end of the range to the other, on a 1, 2 or 5 step; no range steps by one.
53export function stepOf({ min, max }: Range = {}) {
54  if (min === undefined || max === undefined || max <= min) return 1
55  const raw = (max - min) / 20
56  const power = 10 ** Math.floor(Math.log10(raw))
57  const nearest = [1, 2, 5, 10].map(factor => factor * power).reduce((best, step) => (Math.abs(step - raw) < Math.abs(best - raw) ? step : best))
58  return Math.max(1, nearest)
59}
60
61export function stepNumber(value: number, by: 1 | -1, range: Range = {}) {
62  return Math.min(range.max ?? Infinity, Math.max(range.min ?? -Infinity, value + by * stepOf(range)))
63}
64
65type ThemeColor = keyof typeof THEME
66
67// A literal run of an example line, as the mod's previews test drew it from its real tree; a color is a theme name or a #hex.
68export type PreviewSpan = { text: string; color?: string; bg?: string; bold?: boolean }
69export type PreviewLine = { spans: PreviewSpan[] }
70export type Preview = { frame?: { border: string; bg: string }; nerd: PreviewLine[]; safe: PreviewLine[] }
71
72// showcase.json's "previews", written by `just previews`: per setting field, per value as text ("true", "false", an option), the card in each icon mode.
73export type Previews = Record<string, Record<string, Preview>>
74
75const shade = (color: string | undefined) => (color === undefined ? undefined : color in THEME ? THEME[color as ThemeColor] : color)
76
77// What the row's current value looks like, as a card under it; null when the mod drew none for that value.
78function exampleOf({ Box, Text }: Elements, look: Look, previews: Previews | undefined, row: ConfigRow, index: number) {
79  const entry = previews?.[row.key.slice(row.key.indexOf('.') + 1)]?.[String(row.value)]
80  const lines = look.nerd ? entry?.nerd : entry?.safe
81  if (!entry || !lines?.length) return null
82  // The lines carry their own padding and strip, so the frame adds none. The example sinks to bg, so it never matches the card it sits in.
83  const sunk = (color: string | undefined) => (color !== undefined && color === shade(entry.frame?.bg) ? THEME.bg : color)
84  return (
85    <Box key={`setting-preview-${index}`} flexDirection="column" borderStyle="round" borderColor={shade(entry.frame?.border) ?? THEME.border} backgroundColor={THEME.bg} marginTop={1}>
86      {lines.map((line, index) => (
87        <Text key={`line-${index}`} wrap="truncate-end">
88          {line.spans.length === 0 ? ' ' : line.spans.map((span, at) => <Text key={`span-${at}`} color={shade(span.color) ?? THEME.onSurface} {...(span.bg ? { backgroundColor: sunk(shade(span.bg)) } : {})} {...(span.bold ? { bold: true } : {})}>{span.text}</Text>)}
89        </Text>
90      ))}
91    </Box>
92  )
93}
94
95// The value as the panel shows it: on/off as words, a list as its items.
96const shown = (row: ConfigRow) =>
97  row.kind === 'boolean' ? (row.value === true ? style.text.on : style.text.off) : Array.isArray(row.value) ? row.value.join(', ') : String(row.value)
98
99// Words that stay capitalized when a description follows a tip's label and colon.
100const PROPER = new Set(['Claude', 'Haiku', 'Opus', 'Sonnet', 'Windows', 'Nerd'])
101
102// A setting's description as the tip after "Label: ": its first sentence, led by a lowercase letter unless that word is a proper noun.
103export function descriptionTip(description: string) {
104  const sentence = description.split('. ')[0]!.replace(/\.$/, '')
105  const word = /^[A-Z][a-z]+\b/.exec(sentence)?.[0]
106  return word && !PROPER.has(word) ? sentence[0]!.toLowerCase() + sentence.slice(1) : sentence
107}
108
109// A control is an Entry pill posting `setting:<key>=<value as JSON>`; the mod's ui.message hook saves it with settingChoice.
110const SETTING = 'setting:'
111const settingId = (row: ConfigRow, value: ConfigValue) => `${SETTING}${row.key}=${JSON.stringify(value)}`
112
113// The change a clicked settings control asks for, or null when the message is something else.
114export function settingChoice(data: unknown): { key: string; value: ConfigValue } | null {
115  const message = data as { type?: string; id?: string } | undefined
116  if (message?.type !== 'menu-entry' || !message.id?.startsWith(SETTING)) return null
117  const rest = message.id.slice(SETTING.length)
118  const at = rest.indexOf('=')
119  if (at === -1) return null
120  try {
121    return { key: rest.slice(0, at), value: JSON.parse(rest.slice(at + 1)) as ConfigValue }
122  } catch {
123    return null
124  }
125}
126
127// A pill already frames the glyph, so drop the brackets and padding of the standalone button (safe `[↻]`).
128const bare = (glyph: string) => glyph.replace(/[[\]\s]/g, '')
129
130// A pill on the card's tint, capped where the font has the caps; `hover` lights the caps with a Button scoped to the pill's key.
131const pillOn = ({ Box, Text }: Elements, look: Look, key: string, bg: string, body: unknown, on = THEME.surface1, hover?: string) => {
132  const lit = hover ? { hover: { scope: key, color: hover } } : {}
133  return (
134    <Box key={key} flexDirection="row" flexShrink={0}>
135      {look.nerd ? <Text key={`${key}-left`} color={bg} backgroundColor={on} {...lit}>{CAPS.left}</Text> : null}
136      {body}
137      {look.nerd ? <Text key={`${key}-right`} color={bg} backgroundColor={on} {...lit}>{CAPS.right}</Text> : null}
138    </Box>
139  )
140}
141
142export type Choice = { key: string; isOn: boolean; props: Pick<EntryProps, 'id' | 'label' | 'still' | 'tip' | 'subLead' | 'subLeadColor'> }
143
144// A control track: a surface3 pill on `on`, padded by a space each side in safe mode, where it has no caps.
145function track(elements: Elements, look: Look, key: string, on: string, parts: unknown[]) {
146  const { Box, Text } = elements
147  const space = (side: string) => (look.nerd ? null : <Text key={`${key}-space-${side}`} backgroundColor={THEME.surface3}> </Text>)
148  return pillOn(elements, look, `${key}-bar`, THEME.surface3, <Box key={`${key}-options`} flexDirection="row" backgroundColor={THEME.surface3}>{[space('left'), ...parts, space('right')]}</Box>, on)
149}
150
151// A choice in a track: the pick a lifted (surface-4) subpill in primary-70 (`ok`), the rest muted; `on` is what the track sits on.
152export function choiceBar(elements: Elements, look: Look, key: string, on: string, choices: Choice[]) {
153  const { Client } = elements
154  const entry = (choice: Choice, shape: Partial<EntryProps>) => (
155    <Client key={choice.key} module="./entry.tsx" height={1} props={{ ...choice.props, isOpen: choice.isOn, bg: THEME.surface3, ...shape } satisfies EntryProps} />
156  )
157  const options = choices.map(choice =>
158    choice.isOn
159      ? // Tight, so the subpill is as wide as an idle option: the caps (Nerd) or a space (safe) stand where its padding was.
160        pillOn(elements, look, `${choice.key}-chosen`, THEME.lifted, entry(choice, { bg: THEME.lifted, color: THEME.ok, idle: THEME.muted, nerd: look.nerd, tight: true }), THEME.surface3)
161      : entry(choice, { idle: THEME.muted }),
162  )
163  return track(elements, look, key, on, options)
164}
165
166const actionScope = (index: number) => `action-row-${index}`
167
168// The tip each live action shows in the pane footer on hover; a Button cannot post one, so paneFooter reveals it by the pill's hover group.
169export const actionReveals = (actions: Action[], look: Look, working: boolean): Reveal[] =>
170  actions.flatMap((action, index) =>
171    working && !action.isLocal ? [] : [{ scope: actionScope(index), tip: { glyph: bare(look.glyph.go), label: action.label, text: fill(style.text.runsCommand, { command: [action.command, action.args].filter(Boolean).join(' ') }) } }],
172  )
173
174const editorScope = (index: number) => `editor-row-${index}`
175
176const fieldOf = (row: ConfigRow) => row.key.slice(row.key.indexOf('.') + 1)
177
178// The footer tip of each live editor pill, like actionReveals.
179export const editorReveals = (rows: ConfigRow[], editors: Record<string, Action>, look: Look, working: boolean): Reveal[] =>
180  rows.flatMap((row, index) => {
181    const action = row.kind === 'text' && !row.isLocked ? editors[fieldOf(row)] : undefined
182    return action && !working ? [{ scope: editorScope(index), tip: { glyph: MARK.open, label: action.label, text: fill(style.text.runsCommand, { command: [action.command, action.args].filter(Boolean).join(' ') }) } }] : []
183  })
184
185// A pill with a plain Button so the press is a real one; the glyph sits in the label so the whole pill lights.
186const buttonPill = (elements: Elements, look: Look, scope: string, key: string, glyph: string, label: string, onPress: () => unknown) => {
187  const { Button } = elements
188  return pillOn(
189    elements,
190    look,
191    scope,
192    THEME.surface3,
193    <Button key={key} label={look.nerd ? `${glyph} ${label}` : ` ${glyph} ${label} `} plain hover={{ scope, color: THEME.accent, inverse: true }} onPress={onPress} />,
194    THEME.surface1,
195    THEME.accent,
196  )
197}
198
199// Edits the row in place: on/off flips, a choice is a bar, a number steps; text is edited in /config.
200function control(elements: Elements, look: Look, row: ConfigRow, index: number, still: boolean, tips?: Record<string, string>, fields?: Record<string, Range>) {
201  const { Client, Text } = elements
202  const key = `setting-${index}`
203  const text = tips?.[row.key.slice(row.key.indexOf('.') + 1)] ?? (row.description ? descriptionTip(row.description) : '')
204  // Client props refuse an undefined value, so no tip text means no key.
205  const tipFor = (glyph: string) => (text ? { tip: { glyph: bare(glyph), label: splitTitle(row.label).name, text } satisfies Tip } : {})
206  const entry = (suffix: string, label: string, value: ConfigValue, bg: string, color: string, isOpen: boolean, glyph = look.glyph.cycle) => (
207    <Client key={`${key}-${suffix}`} module="./entry.tsx" height={1} props={{ id: settingId(row, value), label, isOpen, bg, color, idle: color, still, ...tipFor(glyph) } satisfies EntryProps} />
208  )
209  if (row.isLocked) return <Text color={THEME.muted}>{`${shown(row)} ${bare(look.glyph.locked)} ${style.text.locked}`}</Text>
210  if (row.kind === 'boolean') {
211    // The switch: `━━●` on, `●━━` off, the knob a bare glyph joined to the line.
212    const isOn = row.value === true
213    const shape = isOn
214      ? { lead: MARK.track, leadColor: THEME.accent, label: MARK.knob, idle: THEME.ok }
215      : { lead: MARK.knob, leadColor: style.colors.pills.readout, label: MARK.track, idle: THEME.trackOff }
216    const flip = <Client key={`${key}-flip`} module="./entry.tsx" height={1} props={{ id: settingId(row, !isOn), isOpen: false, bg: THEME.surface3, tight: true, still, ...shape, ...tipFor(look.glyph.cycle) } satisfies EntryProps} />
217    return track(elements, look, key, cardFill(), [flip])
218  }
219  if (row.kind === 'choice' && row.options?.length) {
220    const choices = row.options.map((option, at) => ({ key: `${key}-option-${at}`, isOn: option === String(row.value), props: { id: settingId(row, option), label: option, still, ...tipFor(look.glyph.cycle) } }))
221    return choiceBar(elements, look, key, cardFill(), choices)
222  }
223  if (row.kind === 'number' && typeof row.value === 'number') {
224    const range = fields?.[row.key.slice(row.key.indexOf('.') + 1)]
225    const value = <Text key={`${key}-value`} color={THEME.ok} backgroundColor={THEME.lifted} bold>{look.nerd ? showNumber(row.value, splitTitle(row.label).unit) : ` ${showNumber(row.value, splitTitle(row.label).unit)} `}</Text>
226    return track(elements, look, key, cardFill(), [
227      entry('down', bare(look.glyph.down), stepNumber(row.value, -1, range), THEME.surface3, THEME.muted, false, look.glyph.down),
228      pillOn(elements, look, `${key}-value-pill`, THEME.lifted, value, THEME.surface3),
229      entry('up', bare(look.glyph.up), stepNumber(row.value, 1, range), THEME.surface3, THEME.muted, false, look.glyph.up),
230    ])
231  }
232  return <Text color={THEME.secondary}>{shown(row)}</Text>
233}
234
235// A setting's first row: the label bold on the left, its control anchored right.
236export const settingHead = ({ Box, Text }: Pick<Elements, 'Box' | 'Text'>, key: string, label: string, control: unknown) => (
237  <Box key={key} flexDirection="row" columnGap={2} justifyContent="space-between">
238    <Text color={THEME.onSurface} bold>{label}</Text>
239    {control}
240  </Box>
241)
242
243// A hairline across its box, under a setting's head row; a Client, since only a Client learns the width it gets.
244export const hairline = ({ Client }: Pick<Elements, 'Client'>, key: string, bg: string) => (
245  <Client key={key} module="./entry.tsx" width="100%" height={1} props={{ id: key, label: '', isOpen: false, bg, rule: true } satisfies EntryProps} />
246)
247
248// The Settings and Actions sections of a mod's panel or page: one round-bordered card per setting.
249export function settingsBody(elements: Elements, look: Look, { rows, actions, working, run, previews, tips, fields, editors = {}, still = false }: SettingsProps) {
250  const { Box, Text } = elements
251  const settings = [
252    rows.length === 0 ? <Text key="settings-none" color={THEME.muted}>{style.text.noSettings}</Text> : null,
253    ...rows.map((row, index) => {
254      const example = exampleOf(elements, look, previews, row, index)
255      const editor = row.kind === 'text' && !row.isLocked ? editors[fieldOf(row)] : undefined
256      const head = editor && !working ? (
257        <Box key={`setting-control-${index}`} flexDirection="row" columnGap={1}>
258          {control(elements, look, row, index, still, tips, fields)}
259          {buttonPill(elements, look, editorScope(index), `setting-editor-${index}`, MARK.open, editor.label, () => run(editor))}
260        </Box>
261      ) : (
262        control(elements, look, row, index, still, tips, fields)
263      )
264      const hint = row.kind !== 'text' ? '' : editor ? (working ? style.text.idleOnly : '') : style.text.editInConfig
265      const note = [row.description, hint].filter(Boolean).join(` ${MARK.sep} `)
266      return (
267        <Box key={`setting-row-${index}`} flexDirection="column" borderStyle="round" borderColor={THEME.border} backgroundColor={cardFill()} paddingX={1} marginTop={index === 0 ? 0 : 1}>
268          {settingHead(elements, `setting-head-${index}`, splitTitle(row.label).name, head)}
269          {row.description || example ? hairline(elements, `setting-rule-${index}`, cardFill()) : null}
270          {row.description ? (
271            sepText(elements, note, THEME.muted, `setting-text-${index}`)
272          ) : null}
273          {example}
274        </Box>
275      )
276    }),
277  ]
278  const buttons = actions.map((action, index) =>
279    working && !action.isLocal ? (
280      sepText(elements, `${look.glyph.go} ${action.label} ${MARK.sep} ${style.text.idleOnly}`, THEME.muted, `action-${index}`)
281    ) : (
282      buttonPill(elements, look, actionScope(index), `action-${index}`, bare(look.glyph.go), action.label, () => run(action))
283    ),
284  )
285  return (
286    <Box key="settings" flexDirection="column">
287      {/* The cards carry their own border, and the panel's open Settings tab already names them. */}
288      <Box key="settings-cards" flexDirection="column">
289        {cardGap(elements, 'settings')}
290        {settings}
291      </Box>
292      {actions.length > 0 ? section(elements, look, 'actions', 'actions', style.text.actions, buttons) : null}
293    </Box>
294  )
295}
296
297
hooks/toasts.tsx 87 lines
1// hooks/toasts.tsx, by v-amorim
2// Shared toast cards; every mod that toasts carries the same copy.
3import type { RenderElement } from 'claude-code'
4
5import type { Tip } from './entry'
6import style from './style'
7import { CAPS, MARK, mix, THEME } from './theme'
8
9type Elements = { Box: (props: any) => RenderElement; Client: (props: any) => RenderElement; Text: (props: any) => RenderElement }
10
11// The slice of a mod's Look the cards read, typed here because mod-menu has no look.tsx.
12type ToastLook = { nerd: boolean; glyph: { close: string } }
13
14export const TOAST_MS = 4000
15
16const DISMISS_TIP = { label: 'Dismiss', text: 'hides this message' }
17const MAX_TOASTS = 3
18
19// A mod's own toasts, newest last. A toast reaches only the hooks above its caller, so each mod catches its own.
20export class ToastList {
21  items: { id: number; text: string }[] = []
22  // The owning mod's animations setting is off, so the dismiss × does not fade.
23  isStill = false
24  private lastId = 0
25
26  // `prefix` keeps the dismiss Client keys apart from other mods' cards in the band.
27  constructor(readonly prefix: string) {}
28
29  add(text: string) {
30    const id = (this.lastId += 1)
31    this.items = [...this.items, { id, text }].slice(-MAX_TOASTS)
32    return id
33  }
34
35  drop(id: number) {
36    this.items = this.items.filter(toast => toast.id !== id)
37  }
38
39  // The toast a dismiss click names, or undefined when the click was not on one of these cards.
40  idOf(entryId: string) {
41    const head = `toast-${this.prefix}-`
42    return entryId.startsWith(head) ? Number(entryId.slice(head.length)) : undefined
43  }
44}
45
46// A pill already frames the glyph, so drop the brackets and padding of the standalone button (safe `[×]`).
47const bare = (glyph: string) => glyph.replace(/[[\]\s]/g, '')
48
49// Callers write `✓ Saved` or `! Failed`; the card draws the mark.
50function toneOf(text: string) {
51  for (const [mark, color, bold] of [[MARK.ok, THEME.good, false], [MARK.warn, THEME.alert, true]] as const) {
52    if (text.startsWith(mark)) return { mark, color, bold, text: text.slice(mark.length).trimStart() }
53  }
54  return { mark: MARK.bullet, color: THEME.accent, bold: false, text }
55}
56
57// The web toast as a band card: tinted icon square, "Title: detail" split bold and muted, an error edged in alert; closeTip is the × tip.
58export const toastCards = ({ Box, Client, Text }: Elements, list: ToastList, look?: ToastLook, closeTip: Pick<Tip, 'label' | 'text'> = DISMISS_TIP) =>
59  list.items.map(toast => {
60    const id = `toast-${list.prefix}-${toast.id}`
61    const tone = toneOf(toast.text)
62    const nerd = look?.nerd === true
63    const close = bare(look?.glyph.close ?? style.glyphs.safe.close)
64    const fill = mix(THEME.surface1, tone.color, 0.06)
65    const split = tone.text.indexOf(': ')
66    const [title, detail] = split > 0 ? [tone.text.slice(0, split), tone.text.slice(split + 2)] : [tone.text, '']
67    const edge = tone.color === THEME.alert ? mix(fill, THEME.alert, 0.33) : THEME.border
68    return (
69      <Box key={id} flexDirection="row" columnGap={1} paddingX={1} borderStyle="round" borderColor={edge} backgroundColor={fill}>
70        <Box key={`${id}-mark`} flexShrink={0}>
71          <Text color={tone.color} backgroundColor={mix(fill, tone.color, 0.15)} bold={tone.bold}>{` ${tone.mark} `}</Text>
72        </Box>
73        <Box key={`${id}-text`} flexGrow={1} flexShrink={1}>
74          <Text wrap="wrap">
75            <Text color={THEME.onSurface} bold>{title}</Text>
76            {detail ? <Text color={THEME.muted}>{` ${detail}`}</Text> : null}
77          </Text>
78        </Box>
79        <Box key={`${id}-close`} flexDirection="row" flexShrink={0}>
80          {nerd ? <Text key={`${id}-close-left`} color={THEME.surface3} backgroundColor={fill}>{CAPS.left}</Text> : null}
81          <Client key={id} module="./entry.tsx" height={1} props={{ id, label: close, isOpen: false, bg: THEME.surface3, still: list.isStill, tip: { glyph: close, ...closeTip } }} />
82          {nerd ? <Text key={`${id}-close-right`} color={THEME.surface3} backgroundColor={fill}>{CAPS.right}</Text> : null}
83        </Box>
84      </Box>
85    )
86  })
87