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…

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