One /mods pane listing every installed mod: each mod's state, settings and actions, drawn in Claude Code's own idiom

One /mods pane for every installed Claude Code mod. The pane lists every mod with its live state; open one for its settings and actions, all drawn alike from the pack's shared kit, in Claude Code's own idiom and the skin chosen in skins. The mods themselves register no slash commands, and every setting is the user's: the same in every session and project.
| View | Mod | What it holds |
|---|---|---|
| Overview | Every installed mod with its live state, and a way into its view | |
| Keep warm | cache-tax | Whether the prompt cache is warm and when it goes cold, keep-warm windows, every-session switch, the cold-send guard |
| Skins | skins | The skin with a live preview, the look, the content cards, the usage band, the colours, copy and gallery |
| Files | filetree | Open or close the file tree, show another folder, and every filetree setting |
| Agents | savvy-progress | Running, finished and planned subagents, the current flow, and the agents panel |
| Replay | replay-theater | Step through the last turn's edits |
| Reflect | reflect-mod | Rules found in your corrections, to save to CLAUDE.md or skip, and the pause switch |
| Components | Everything a mod can draw or do, named and live: every element with every prop and value (Box, Text, Button, Input, Select, Link, Code, Markdown, Svg, Raster, Image, Client), the theme colours, the sites a mod draws in, the calls outside a drawing (toast, notify, ask, status, copy, panes), and every named component of each installed mod, from its catalog | |
| Settings | The language of the pane and of every view (English or Português), and where settings live |
A mod that is not installed is not listed. 1 to 9 open a mod, 0 goes back, Tab and the arrows move, Esc closes.
From the claude-mods marketplace:
claude plugin marketplace add mvacoimbra/claude-mods
claude plugin install mods@claude-mods
The hub draws every tab itself from a description the mod hands it, since a press cannot cross from one mod's tree to another's. PROTOCOL.md has the contract: a noun on $ with panel and act, and its name in the hub's MODS table.
kit/ is the pack's component library: tones from the skin (tone.ts), stroke icons (icons.ts) and the rows, groups, controls, meters and notices every mod draws with (kit.tsx). Edit it here, then copy it into every mod:
dev/sync-kit.sh
claude plugin validate .
claude plugin test .
dev/preview/ draws captured trees as an HTML approximation of the desktop app (render.mjs) and screenshots it (shot.sh), to look at a change outside a live session.
hooks/register.tsx 529 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, Timer } from 'claude-code'
3
4import { action, choice, dot, empty, field, icon, kitOf, meter, picker, row, section, stack, staticTree, status, swatch, text, toggle } from '../kit/kit'
5import type { Kit } from '../kit/kit'
6import type { IconName } from '../kit/icons'
7import { toneOf } from '../kit/tone'
8import type { KitSkin, Tone } from '../kit/tone'
9import type { ModsActArgs, ModsActResult, ModsCatalogEntry, ModsItem, ModsLang, ModsPanel, ModsPanelArgs } from '../types'
10import { componentsTab, DEMO_START, DEMO_WORDS } from './components'
11import type { DemoCatalog, DemoState, DemoUi } from './components'
12import { HUB_STRINGS } from './i18n'
13import type { HubStrings } from './i18n'
14
15const PANE = 'mods'
16
17// The mods that join the pane, by the noun each adds to `$`, in list order: the plugin's
18// name, the name a person sees (a mod's own panel title wins once it is loaded), its icon.
19type ModInfo = { noun: string; name: string; title: Readonly<Record<ModsLang, string>>; icon: IconName }
20
21export const MODS: readonly ModInfo[] = [
22 { noun: 'cachetax', name: 'cache-tax', title: { en: 'Keep warm', pt: 'Manter aquecido' }, icon: 'cache' },
23 { noun: 'skins', name: 'skins', title: { en: 'Skins', pt: 'Skins' }, icon: 'skins' },
24 { noun: 'filetree', name: 'filetree', title: { en: 'Files', pt: 'Arquivos' }, icon: 'folder' },
25 { noun: 'savvy', name: 'savvy-progress', title: { en: 'Agents', pt: 'Agentes' }, icon: 'agents' },
26 { noun: 'replay', name: 'replay-theater', title: { en: 'Replay', pt: 'Replay' }, icon: 'replay' },
27 { noun: 'reflect', name: 'reflect-mod', title: { en: 'Reflect', pt: 'Reflect' }, icon: 'reflect' },
28]
29
30// The mods whose components the Components tab names; blast-radius has a catalog and no view.
31export const CATALOGS: readonly ModInfo[] = [...MODS, { noun: 'blast', name: 'blast-radius', title: { en: 'Blast radius', pt: 'Blast radius' }, icon: 'shield' }]
32
33type Loaded = { noun: string; panel: ModsPanel; failed?: string }
34
35const tabAtom = atom({ plugin: 'mods', key: 'tab' } as const, 'overview')
36const revisionAtom = atom({ plugin: 'mods', key: 'revision' } as const, 0)
37const langAtom = atom({ plugin: 'mods', key: 'lang' } as const, 'en' as ModsLang)
38const demoAtom = atom({ plugin: 'mods', key: 'demo' } as const, DEMO_START as DemoState)
39
40// Each mod's noun, spelled out: `$` is only ever called by name. A mod that is not
41// installed has no noun on `$`, so the call throws before reaching it.
42async function panelOf($: EngineInterface, noun: string, args: ModsPanelArgs): Promise<ModsPanel> {
43 switch (noun) {
44 case 'cachetax':
45 return $.cachetax.panel(args)
46 case 'skins':
47 return $.skins.panel(args)
48 case 'filetree':
49 return $.filetree.panel(args)
50 case 'savvy':
51 return $.savvy.panel(args)
52 case 'replay':
53 return $.replay.panel(args)
54 case 'reflect':
55 return $.reflect.panel(args)
56 default:
57 throw new Error(`no mod named ${noun}`)
58 }
59}
60
61async function actOn($: EngineInterface, noun: string, args: ModsActArgs): Promise<ModsActResult> {
62 switch (noun) {
63 case 'cachetax':
64 return $.cachetax.act(args)
65 case 'skins':
66 return $.skins.act(args)
67 case 'filetree':
68 return $.filetree.act(args)
69 case 'savvy':
70 return $.savvy.act(args)
71 case 'replay':
72 return $.replay.act(args)
73 case 'reflect':
74 return $.reflect.act(args)
75 default:
76 throw new Error(`no mod named ${noun}`)
77 }
78}
79
80async function catalogOf($: EngineInterface, noun: string, args: ModsPanelArgs): Promise<ModsCatalogEntry[]> {
81 switch (noun) {
82 case 'cachetax':
83 return $.cachetax.catalog(args)
84 case 'skins':
85 return $.skins.catalog(args)
86 case 'filetree':
87 return $.filetree.catalog(args)
88 case 'savvy':
89 return $.savvy.catalog(args)
90 case 'replay':
91 return $.replay.catalog(args)
92 case 'reflect':
93 return $.reflect.catalog(args)
94 case 'blast':
95 return $.blast.catalog(args)
96 default:
97 throw new Error(`no mod named ${noun}`)
98 }
99}
100
101// What a call to a noun nobody added throws: reading its method off undefined, or calling a
102// method a mod's noun does not have.
103const isAbsent = (error: unknown): boolean => error instanceof TypeError && /\b(panel|act|catalog|palette)\b/.test(error.message)
104
105// The skin every mod wears, from skins; Claude Code's own colours without it.
106async function skinOf($: EngineInterface): Promise<KitSkin | null> {
107 try {
108 return await $.skins.palette()
109 } catch (error) {
110 if (!isAbsent(error)) {
111 $.ui.log(`mods: no palette from skins: ${messageOf(error)}`, { to: 'debug' })
112 }
113
114 return null
115 }
116}
117
118// The open Mods section's catalog: null entries when the mod is absent or has none.
119async function loadCatalog($: EngineInterface, noun: string, args: ModsPanelArgs, name: string, t?: Tone): Promise<DemoCatalog> {
120 try {
121 // Another mod's drawing reaches the hub as data: anything that would need a handler
122 // (a Button, a field) is drawn as its label, so one careless entry cannot blank the pane.
123 const entries = await catalogOf($, noun, args)
124
125 return { noun, name, entries: entries.map(entry => ({ ...entry, tree: staticTree(entry.tree, t) })) }
126 } catch (error) {
127 return isAbsent(error) ? { noun, name, entries: null } : { noun, name, entries: null, failed: messageOf(error) }
128 }
129}
130
131const messageOf = (error: unknown): string => (error instanceof Error ? error.message : String(error))
132
133async function loadAll($: EngineInterface, args: ModsPanelArgs): Promise<Loaded[]> {
134 const loaded = await Promise.all(
135 MODS.map(async ({ noun, name }): Promise<Loaded | null> => {
136 try {
137 return { noun, panel: await panelOf($, noun, args) }
138 } catch (error) {
139 return isAbsent(error) ? null : { noun, panel: { title: name, sections: [] }, failed: messageOf(error) }
140 }
141 }),
142 )
143
144 return loaded.filter((mod): mod is Loaded => mod !== null)
145}
146
147// The pane's language lives in the user's store, so every session and project shows the
148// same one; the atom only carries it between draws of one session.
149async function langOf($: EngineInterface): Promise<ModsLang> {
150 const stored = await $.store.get('lang')
151
152 return stored === 'pt' ? 'pt' : 'en'
153}
154
155// The redraw tick of a view that counts down, and how often it runs.
156let ticker: Timer | undefined
157let tickMs = 0
158
159function setTicker($: EngineInterface, ms: number): void {
160 if (ms === tickMs) {
161 return
162 }
163
164 ticker?.cancel()
165 ticker = undefined
166 tickMs = ms
167
168 if (ms > 0) {
169 ticker = $.clock.every(ms, () => void update($, revisionAtom, n => n + 1))
170 }
171}
172
173// Swatches next to each other share one wrapped grid, and so do actions with no help, as one
174// row of buttons; every other item is a row of its own.
175type Swatch = Extract<ModsItem, { kind: 'swatch' }>
176type Action = Extract<ModsItem, { kind: 'action' }>
177type Run = { kind: 'one'; item: ModsItem; index: number } | { kind: 'swatches'; items: Swatch[] } | { kind: 'actions'; items: Action[] }
178
179const isBare = (item: ModsItem): item is Action => item.kind === 'action' && item.help === undefined
180
181function runsOf(list: readonly ModsItem[]): Run[] {
182 const runs: Run[] = []
183
184 list.forEach((item, index) => {
185 const last = runs.at(-1)
186
187 if (item.kind === 'swatch') {
188 if (last?.kind === 'swatches') {
189 last.items.push(item)
190 } else {
191 runs.push({ kind: 'swatches', items: [item] })
192 }
193 } else if (isBare(item)) {
194 if (last?.kind === 'actions') {
195 last.items.push(item)
196 } else {
197 runs.push({ kind: 'actions', items: [item] })
198 }
199 } else {
200 runs.push({ kind: 'one', item, index })
201 }
202 })
203
204 return runs
205}
206
207// Only a live state earns colour and a dot: ok, warn or err. Accent and muted read as plain.
208const isState = (tone: string | undefined): tone is 'ok' | 'warn' | 'err' => tone === 'ok' || tone === 'warn' || tone === 'err'
209
210// Choices short enough to sit at a row's end; the rest wrap under the label.
211const fitsAtEnd = (options: readonly { label: string }[], columns: number): boolean =>
212 options.length <= 3 && options.reduce((sum, option) => sum + option.label.length + 5, 0) <= Math.max(16, columns - 34)
213
214export const register: Register = on => {
215 on('session.start', async ($, e, next) => {
216 const lang = await langOf($)
217 await update($, langAtom, () => lang)
218 await $.command.register({ name: 'mods', description: HUB_STRINGS[lang].command, immediate: true })
219
220 // The user's choice to see the pane from the first moment, in every session.
221 if ((await $.store.get('openAtStart')) === true) {
222 await $.ui.open({ id: PANE, title: HUB_STRINGS[lang].title }).catch(() => undefined)
223 }
224
225 return next(e)
226 })
227
228 on('command.run', { command: 'mods' }, async $ => {
229 const lang = await langOf($)
230 await $.ui.open({ id: PANE, title: HUB_STRINGS[lang].title, focus: true, closeOnEscape: true })
231
232 return {}
233 })
234
235 on('ui.close', async ($, e, next) => {
236 if (e.id === PANE) {
237 setTicker($, 0)
238 }
239
240 return next(e)
241 })
242
243 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
244 const ui = $.ui.resolve(e)
245
246 if (e.surface === 'mobile' || !('Input' in ui)) {
247 return <ui.Text>Open /mods in the terminal or the desktop app.</ui.Text>
248 }
249
250 await read($, revisionAtom)
251 const lang = await langOf($)
252 const w: HubStrings = HUB_STRINGS[lang]
253 const columns = e.props.bodyColumns
254 const k: Kit = kitOf(ui, toneOf(await skinOf($)), e.surface, columns)
255 const { Box } = ui
256 const mods = await loadAll($, { lang, surface: e.surface, columns: Math.max(20, columns - 4) })
257 const stored = await read($, tabAtom)
258 const tab = stored === 'overview' || stored === 'settings' || stored === 'components' || mods.some(mod => mod.noun === stored) ? stored : 'overview'
259 const current = mods.find(mod => mod.noun === tab)
260
261 // A countdown in view draws again on its own; the overview shows every summary.
262 const refresh = tab === 'overview' ? Math.min(...mods.map(mod => mod.panel.refreshMs ?? Infinity)) : (current?.panel.refreshMs ?? Infinity)
263 setTicker($, Number.isFinite(refresh) ? Math.max(1000, refresh) : 0)
264
265 const go = (next: string) => void update($, tabAtom, () => next)
266
267 const press = (noun: string, id: string, value?: string) =>
268 void (async () => {
269 try {
270 const result = await actOn($, noun, value === undefined ? { id } : { id, value })
271
272 if (result?.message) {
273 $.ui.toast(result.message)
274 }
275 } catch (error) {
276 $.ui.toast(messageOf(error))
277 }
278
279 await update($, revisionAtom, n => n + 1)
280 })()
281
282 const toggleWords = { on: w.on, off: w.off, turnOn: w.turnOn, turnOff: w.turnOff }
283
284 // Help, with the session-only mark where an item is not the user's setting.
285 const helpOf = (entry: { help?: string; scope?: string }) =>
286 entry.scope === 'session' ? [entry.help, w.sessionOnly].filter(Boolean).join(' · ') : entry.help
287
288 const item = (noun: string, entry: ModsItem, index: number): RenderElement | null => {
289 const key = `item-${noun}-${'id' in entry ? entry.id : index}`
290
291 switch (entry.kind) {
292 case 'toggle':
293 return row(k, { key, label: entry.label, help: helpOf(entry), end: toggle(k, key, entry.value, next => press(noun, entry.id, next ? 'on' : 'off'), toggleWords) })
294 case 'choice': {
295 const picked = picker(k, key, entry.value, entry.options, value => press(noun, entry.id, value), w.choose)
296
297 return fitsAtEnd(entry.options, columns)
298 ? row(k, { key, label: entry.label, help: helpOf(entry), end: picked })
299 : row(k, { key, label: entry.label, help: helpOf(entry), below: picked })
300 }
301 case 'action':
302 return row(k, {
303 key,
304 label: entry.title ?? helpOf(entry) ?? entry.label,
305 help: entry.title === undefined ? undefined : helpOf(entry),
306 end: action(k, key, entry.label, () => press(noun, entry.id), { primary: entry.primary === true }),
307 })
308 case 'input':
309 return field(k, key, entry.label, entry.placeholder ?? '', value => press(noun, entry.id, value), {
310 submitLabel: entry.submitLabel ?? w.apply,
311 ...(helpOf(entry) ? { help: helpOf(entry)! } : {}),
312 })
313 case 'info':
314 return row(k, { key, label: entry.label, muted: true, end: text(k, entry.value, entry.tone), endWraps: true })
315 case 'text':
316 return (
317 <Box key={key} paddingX={1}>
318 {text(k, entry.text, entry.tone ?? 'muted')}
319 </Box>
320 )
321 case 'tree':
322 return (
323 <Box key={key} flexDirection="column" paddingX={1}>
324 {staticTree(entry.tree, k.t) as RenderElement}
325 </Box>
326 )
327 case 'swatch':
328 return null
329 }
330 }
331
332 const items = (noun: string, list: readonly ModsItem[]) =>
333 runsOf(list).map(run => {
334 switch (run.kind) {
335 case 'one':
336 return item(noun, run.item, run.index)
337 case 'swatches':
338 return (
339 <Box key={`swatches-${noun}-${run.items[0]!.id}`} flexDirection="row" flexWrap="wrap" columnGap={2} paddingX={1}>
340 {run.items.map(entry => swatch(k, `item-${noun}-${entry.id}`, entry.label, entry.color, entry.selected, () => press(noun, entry.id)))}
341 </Box>
342 )
343 case 'actions':
344 return (
345 <Box key={`actions-${noun}-${run.items[0]!.id}`} flexDirection="row" flexWrap="wrap" columnGap={1} paddingX={1}>
346 {run.items.map(entry => action(k, `item-${noun}-${entry.id}`, entry.label, () => press(noun, entry.id), { primary: entry.primary === true }))}
347 </Box>
348 )
349 }
350 })
351
352 // The way back from a mod, Components or Settings, and the view's own title.
353 const header = (title: string, iconName: IconName, state?: RenderElement | null) => (
354 <Box key="header" flexDirection="column">
355 <Box flexDirection="row" columnGap={1} alignItems="center">
356 {icon(k, 'chevronLeft', 'muted', 14)}
357 {action(k, 'tab-overview', w.allMods, () => go('overview'), { hotkey: '0' })}
358 </Box>
359 <Box flexDirection="row" columnGap={1} alignItems="center" paddingX={1} paddingTop={1}>
360 {icon(k, iconName, undefined, 18)}
361 {text(k, title, undefined, { bold: true })}
362 <Box flexGrow={1} />
363 {state ?? null}
364 </Box>
365 </Box>
366 )
367
368 // A progress the mod reports, as the pack's ring.
369 const ringOf = (mod: Loaded) => (mod.panel.percent === undefined ? null : meter(k, `ring-${mod.noun}`, '', mod.panel.percent, undefined, 'accent'))
370
371 const stateOf = (mod: Loaded) =>
372 mod.failed ? (
373 status(k, w.couldNotDraw, 'err')
374 ) : !mod.panel.summary ? (
375 ringOf(mod)
376 ) : (
377 <Box flexDirection="row" columnGap={1} alignItems="center" flexShrink={1}>
378 {ringOf(mod)}
379 {isState(mod.panel.tone) ? status(k, mod.panel.summary, mod.panel.tone) : text(k, mod.panel.summary, 'muted')}
380 </Box>
381 )
382
383 const iconOf = (noun: string): IconName => CATALOGS.find(mod => mod.noun === noun)?.icon ?? 'mods'
384
385 const missing = MODS.filter(mod => !mods.some(present => present.noun === mod.noun)).map(mod => mod.title[lang])
386
387 // One name per mod everywhere: its own panel title, or the table's when it has no view.
388 const titleOf = (noun: string): string => mods.find(mod => mod.noun === noun)?.panel.title || CATALOGS.find(mod => mod.noun === noun)?.title[lang] || noun
389
390 const overview = stack(k, 'overview', [
391 mods.length === 0
392 ? empty(k, 'no-mods', w.noMods, w.noModsHint)
393 : section(
394 k,
395 'list-mods',
396 w.installed,
397 mods.map((mod, i) =>
398 row(k, {
399 key: `mod-${mod.noun}`,
400 label: mod.panel.title,
401 icon: iconOf(mod.noun),
402 help: mod.failed ? w.failed(mod.failed) : mod.panel.summary,
403 ...(mod.failed ? { helpTone: 'err' as const } : isState(mod.panel.tone) ? { helpTone: mod.panel.tone } : {}),
404 end: [
405 ...(mod.failed ? [] : [ringOf(mod)]),
406 ...(!mod.failed && isState(mod.panel.tone) ? [dot(k, mod.panel.tone)] : []),
407 action(k, `open-${mod.noun}`, w.open, () => go(mod.noun), i < 9 ? { hotkey: String(i + 1) } : {}),
408 ],
409 }),
410 ),
411 mods.length === 1 ? w.oneMod : w.manyMods(mods.length),
412 ),
413 section(k, 'list-more', w.more, [
414 row(k, { key: 'components', label: w.components, help: w.componentsHelp, icon: 'components', end: action(k, 'tab-components', w.open, () => go('components')) }),
415 row(k, { key: 'settings', label: w.settings, help: w.settingsHelp, icon: 'settings', end: action(k, 'tab-settings', w.open, () => go('settings')) }),
416 ]),
417 missing.length > 0 ? <Box key="missing" paddingX={1}>{text(k, w.missing(missing.join(', ')), 'faint')}</Box> : null,
418 ])
419
420 const setLang = (value: string) =>
421 void (async () => {
422 const next: ModsLang = value === 'pt' ? 'pt' : 'en'
423 await $.store.set('lang', next)
424 await update($, langAtom, () => next)
425 await update($, revisionAtom, n => n + 1)
426 })()
427
428 const openAtStart = (await $.store.get('openAtStart')) === true
429
430 const settings = stack(k, 'settings', [
431 header(w.settings, 'settings'),
432 section(k, 'settings-language', w.general, [
433 row(k, {
434 key: 'mods-start',
435 label: w.openAtStart,
436 help: w.openAtStartHelp,
437 end: toggle(
438 k,
439 'item-mods-start',
440 openAtStart,
441 next =>
442 void (async () => {
443 await $.store.set('openAtStart', next)
444 await update($, revisionAtom, n => n + 1)
445 })(),
446 toggleWords,
447 ),
448 }),
449 row(k, {
450 key: 'mods-lang',
451 label: w.language,
452 help: w.languageHelp,
453 end: choice(k, 'item-mods-lang', lang, [{ value: 'en', label: 'English' }, { value: 'pt', label: 'Português' }], setLang),
454 }),
455 ]),
456 section(k, 'settings-scope', w.scopeTitle, [
457 row(k, { key: 'scope-user', label: w.scopeUser, help: w.scopeUserHelp, icon: 'settings' }),
458 row(k, { key: 'scope-session', label: w.scopeSession, help: w.scopeSessionHelp, icon: 'clock' }),
459 ]),
460 section(k, 'settings-keys', w.keysTitle, [row(k, { key: 'keys', label: w.footer, muted: true })]),
461 ])
462
463 const modView = (mod: Loaded) => {
464 const { panel } = mod
465
466 return stack(k, `view-${mod.noun}`, [
467 header(panel.title, iconOf(mod.noun), stateOf(mod)),
468 mod.failed ? <Box key="failed" paddingX={1}>{text(k, w.failed(mod.failed), 'err')}</Box> : null,
469 ...panel.sections.map(entry =>
470 section(k, `section-${mod.noun}-${entry.id}`, entry.title, items(mod.noun, entry.items), undefined, entry.items.every(one => one.kind === 'info')),
471 ),
472 panel.sections.length === 0 && !mod.failed ? empty(k, 'nothing', w.nothingToSet) : null,
473 ])
474 }
475
476 // The Components tab draws a mod's catalog only while its Mods section is open.
477 const demo = await read($, demoAtom)
478 const catalogMods = CATALOGS.filter(mod => mod.noun === 'blast' || mods.some(present => present.noun === mod.noun)).map(mod => ({ noun: mod.noun, name: titleOf(mod.noun), slug: mod.name }))
479 const openCatalog = demo.mod !== '' && catalogMods.some(mod => mod.noun === demo.mod) ? demo.mod : catalogMods[0]?.noun
480 const catalog =
481 tab === 'components' && demo.group === 'mods' && openCatalog !== undefined
482 ? await loadCatalog($, openCatalog, { lang, surface: e.surface, columns: Math.max(20, columns - 4) }, titleOf(openCatalog), k.t)
483 : null
484
485 const components = () =>
486 stack(k, 'components', [
487 header(w.components, 'components'),
488 componentsTab(
489 ui as unknown as DemoUi,
490 k,
491 e.surface,
492 columns,
493 lang,
494 demo,
495 {
496 group: id => void update($, demoAtom, d => ({ ...d, group: id })),
497 element: id => void update($, demoAtom, d => ({ ...d, element: id })),
498 mod: noun => void update($, demoAtom, d => ({ ...d, mod: noun })),
499 click: () => void update($, demoAtom, d => ({ ...d, clicks: d.clicks + 1 })),
500 type: value => void update($, demoAtom, d => ({ ...d, typed: value })),
501 submit: value => void update($, demoAtom, d => ({ ...d, submitted: value, typed: '' })),
502 pick: value => void update($, demoAtom, d => ({ ...d, picked: value })),
503 link: () => void update($, demoAtom, d => ({ ...d, links: d.links + 1 })),
504 toast: () => $.ui.toast(w.toast),
505 notify: () => void $.ui.notify(w.toast, { title: 'mods' }).catch(error => $.ui.toast(messageOf(error))),
506 ask: () =>
507 void $.ui
508 .ask(DEMO_WORDS[lang].outside.askQuestion, ['Box', 'Text', 'Button'])
509 .then(answer => $.ui.toast(`$.ui.ask → ${answer}`))
510 .catch(error => $.ui.toast(messageOf(error))),
511 status: () =>
512 void (async () => {
513 const time = new Date(await $.clock.now()).toTimeString().slice(0, 8)
514 $.ui.status(w.status(time))
515 $.clock.after(8000, () => $.ui.status(undefined))
516 })(),
517 copy: () => void $.ui.copy({ text: 'copied by /mods' }).then(result => $.ui.toast(result.isCopied ? '$.ui.copy → ok' : '$.ui.copy → not here')),
518 },
519 catalogMods,
520 catalog,
521 ),
522 ])
523
524 const view = tab === 'settings' ? settings : tab === 'components' ? components() : current === undefined ? overview : modView(current)
525
526 return view
527 })
528}
529kit/kit.tsx 468 lines1import type { RenderElement, RenderSurface } from 'claude-code'
2
3import { iconSvg } from './icons'
4import type { IconName } from './icons'
5import { hexOf, inkOf } from './tone'
6import type { KitTone, Tone } from './tone'
7
8// The pack's components: one grammar for every pane, band and dialog the mods draw, in
9// Claude Code's own idiom. Native Buttons, the app's text colours, hairline groups that
10// never nest, one accent for the current choice and the one main action, stroke icons
11// instead of glyphs, and the ring meter for anything with a percent.
12//
13// Pure functions of the resolved elements, the tone and the surface: nothing here takes `$`.
14// Shared kit: edit in claude-mods-hub/kit, then run dev/sync-kit.sh; never edit a copy.
15
16// The elements the kit draws with; `Svg` only where the surface draws vectors.
17export type KitUi = {
18 Box: any
19 Text: any
20 Button: any
21 Input?: any
22 Select?: any
23 Svg?: any
24}
25
26export type Kit = {
27 readonly ui: KitUi
28 readonly t: Tone
29 readonly surface: RenderSurface
30 // The desktop app, or another surface that draws Svg: icons, switches and rings.
31 readonly vector: boolean
32 // Columns the drawing may take.
33 readonly columns: number
34}
35
36export const kitOf = (ui: KitUi, t: Tone, surface: RenderSurface, columns: number): Kit => ({
37 ui,
38 t,
39 surface,
40 vector: surface !== 'terminal' && ui.Svg !== undefined,
41 columns,
42})
43
44type Child = RenderElement | string | null | undefined | false
45
46// An icon, 16px; nothing on the terminal unless a fallback glyph is given.
47export function icon(k: Kit, name: IconName, tone?: KitTone | 'faint', size = 16, fallback?: string): RenderElement | null {
48 if (k.vector) {
49 const { Svg } = k.ui
50 const color = tone === 'faint' ? k.t.hex.faint : hexOf(k.t, tone ?? 'muted')
51
52 return <Svg source={iconSvg(name, color, size)} alt={name} width={size} height={size} />
53 }
54
55 return fallback === undefined ? null : <k.ui.Text color={tone === 'faint' ? k.t.ink.faint : inkOf(k.t, tone ?? 'muted')}>{fallback}</k.ui.Text>
56}
57
58// A small filled circle in a state's colour: the dot beside a live state.
59export function dot(k: Kit, tone: KitTone, size = 8): RenderElement {
60 if (k.vector) {
61 const { Svg } = k.ui
62 const r = size / 2
63
64 return (
65 <Svg
66 source={`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><circle cx="${r}" cy="${r}" r="${r - 0.5}" fill="${hexOf(k.t, tone)}"/></svg>`}
67 alt={tone}
68 width={size}
69 height={size}
70 />
71 )
72 }
73
74 return <k.ui.Text color={inkOf(k.t, tone)}>●</k.ui.Text>
75}
76
77// Text in a role: body, muted (secondary), faint (tertiary), or a state.
78export function text(k: Kit, content: Child | Child[], tone?: KitTone | 'faint', opts: { bold?: boolean; wrap?: string; key?: string } = {}): RenderElement {
79 const color = tone === 'faint' ? k.t.ink.faint : inkOf(k.t, tone)
80
81 return (
82 <k.ui.Text {...(opts.key ? { key: opts.key } : {})} {...(color === undefined ? {} : { color })} {...(opts.bold ? { bold: true } : {})} {...(opts.wrap ? { wrap: opts.wrap } : {})}>
83 {content}
84 </k.ui.Text>
85 )
86}
87
88// A group's title, with optional muted detail and a control at the right:
89// `Finished 2 [Hide]`.
90export function heading(k: Kit, title: string, detail?: string, key?: string, end?: Child | Child[]): RenderElement {
91 const { Box } = k.ui
92
93 return (
94 <Box {...(key ? { key } : {})} flexDirection="row" columnGap={2} paddingX={1} alignItems="center">
95 <Box flexGrow={1} flexShrink={1}>
96 {text(k, title, undefined, { bold: true })}
97 </Box>
98 {detail ? text(k, detail, 'muted') : null}
99 {end ?? null}
100 </Box>
101 )
102}
103
104// Rows held in one hairline rounded outline, a line apart. Groups never nest. A dense group
105// (values, a list of readings) keeps its rows together.
106export function group(k: Kit, key: string, rows: Child[], dense = false): RenderElement {
107 const { Box } = k.ui
108
109 return (
110 <Box key={key} flexDirection="column" borderStyle="round" borderColor={k.t.ink.line} paddingY={1} rowGap={dense ? 0 : 1}>
111 {rows.filter(Boolean)}
112 </Box>
113 )
114}
115
116// A titled group: the heading, then the outlined rows. `end` puts a control in the heading.
117export function section(k: Kit, key: string, title: string, rows: Child[], detail?: string, dense = false, end?: Child | Child[]): RenderElement {
118 const { Box } = k.ui
119
120 return (
121 <Box key={`section-box-${key}`} flexDirection="column">
122 {heading(k, title, detail, undefined, end)}
123 {group(k, key, rows, dense)}
124 </Box>
125 )
126}
127
128export type RowArgs = {
129 key: string
130 label: string
131 help?: string | undefined
132 icon?: IconName | undefined
133 iconTone?: KitTone | undefined
134 // Any leading element in place of the icon: a mascot, an avatar, a ring.
135 lead?: Child
136 // Right-aligned: a control, a value, a state.
137 end?: Child | Child[]
138 // A long value at the end wraps instead of pushing the row wider.
139 endWraps?: boolean
140 // Under the label: a full-width control, a list of options that wraps.
141 below?: Child | Child[]
142 tone?: KitTone | undefined
143 // The help line in a state's colour: a mod's live state under its name.
144 helpTone?: KitTone | undefined
145 muted?: boolean
146}
147
148// One line of a group: icon, label and help at the left, the control at the right. The
149// row lights under the pointer, as the app's own list rows do.
150export function row(k: Kit, args: RowArgs): RenderElement {
151 const { Box } = k.ui
152 const narrow = k.columns < 44
153 const end = args.end === undefined || args.end === null ? null : (
154 <Box flexDirection="row" columnGap={1} alignItems="center" flexShrink={args.endWraps ? 1 : 0} {...(args.endWraps ? { justifyContent: 'flex-end' } : {})}>
155 {args.end}
156 </Box>
157 )
158
159 return (
160 <Box key={`row-${args.key}`} flexDirection="column" paddingX={1} hover={{ backgroundColor: k.t.ink.hover }}>
161 <Box flexDirection={narrow && end !== null && args.help ? 'column' : 'row'} columnGap={2} alignItems={narrow ? 'flex-start' : 'center'}>
162 <Box flexDirection="row" columnGap={1} flexGrow={1} flexShrink={1} alignItems="flex-start">
163 {args.lead ?? (args.icon ? icon(k, args.icon, args.iconTone) : null)}
164 <Box flexDirection="column" flexShrink={1}>
165 {text(k, args.label, args.muted ? 'muted' : args.tone)}
166 {args.help ? text(k, args.help, args.helpTone ?? 'muted') : null}
167 </Box>
168 </Box>
169 {end}
170 </Box>
171 {args.below === undefined || args.below === null ? null : (
172 <Box flexDirection="column" paddingTop={0} paddingLeft={(args.icon || args.lead) && k.vector ? 3 : 0}>
173 {args.below}
174 </Box>
175 )}
176 </Box>
177 )
178}
179
180export type Option = { value: string; label: string }
181
182// A choice among a few: a row of native buttons, the current one in the accent. The
183// options wrap under the label when there are many.
184export function choice(k: Kit, key: string, value: string, options: readonly Option[], pick: (value: string) => void): RenderElement {
185 const { Box, Button } = k.ui
186
187 return (
188 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
189 {options.map(option =>
190 option.value === value ? (
191 <Button key={`${key}-${option.value}`} label={option.label} variant="primary" onPress={() => pick(option.value)} />
192 ) : (
193 <Button key={`${key}-${option.value}`} label={option.label} dimColor onPress={() => pick(option.value)} />
194 ),
195 )}
196 </Box>
197 )
198}
199
200// A choice of any size: a few options as native buttons, more than three as the native
201// select, so a long list never becomes a wall of buttons. `value` '' selects nothing.
202// With nothing selected, `prompt` is the first entry and the one shown ("Choose a length"),
203// never the select's generic placeholder; picking it does nothing.
204export function picker(k: Kit, key: string, value: string, options: readonly Option[], pick: (value: string) => void, prompt?: string): RenderElement {
205 const { Select } = k.ui
206
207 if (options.length <= 3 || Select === undefined) {
208 return choice(k, key, value, options, pick)
209 }
210
211 const none = value === '' && prompt !== undefined
212
213 return (
214 <Select
215 key={key}
216 options={[...(none ? [{ value: '', label: prompt }] : []), ...options.map(option => ({ value: option.value, label: option.label }))]}
217 {...(value === '' && !none ? {} : { value })}
218 onSelect={(next: string) => next !== '' && pick(next)}
219 />
220 )
221}
222
223const SWITCH_W = 28
224const SWITCH_H = 16
225
226// The drawn state of a switch: a pill track, the knob at the right and the track in the
227// accent when on. A picture only; the Button beside it does the switching.
228export function switchSvg(k: Kit, on: boolean): string {
229 const track = on ? k.t.hex.accent : k.t.hex.line
230 const knob = on ? '#ffffff' : k.t.hex.muted
231 const x = on ? SWITCH_W - SWITCH_H / 2 : SWITCH_H / 2
232
233 return `<svg xmlns="http://www.w3.org/2000/svg" width="${SWITCH_W}" height="${SWITCH_H}" viewBox="0 0 ${SWITCH_W} ${SWITCH_H}"><rect width="${SWITCH_W}" height="${SWITCH_H}" rx="${SWITCH_H / 2}" fill="${track}"/><circle cx="${x}" cy="${SWITCH_H / 2}" r="${SWITCH_H / 2 - 2.5}" fill="${knob}"/></svg>`
234}
235
236// An on/off setting: the switch shows the state, the button names what pressing does. On
237// the terminal, one plain button with a filled or hollow mark: `● On`, `○ Off`.
238export function toggle(k: Kit, key: string, on: boolean, flip: (next: boolean) => void, words: { on: string; off: string; turnOn: string; turnOff: string }): RenderElement {
239 const { Box, Button, Svg } = k.ui
240
241 if (!k.vector) {
242 return <Button key={key} plain dimColor={!on} label={on ? `● ${words.on}` : `○ ${words.off}`} onPress={() => flip(!on)} />
243 }
244
245 return (
246 <Box flexDirection="row" columnGap={1} alignItems="center">
247 <Svg source={switchSvg(k, on)} alt={on ? words.on : words.off} width={SWITCH_W} height={SWITCH_H} />
248 <Button key={key} label={on ? words.turnOff : words.turnOn} dimColor onPress={() => flip(!on)} />
249 </Box>
250 )
251}
252
253// A press: the group's one main action in the accent, the rest native and quiet.
254// `focus` starts the keyboard there (Enter presses it): the safe choice of a dialog. `plain`
255// draws the terminal's `1: Label` survey row; the desktop draws its native button either way.
256export function action(
257 k: Kit,
258 key: string,
259 label: string,
260 press: () => void,
261 opts: { primary?: boolean; hotkey?: string; dismiss?: boolean; focus?: boolean; plain?: boolean; dim?: boolean } = {},
262): RenderElement {
263 const { Button } = k.ui
264
265 return (
266 <Button
267 key={key}
268 label={label}
269 {...(opts.primary ? { variant: 'primary' } : {})}
270 {...(opts.hotkey ? { hotkey: opts.hotkey } : {})}
271 {...(opts.dismiss ? { role: 'dismiss' } : {})}
272 {...(opts.focus ? { autoFocus: true } : {})}
273 {...(opts.plain ? { plain: true } : {})}
274 {...(opts.dim ? { dimColor: true } : {})}
275 onPress={press}
276 />
277 )
278}
279
280// A value with its label, the value in its state's colour: `Cache warm · 42m`.
281export function value(k: Kit, label: string, shown: string, tone?: KitTone, key?: string): RenderElement {
282 return row(k, { key: key ?? `value-${label}`, label, muted: true, end: text(k, shown, tone) })
283}
284
285const RING_R = 7
286
287// A percent as a ring, the pack's one data glyph, with its label beside it: `◔ 42% context`.
288// `detail` follows in muted text, such as a reset countdown. The terminal draws a bar.
289// A usage fills toward warn (80%) and err (95%); a progress passes `tone` and keeps it, so
290// finishing never reads as a warning.
291export function meter(k: Kit, key: string, label: string, percent: number, detail?: string, fixed?: KitTone): RenderElement {
292 const { Box, Svg } = k.ui
293 const p = Math.max(0, Math.min(100, Math.round(percent)))
294 const tone: KitTone = fixed ?? (p >= 95 ? 'err' : p >= 80 ? 'warn' : 'accent')
295
296 if (!k.vector) {
297 const filled = Math.round(p / 12.5)
298
299 return (
300 <Box key={key} flexDirection="row" columnGap={1}>
301 {text(k, '█'.repeat(filled) + '░'.repeat(8 - filled), tone)}
302 {text(k, `${p}%`, undefined, { bold: true })}
303 {text(k, detail ? `${label} · ${detail}` : label, 'muted')}
304 </Box>
305 )
306 }
307
308 const size = RING_R * 2 + 4
309 const c = 2 * Math.PI * RING_R
310 const ring = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><circle cx="${size / 2}" cy="${size / 2}" r="${RING_R}" fill="none" stroke="${k.t.hex.muted}" stroke-opacity=".25" stroke-width="2.5"/><circle cx="${size / 2}" cy="${size / 2}" r="${RING_R}" fill="none" stroke="${hexOf(k.t, tone)}" stroke-width="2.5" stroke-linecap="round" stroke-dasharray="${(c * p) / 100} ${c}" transform="rotate(-90 ${size / 2} ${size / 2})"/></svg>`
311
312 return (
313 <Box key={key} flexDirection="row" columnGap={1} alignItems="center">
314 <Svg source={ring} alt={`${label} ${p}%`} width={size} height={size} />
315 {text(k, `${p}%`, undefined, { bold: true })}
316 {text(k, detail ? `${label} · ${detail}` : label, 'muted')}
317 </Box>
318 )
319}
320
321// A short state word in its colour, with its dot: `● warm`.
322export function status(k: Kit, word: string, tone: KitTone): RenderElement {
323 const { Box } = k.ui
324
325 return (
326 <Box flexDirection="row" columnGap={1} alignItems="center">
327 {dot(k, tone)}
328 {text(k, word, tone === 'muted' ? 'muted' : tone)}
329 </Box>
330 )
331}
332
333// A notice inside a pane or dialog: an icon in the state's colour, a title, a body. No
334// coloured side bar: the outline and the icon carry the state.
335export function notice(k: Kit, key: string, tone: KitTone, title: string, body?: Child | Child[], actions?: Child[], iconName?: IconName): RenderElement {
336 const { Box } = k.ui
337 const name: IconName = iconName ?? (tone === 'err' || tone === 'warn' ? 'alert' : tone === 'ok' ? 'check' : 'info')
338
339 return (
340 <Box key={key} flexDirection="column" borderStyle="round" borderColor={k.t.ink.line} paddingX={1} rowGap={0}>
341 <Box flexDirection="row" columnGap={1} alignItems="center">
342 {icon(k, name, tone, 16, tone === 'err' || tone === 'warn' ? '!' : 'i')}
343 {text(k, title, tone === 'muted' ? undefined : tone, { bold: true })}
344 </Box>
345 {body === undefined ? null : <Box flexDirection="column">{typeof body === 'string' ? text(k, body, 'muted') : body}</Box>}
346 {actions && actions.length > 0 ? (
347 <Box flexDirection="row" columnGap={1} paddingTop={1} justifyContent="flex-end">
348 {actions}
349 </Box>
350 ) : null}
351 </Box>
352 )
353}
354
355// What a list shows when it has nothing yet: what it will hold, and how it gets there.
356export function empty(k: Kit, key: string, title: string, hint?: string): RenderElement {
357 const { Box } = k.ui
358
359 return (
360 <Box key={key} flexDirection="column" paddingX={1}>
361 {text(k, title, 'muted')}
362 {hint ? text(k, hint, 'faint') : null}
363 </Box>
364 )
365}
366
367// A colour sample beside its name and value, as a palette editor lists them.
368export function swatch(k: Kit, key: string, label: string, color: string, selected: boolean, press: () => void): RenderElement {
369 const { Box, Button, Svg } = k.ui
370 const chip = k.vector ? (
371 <Svg
372 source={`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><rect x=".5" y=".5" width="15" height="15" rx="4" fill="${color}" stroke="${k.t.hex.line}"/></svg>`}
373 alt={color}
374 width={16}
375 height={16}
376 />
377 ) : (
378 <k.ui.Text backgroundColor={color}>{' '}</k.ui.Text>
379 )
380
381 return (
382 <Box key={`swatch-${key}`} flexDirection="row" columnGap={1} alignItems="center" width={26}>
383 {chip}
384 {selected ? <Button key={key} label={label} variant="primary" onPress={press} /> : <Button key={key} label={label} dimColor onPress={press} />}
385 {text(k, color, 'faint')}
386 </Box>
387 )
388}
389
390// A single-line field with its label above, as the app's own forms set them.
391export function field(k: Kit, key: string, label: string, placeholder: string, submit: (value: string) => void, opts: { submitLabel?: string; help?: string; value?: string } = {}): RenderElement {
392 const { Box, Input } = k.ui
393
394 if (Input === undefined) {
395 return text(k, label, 'muted')
396 }
397
398 return (
399 <Box key={`field-${key}`} flexDirection="column" paddingX={1}>
400 {text(k, label)}
401 {opts.help ? text(k, opts.help, 'muted') : null}
402 <Input key={key} placeholder={placeholder} value={opts.value ?? ''} submitLabel={opts.submitLabel ?? 'OK'} onSubmit={(v: string) => submit(v.trim())} />
403 </Box>
404 )
405}
406
407// Space between groups: one empty line, the pack's whole vertical rhythm.
408export function stack(k: Kit, key: string, children: Child[], gap = 1): RenderElement {
409 return (
410 <k.ui.Box key={key} flexDirection="column" rowGap={gap}>
411 {children.filter(Boolean)}
412 </k.ui.Box>
413 )
414}
415
416// A drawing made plain data, for a catalog another mod draws: a press cannot cross mods, so
417// handlers are dropped and each control is drawn as its shape without its press. A Button is
418// a round hairline box around its label (filled with the accent when primary), a field or a
419// select a hairline box around its placeholder or value, a Client its props' summary.
420export function staticTree(node: unknown, t?: Tone): unknown {
421 if (Array.isArray(node)) {
422 return node.map(child => staticTree(child, t))
423 }
424
425 if (node === null || typeof node !== 'object') {
426 return typeof node === 'function' ? undefined : node
427 }
428
429 const element = node as { type?: unknown; props?: Record<string, unknown> }
430 const p = element.props ?? {}
431 const line = t?.ink.line ?? 'subtle'
432
433 if (element.type === 'Button') {
434 const label = String(p.label ?? p.key ?? '')
435 const primary = p.variant === 'primary' && p.plain !== true
436
437 return {
438 type: 'Box',
439 props: { borderStyle: 'round', borderColor: primary ? (t?.ink.accent ?? 'claude') : line, paddingX: 1, ...(primary ? { backgroundColor: t?.hex.accent ?? 'claude' } : {}) },
440 children: [{ type: 'Text', props: primary ? { color: '#ffffff', bold: true } : p.dimColor === true ? { color: t?.ink.muted ?? 'inactive' } : {}, children: [label] }],
441 }
442 }
443
444 if (element.type === 'Input' || element.type === 'Select') {
445 const options = Array.isArray(p.options) ? (p.options as { value: string; label?: string }[]) : []
446 const shown = element.type === 'Select' ? (options.find(option => option.value === p.value)?.label ?? String(p.value ?? options[0]?.label ?? '')) : String(p.value || p.placeholder || '')
447
448 return {
449 type: 'Box',
450 props: { flexDirection: 'row', columnGap: 1 },
451 children: [
452 ...(p.label ? [{ type: 'Text', props: {}, children: [String(p.label)] }] : []),
453 { type: 'Box', props: { borderStyle: 'round', borderColor: line, paddingX: 1 }, children: [{ type: 'Text', props: { color: t?.ink.muted ?? 'inactive' }, children: [element.type === 'Select' ? `${shown} ⌄` : shown] }] },
454 ],
455 }
456 }
457
458 if (element.type === 'Client') {
459 return { type: 'Text', props: { color: t?.ink.muted ?? 'inactive' }, children: [String(p.key ?? p.module ?? '')] }
460 }
461
462 return Object.fromEntries(
463 Object.entries(element)
464 .filter(([key, value]) => key !== 'press' && typeof value !== 'function')
465 .map(([key, value]) => [key, key === 'props' && value !== null && typeof value === 'object' ? Object.fromEntries(Object.entries(value).filter(([, prop]) => typeof prop !== 'function')) : staticTree(value, t)]),
466 )
467}
468kit/icons.ts 61 lines1// The pack's icons: outline paths on a 24-unit grid, drawn with one stroke (1.8, round caps
2// and joins), the set skins' tool rows started. An icon is a whole SVG document, for the
3// `Svg` element; the terminal draws no Svg, so callers pass a fallback there or nothing.
4//
5// Shared kit: edit in claude-mods-hub/kit, never in a copy.
6
7export const ICON_PATHS = {
8 // Tool kinds, as skins draws them.
9 run: '<path d="M5 7l5 5-5 5"/><path d="M12 18h7"/>',
10 write: '<path d="M4 20h4L18.5 9.5a2.83 2.83 0 0 0-4-4L4 16v4z"/><path d="M13.5 6.5l4 4"/>',
11 read: '<path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2z"/><path d="M9 13h6M9 17h4"/>',
12 search: '<circle cx="10" cy="10" r="6.5"/><path d="M20 20l-5.2-5.2"/>',
13 web: '<circle cx="12" cy="12" r="8.5"/><path d="M3.8 9.5h16.4M3.8 14.5h16.4"/><path d="M11.5 3.5a15 15 0 0 0 0 17M12.5 3.5a15 15 0 0 1 0 17"/>',
14 mcp: '<path d="M9.8 6.2l8 8-2 2a5.66 5.66 0 0 1-8-8z"/><path d="M4 20l3.6-3.6M15 4l-3.4 3.4M20 9l-3.4 3.4"/>',
15 other: '<circle cx="12" cy="12" r="3"/>',
16
17 // Navigation and state.
18 chevronRight: '<path d="M9 5l7 7-7 7"/>',
19 chevronLeft: '<path d="M15 5l-7 7 7 7"/>',
20 chevronDown: '<path d="M5 9l7 7 7-7"/>',
21 check: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',
22 close: '<path d="M6 6l12 12M18 6L6 18"/>',
23 alert: '<path d="M12 4l9 16H3z"/><path d="M12 10v4.5"/><path d="M12 17.5v.01"/>',
24 info: '<circle cx="12" cy="12" r="8.5"/><path d="M12 11v5.5"/><path d="M12 7.5v.01"/>',
25 clock: '<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/>',
26 refresh: '<path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"/><path d="M19.5 4.5v4h-4"/>',
27 eye: '<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12z"/><circle cx="12" cy="12" r="2.8"/>',
28 eyeOff: '<path d="M4 4l16 16"/><path d="M9.9 6A9.6 9.6 0 0 1 12 5.5c6 0 9.5 6.5 9.5 6.5a16 16 0 0 1-2.6 3.4M6.4 7.6A15.5 15.5 0 0 0 2.5 12S6 18.5 12 18.5a9.4 9.4 0 0 0 4.2-1"/>',
29 copy: '<rect x="8.5" y="8.5" width="11" height="11" rx="2"/><path d="M15.5 8.5V6.5a2 2 0 0 0-2-2h-7a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h2"/>',
30 external: '<path d="M13.5 4.5h6v6"/><path d="M19.5 4.5l-8 8"/><path d="M17.5 14v4a1.5 1.5 0 0 1-1.5 1.5H6A1.5 1.5 0 0 1 4.5 18V8A1.5 1.5 0 0 1 6 6.5h4"/>',
31 play: '<path d="M8 5.5v13l10-6.5z"/>',
32 pause: '<path d="M8.5 5.5v13M15.5 5.5v13"/>',
33 stepBack: '<path d="M16 6l-7 6 7 6"/><path d="M7 6v12"/>',
34 stepForward: '<path d="M8 6l7 6-7 6"/><path d="M17 6v12"/>',
35
36 // The mods.
37 cache: '<path d="M12 3.5c3.5 3.2 5.5 6 5.5 9a5.5 5.5 0 0 1-11 0c0-1.6.6-3 1.6-4.3.4 1.4 1.3 2.3 2.4 2.6-.4-2.8.2-5 1.5-7.3z"/>',
38 skins: '<path d="M12 3.5a8.5 8.5 0 1 0 0 17c1.2 0 1.8-.8 1.8-1.7 0-1.1-.9-1.5-.9-2.4 0-.9.7-1.6 1.6-1.6h2a4 4 0 0 0 4-4C20.5 6.8 16.7 3.5 12 3.5z"/><circle cx="7.8" cy="11" r=".9"/><circle cx="10.5" cy="7.5" r=".9"/><circle cx="15" cy="7.8" r=".9"/>',
39 folder: '<path d="M3.5 7.5a2 2 0 0 1 2-2h4l2 2.5h7a2 2 0 0 1 2 2v7.5a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2z"/>',
40 file: '<path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2z"/>',
41 branch: '<circle cx="7" cy="5.5" r="2"/><circle cx="7" cy="18.5" r="2"/><circle cx="17" cy="8" r="2"/><path d="M7 7.5v9"/><path d="M17 10c0 4-10 2.5-10 6.5"/>',
42 agents: '<rect x="4.5" y="8" width="15" height="11" rx="3"/><path d="M12 8V4.5"/><circle cx="12" cy="4" r=".8"/><path d="M9.5 13v1.5M14.5 13v1.5"/><path d="M2.5 12.5v2.5M21.5 12.5v2.5"/>',
43 replay: '<path d="M4.5 12a7.5 7.5 0 1 0 2.2-5.3"/><path d="M4.5 4.5v4h4"/><path d="M11 9.5v5l3.5-2.5z"/>',
44 shield: '<path d="M12 3.5l7 2.8v5.2c0 4.3-3 7.6-7 9-4-1.4-7-4.7-7-9V6.3z"/><path d="M12 8.5v4"/><path d="M12 15.5v.01"/>',
45 components: '<rect x="4" y="4" width="6.5" height="6.5" rx="1.5"/><rect x="13.5" y="4" width="6.5" height="6.5" rx="1.5"/><rect x="4" y="13.5" width="6.5" height="6.5" rx="1.5"/><rect x="13.5" y="13.5" width="6.5" height="6.5" rx="1.5"/>',
46 settings: '<circle cx="12" cy="12" r="2.8"/><path d="M12 3.5v2.2M12 18.3v2.2M3.5 12h2.2M18.3 12h2.2M6 6l1.6 1.6M16.4 16.4L18 18M6 18l1.6-1.6M16.4 7.6L18 6"/>',
47 mods: '<path d="M4 7h16M4 12h16M4 17h10"/>',
48 reflect: '<path d="M9 18h6M10 21h4"/><path d="M12 3a6 6 0 0 0-3.5 10.9c.6.5 1 1.2 1 2V16h5v-.1c0-.8.4-1.5 1-2A6 6 0 0 0 12 3z"/>',
49} as const
50
51export type IconName = keyof typeof ICON_PATHS
52
53// The whole SVG document: `size` CSS pixels, stroked in `color`.
54export function iconSvg(name: IconName, color: string, size = 16): string {
55 return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24"><g fill="none" stroke="${color}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${ICON_PATHS[name]}</g></svg>`
56}
57
58// The same paths placed inside a larger drawing, as skins' cards place theirs.
59export const iconAt = (name: IconName, x: number, y: number, size: number, color: string): string =>
60 `<g transform="translate(${x} ${y}) scale(${size / 24})" fill="none" stroke="${color}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${ICON_PATHS[name]}</g>`
61kit/tone.ts 131 lines1// The pack's colours. One skin themes every mod: skins hands its palette out through
2// `$.skins.palette()`, and a mod without skins installed falls back to Claude Code's own.
3//
4// Two forms of every colour: `hex`, for SVG markup and mixing, and `ink`, for an element's
5// `color`. On the native skin `ink` holds Claude Code's theme keys, so text follows the app
6// exactly, light or dark; on any other skin it holds the skin's hex.
7//
8// This file is the pack's shared kit: the source lives in claude-mods-hub/kit and
9// `dev/sync-kit.sh` copies it into every mod. Edit it there, never in a copy.
10
11export type KitSlot =
12 | 'read' | 'write' | 'run' | 'search' | 'web' | 'mcp' | 'other'
13 | 'user' | 'fg' | 'muted' | 'surface' | 'zebra' | 'ok' | 'err' | 'warn'
14
15export type KitPalette = Readonly<Record<KitSlot, string>>
16
17// What `$.skins.palette()` answers.
18export type KitSkin = { name: string; palette: KitPalette; isLight: boolean }
19
20export type KitTone = 'ok' | 'warn' | 'err' | 'muted' | 'accent'
21
22export type Tone = {
23 readonly name: string
24 readonly isNative: boolean
25 readonly isLight: boolean
26 // For SVG and mixing.
27 readonly hex: KitPalette & { readonly accent: string; readonly line: string; readonly hover: string; readonly faint: string; readonly selected: string }
28 // For an element's colour. `fg` is undefined on the native skin: the app's own text colour.
29 readonly ink: {
30 readonly fg: string | undefined
31 readonly muted: string
32 readonly faint: string
33 readonly accent: string
34 readonly ok: string
35 readonly warn: string
36 readonly err: string
37 readonly line: string
38 readonly hover: string
39 // The background of the selected row: a tint of the accent.
40 readonly selected: string
41 }
42}
43
44// Claude Code's own colours, as the native skin carries them.
45export const NATIVE: { readonly dark: KitPalette; readonly light: KitPalette } = {
46 dark: {
47 read: '#c9c7bd', write: '#c9c7bd', run: '#c9c7bd', search: '#c9c7bd', web: '#c9c7bd', mcp: '#c9c7bd', other: '#c9c7bd',
48 user: '#d97757', fg: '#e8e6dc', muted: '#9c9a92', surface: '#262624', zebra: '#2d2c2a',
49 ok: '#4eba65', err: '#ff6b80', warn: '#ffc107',
50 },
51 light: {
52 read: '#3d3c38', write: '#3d3c38', run: '#3d3c38', search: '#3d3c38', web: '#3d3c38', mcp: '#3d3c38', other: '#3d3c38',
53 user: '#c96442', fg: '#1f1e1d', muted: '#6f6d66', surface: '#faf9f5', zebra: '#f0eee6',
54 ok: '#2c7a39', err: '#ab2b3f', warn: '#966c1e',
55 },
56}
57
58export const NATIVE_NAME = 'claude'
59
60const channels = (hex: string): [number, number, number] => {
61 const h = /^#[0-9a-f]{6}$/i.test(hex) ? hex : '#808080'
62
63 return [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)]
64}
65
66// `amount` of the way from `from` to `to`.
67export function mix(from: string, to: string, amount: number): string {
68 const a = channels(from)
69 const b = channels(to)
70
71 return `#${a.map((value, i) => Math.round(value + (b[i]! - value) * amount).toString(16).padStart(2, '0')).join('')}`
72}
73
74export function toneOf(skin: KitSkin | null): Tone {
75 const isLight = skin?.isLight ?? false
76 const palette = skin?.palette ?? (isLight ? NATIVE.light : NATIVE.dark)
77 const name = skin?.name ?? NATIVE_NAME
78 const isNative = name === NATIVE_NAME
79 const line = mix(palette.surface, palette.fg, isLight ? 0.14 : 0.16)
80 const hover = mix(palette.surface, palette.fg, isLight ? 0.05 : 0.07)
81 const faint = mix(palette.muted, palette.surface, 0.35)
82 const selected = mix(palette.surface, palette.user, isLight ? 0.14 : 0.22)
83 const hex = { ...palette, accent: palette.user, line, hover, faint, selected }
84
85 return {
86 name,
87 isNative,
88 isLight,
89 hex,
90 ink: isNative
91 ? { fg: undefined, muted: 'inactive', faint: 'subtle', accent: 'claude', ok: 'success', warn: 'warning', err: 'error', line, hover, selected }
92 : { fg: palette.fg, muted: palette.muted, faint, accent: palette.user, ok: palette.ok, warn: palette.warn, err: palette.err, line, hover, selected },
93 }
94}
95
96// A tone word as an element colour; no tone is the body text.
97export function inkOf(t: Tone, tone: KitTone | undefined): string | undefined {
98 switch (tone) {
99 case 'ok':
100 return t.ink.ok
101 case 'warn':
102 return t.ink.warn
103 case 'err':
104 return t.ink.err
105 case 'muted':
106 return t.ink.muted
107 case 'accent':
108 return t.ink.accent
109 default:
110 return t.ink.fg
111 }
112}
113
114// The same, as hex for markup.
115export function hexOf(t: Tone, tone: KitTone | undefined): string {
116 switch (tone) {
117 case 'ok':
118 return t.hex.ok
119 case 'warn':
120 return t.hex.warn
121 case 'err':
122 return t.hex.err
123 case 'muted':
124 return t.hex.muted
125 case 'accent':
126 return t.hex.accent
127 default:
128 return t.hex.fg
129 }
130}
131hooks/components.tsx 604 lines1import type { ElementTable, RenderElement, RenderSurface } from 'claude-code'
2
3import { choice, text } from '../kit/kit'
4import type { Kit } from '../kit/kit'
5import type { ModsCatalogEntry, ModsLang } from '../types'
6import type { DemoClientProps } from './demo-client'
7
8// The elements every pane surface draws, and the ones only some do.
9export type DemoUi = Pick<ElementTable<'desktop'>, 'Box' | 'Text' | 'Button' | 'Input' | 'Select' | 'Link' | 'Code' | 'Markdown'> & {
10 Svg?: ElementTable<'desktop'>['Svg']
11 Client?: ElementTable<'desktop'>['Client']
12 Raster?: ElementTable<'terminal'>['Raster']
13 Image?: ElementTable<'terminal'>['Image']
14}
15
16export type DemoState = {
17 group: string
18 element: string
19 mod: string
20 clicks: number
21 typed: string
22 submitted: string
23 picked: string
24 links: number
25}
26
27export const DEMO_START: DemoState = { group: 'elements', element: 'box', mod: '', clicks: 0, typed: '', submitted: '', picked: 'b', links: 0 }
28
29export type DemoActions = {
30 group: (id: string) => void
31 element: (id: string) => void
32 mod: (noun: string) => void
33 click: () => void
34 type: (value: string) => void
35 submit: (value: string) => void
36 pick: (value: string) => void
37 link: () => void
38 toast: () => void
39 notify: () => void
40 ask: () => void
41 status: () => void
42 copy: () => void
43}
44
45// A mod's catalog as the hub loaded it for the open Mods section.
46export type DemoCatalog = { noun: string; name: string; entries: readonly ModsCatalogEntry[] | null; failed?: string }
47
48const GROUPS = ['elements', 'colors', 'sites', 'outside', 'mods'] as const
49const ELEMENTS = ['box', 'text', 'button', 'input', 'select', 'link', 'code', 'markdown', 'svg', 'raster', 'image', 'client'] as const
50
51// The colours a tree may name by meaning; each follows the person's theme.
52const THEME_KEYS = [
53 'text', 'inverseText', 'inactive', 'subtle', 'suggestion', 'remember', 'success', 'error', 'warning', 'merged', 'claude',
54 'permission', 'planMode', 'autoAccept', 'promptBorder', 'bashBorder', 'ide', 'diffAdded', 'diffRemoved', 'diffAddedDimmed',
55 'diffRemovedDimmed', 'diffAddedWord', 'diffRemovedWord',
56] as const
57
58const SITES = [
59 'Pane', 'AbovePrompt', 'UserMessage', 'AssistantMessage', 'ToolUse', 'ToolResult', 'ToolGroup', 'ToolProgress', 'CommandOutput',
60 'Spinner', 'TurnDuration', 'InfoNotice', 'SessionMode', 'PromptHint', 'AskUserQuestion',
61] as const
62
63type Site = (typeof SITES)[number]
64
65const BORDERS = ['single', 'double', 'round', 'bold', 'singleDouble', 'doubleSingle', 'classic', 'arrow'] as const
66const JUSTIFY = ['flex-start', 'center', 'flex-end', 'space-between', 'space-around', 'space-evenly'] as const
67const ALIGN = ['flex-start', 'center', 'flex-end', 'stretch'] as const
68const WRAPS = ['wrap', 'end', 'middle', 'truncate', 'truncate-start', 'truncate-middle', 'truncate-end'] as const
69
70type Words = {
71 intro: string
72 groups: Record<(typeof GROUPS)[number], string>
73 notHere: (surface: string, surfaces: string) => string
74 helps: Record<(typeof ELEMENTS)[number], string>
75 hoverMe: string
76 card: string
77 wrapSample: string
78 pressed: (n: number) => string
79 typed: (v: string) => string
80 submitted: (v: string) => string
81 picked: (v: string) => string
82 linkPresses: (n: number) => string
83 autoFocusNote: string
84 actionNote: string
85 pngNote: string
86 clientHint: string
87 colorsHelp: string
88 sitesHelp: string
89 sites: Record<Site, string>
90 outside: { help: string; rows: readonly [string, string][]; try: string; toast: string; notify: string; ask: string; askQuestion: string; status: string; copy: string }
91 mods: { help: string; none: string; noCatalog: (name: string) => string; failed: (e: string) => string; count: (n: number) => string }
92}
93
94const en: Words = {
95 intro: 'Everything a mod can draw or do, live on this surface and named, so you can point at any of it when asking for a change.',
96 groups: { elements: 'Elements', colors: 'Colors', sites: 'Sites', outside: 'Outside a drawing', mods: 'Your mods' },
97 notHere: (surface, surfaces) => `Not drawn on the ${surface} surface. Only on: ${surfaces}.`,
98 helps: {
99 box: 'The flex container every layout is made of',
100 text: 'A run of text and its styles',
101 button: 'A control whose press runs code in the mod',
102 input: 'A text field',
103 select: 'One of several options',
104 link: 'A hyperlink',
105 code: 'Source code with highlighting, or a diff',
106 markdown: 'Markdown drawn the way Claude Code draws a reply',
107 svg: 'A vector image from an SVG string',
108 raster: 'A box of cells, each a glyph with its own colours',
109 image: 'A picture over a box of cells',
110 client: "A region drawn by the mod's own surface module",
111 },
112 hoverMe: 'point at this',
113 card: 'a card shown on hover',
114 wrapSample: 'The quick brown fox jumps over the lazy dog, then keeps running well past the end of the box it was given.',
115 pressed: n => `pressed ${n} time${n === 1 ? '' : 's'}`,
116 typed: v => `onInput: "${v}"`,
117 submitted: v => `onSubmit: "${v}"`,
118 picked: v => `onSelect: ${v}`,
119 linkPresses: n => `onLinkPress: ${n}`,
120 autoFocusNote: 'autoFocus: true puts the keyboard on this control when the site takes the keys (not drawn here, so it does not steal focus).',
121 actionNote: 'action: "app:<binding>" presses the button with the person’s own chord for an engine keybinding.',
122 pngNote: 'source: { png } takes a whole PNG file in base64; { rgba, width, height } takes raw pixels (shown).',
123 clientHint: 'point at this line',
124 colorsHelp: 'Theme keys follow the person’s theme; any hex (#rrggbb) works too, in color, backgroundColor, borderColor.',
125 sitesHelp: 'A ui.render hook matches one of these components and draws its own tree, or passes. Pane is the one a mod opens itself.',
126 sites: {
127 Pane: 'a pane like this one: docked beside the transcript, or above the prompt',
128 AbovePrompt: 'the band above the prompt',
129 UserMessage: 'your messages, task notifications, messages from other agents',
130 AssistantMessage: 'each block of Claude’s reply',
131 ToolUse: 'a tool call’s row',
132 ToolResult: 'a tool call’s answer',
133 ToolGroup: 'a run of folded tool calls',
134 ToolProgress: 'a running tool’s progress row',
135 CommandOutput: 'a slash command’s output',
136 Spinner: 'the spinner row while Claude works',
137 TurnDuration: 'the line that closes a turn (terminal)',
138 InfoNotice: 'the dim lines under the logo (terminal)',
139 SessionMode: 'the mode labels in the prompt footer',
140 PromptHint: 'the hint line under the prompt',
141 AskUserQuestion: 'the dialog the AskUserQuestion tool opens',
142 },
143 outside: {
144 help: 'What a mod does without drawing a tree, from any hook.',
145 rows: [
146 ['$.ui.toast(text)', 'a passing notice'],
147 ['$.ui.notify(text, { title })', 'a system notification'],
148 ['$.ui.ask(question, options)', 'a question dialog; resolves to the answer'],
149 ['$.ui.status(text)', 'an entry in the status line; undefined clears it'],
150 ['$.ui.copy({ text })', 'puts text on the clipboard'],
151 ['$.ui.open / close / panes / focus / scroll', 'open, close, list, focus and scroll panes'],
152 ['$.ui.selection()', 'the text the person selected'],
153 ['$.ui.blit(...)', 'repaint a Raster in place, without a render pass'],
154 ['$.ui.notice(toolUseId, text)', 'context on a permission prompt'],
155 ['$.ui.log(text)', 'a dim line in the transcript, or the debug log'],
156 ['$.ui.invalidate(event)', 'ask for a redraw'],
157 ['$.audio.play / speak', 'play a sound file of the mod, or speak text'],
158 ],
159 try: 'Try them',
160 toast: 'toast',
161 notify: 'notify',
162 ask: 'ask',
163 askQuestion: 'Which part should change?',
164 status: 'status',
165 copy: 'copy',
166 },
167 mods: {
168 help: 'Every component your mods draw, by name and id: say "the <id> of <mod>" to ask for a change. Controls are drawn in their shape here, without their press.',
169 none: 'No installed mod has a catalog.',
170 noCatalog: name => `${name} is not installed, or has no catalog.`,
171 failed: e => `This catalog could not be drawn: ${e}`,
172 count: n => `${n} component${n === 1 ? '' : 's'}`,
173 },
174}
175
176const pt: Words = {
177 intro: 'Tudo que um mod pode desenhar ou fazer, ao vivo nesta superfície e com nome, para você apontar qualquer coisa ao pedir uma mudança.',
178 groups: { elements: 'Elementos', colors: 'Cores', sites: 'Lugares', outside: 'Fora do desenho', mods: 'Seus mods' },
179 notHere: (surface, surfaces) => `Não é desenhado na superfície ${surface}. Só em: ${surfaces}.`,
180 helps: {
181 box: 'O contêiner flex de que todo layout é feito',
182 text: 'Um trecho de texto e seus estilos',
183 button: 'Um controle cujo clique roda código no mod',
184 input: 'Um campo de texto',
185 select: 'Uma entre várias opções',
186 link: 'Um hyperlink',
187 code: 'Código com realce, ou um diff',
188 markdown: 'Markdown desenhado como o Claude Code desenha uma resposta',
189 svg: 'Uma imagem vetorial a partir de uma string SVG',
190 raster: 'Uma caixa de células, cada uma com um caractere e as próprias cores',
191 image: 'Uma imagem sobre uma caixa de células',
192 client: 'Uma região desenhada pelo módulo de superfície do próprio mod',
193 },
194 hoverMe: 'aponte aqui',
195 card: 'um card que aparece no hover',
196 wrapSample: 'A raposa marrom pula sobre o cachorro preguiçoso e continua correndo bem além do fim da caixa que recebeu.',
197 pressed: n => `apertado ${n} vez${n === 1 ? '' : 'es'}`,
198 typed: v => `onInput: "${v}"`,
199 submitted: v => `onSubmit: "${v}"`,
200 picked: v => `onSelect: ${v}`,
201 linkPresses: n => `onLinkPress: ${n}`,
202 autoFocusNote: 'autoFocus: true põe o teclado neste controle quando o lugar recebe as teclas (não desenhado aqui, para não roubar o foco).',
203 actionNote: 'action: "app:<atalho>" aperta o botão com o atalho da própria pessoa para uma ação do Claude Code.',
204 pngNote: 'source: { png } recebe um arquivo PNG inteiro em base64; { rgba, width, height } recebe pixels crus (mostrado).',
205 clientHint: 'aponte para esta linha',
206 colorsHelp: 'As chaves do tema seguem o tema da pessoa; qualquer hex (#rrggbb) também funciona, em color, backgroundColor, borderColor.',
207 sitesHelp: 'Um hook ui.render escolhe um destes componentes e desenha a própria árvore, ou passa adiante. Pane é o único que o mod abre sozinho.',
208 sites: {
209 Pane: 'um painel como este: ao lado da conversa, ou acima do prompt',
210 AbovePrompt: 'a faixa acima do prompt',
211 UserMessage: 'suas mensagens, avisos de tarefas, mensagens de outros agentes',
212 AssistantMessage: 'cada bloco da resposta do Claude',
213 ToolUse: 'a linha de uma chamada de ferramenta',
214 ToolResult: 'a resposta de uma chamada de ferramenta',
215 ToolGroup: 'um grupo de chamadas dobradas',
216 ToolProgress: 'a linha de progresso de uma ferramenta rodando',
217 CommandOutput: 'a saída de um comando de barra',
218 Spinner: 'a linha do spinner enquanto o Claude trabalha',
219 TurnDuration: 'a linha que fecha um turno (terminal)',
220 InfoNotice: 'as linhas discretas sob o logo (terminal)',
221 SessionMode: 'os rótulos de modo no rodapé do prompt',
222 PromptHint: 'a linha de dica sob o prompt',
223 AskUserQuestion: 'o diálogo que a ferramenta AskUserQuestion abre',
224 },
225 outside: {
226 help: 'O que um mod faz sem desenhar uma árvore, de qualquer hook.',
227 rows: [
228 ['$.ui.toast(texto)', 'um aviso passageiro'],
229 ['$.ui.notify(texto, { title })', 'uma notificação do sistema'],
230 ['$.ui.ask(pergunta, opções)', 'um diálogo de pergunta; devolve a resposta'],
231 ['$.ui.status(texto)', 'um item na linha de status; undefined limpa'],
232 ['$.ui.copy({ text })', 'põe texto na área de transferência'],
233 ['$.ui.open / close / panes / focus / scroll', 'abrir, fechar, listar, focar e rolar painéis'],
234 ['$.ui.selection()', 'o texto que a pessoa selecionou'],
235 ['$.ui.blit(...)', 'repintar um Raster no lugar, sem redesenhar'],
236 ['$.ui.notice(toolUseId, texto)', 'contexto num pedido de permissão'],
237 ['$.ui.log(texto)', 'uma linha discreta na conversa, ou no log de debug'],
238 ['$.ui.invalidate(evento)', 'pedir um redesenho'],
239 ['$.audio.play / speak', 'tocar um arquivo de som do mod, ou falar um texto'],
240 ],
241 try: 'Experimente',
242 toast: 'toast',
243 notify: 'notificar',
244 ask: 'perguntar',
245 askQuestion: 'Qual parte deve mudar?',
246 status: 'status',
247 copy: 'copiar',
248 },
249 mods: {
250 help: 'Cada componente que os seus mods desenham, com nome e id: diga "o <id> do <mod>" para pedir uma mudança. Aqui os controles aparecem no formato, sem a ação.',
251 none: 'Nenhum mod instalado tem catálogo.',
252 noCatalog: name => `${name} não está instalado, ou não tem catálogo.`,
253 failed: e => `Este catálogo não pôde ser desenhado: ${e}`,
254 count: n => `${n} componente${n === 1 ? '' : 's'}`,
255 },
256}
257
258export const DEMO_WORDS: Readonly<Record<ModsLang, Words>> = { en, pt }
259
260const SVG_DEMO = `<svg xmlns="http://www.w3.org/2000/svg" width="240" height="64" viewBox="0 0 240 64">
261<rect x="1" y="1" width="238" height="62" rx="10" fill="none" stroke="#8c8c8c"/>
262<circle cx="32" cy="32" r="16" fill="none" stroke="#d97757" stroke-width="6" stroke-dasharray="70 101" transform="rotate(-90 32 32)"/>
263<text x="62" y="28" font-family="sans-serif" font-size="13" fill="#d97757">42% context</text>
264<rect x="62" y="38" width="160" height="8" rx="4" fill="#8c8c8c" opacity="0.3"/><rect x="62" y="38" width="67" height="8" rx="4" fill="#d97757"/>
265</svg>`
266
267const SVG_HOVER = `<svg xmlns="http://www.w3.org/2000/svg" width="240" height="48" viewBox="0 0 240 48">
268<style>rect{fill:#7aa2f7;transition:fill .2s}rect:hover{fill:#d97757}</style>
269<rect x="4" y="8" width="70" height="32" rx="6"><title>hover: CSS :hover and a tooltip</title></rect>
270<rect x="84" y="8" width="70" height="32" rx="6"><title>second</title></rect>
271<rect x="164" y="8" width="70" height="32" rx="6"><title>third</title></rect>
272</svg>`
273
274const toBase64 = (bytes: Uint8Array): string => (bytes as unknown as { toBase64: () => string }).toBase64()
275
276// A two-row gradient of half blocks: each cell's foreground paints its top half, its background the bottom.
277function rasterCells(columns: number): string {
278 const words: number[] = []
279 const shade = (t: number, phase: number) => Math.round(127 + 127 * Math.sin(t * Math.PI * 2 + phase))
280
281 for (let row = 0; row < 2; row++) {
282 for (let x = 0; x < columns; x++) {
283 const t = (x + row * columns * 0.5) / columns
284 const top = (shade(t, 0) << 16) | (shade(t, 2) << 8) | shade(t, 4)
285 const bottom = (shade(t + 0.25, 0) << 16) | (shade(t + 0.25, 2) << 8) | shade(t + 0.25, 4)
286 words.push(0x2580, top, bottom)
287 }
288 }
289
290 return toBase64(new Uint8Array(Uint32Array.from(words).buffer))
291}
292
293// A small RGBA gradient, `width` by `height` pixels.
294function imagePixels(width: number, height: number): string {
295 const bytes = new Uint8Array(width * height * 4)
296
297 for (let y = 0; y < height; y++) {
298 for (let x = 0; x < width; x++) {
299 const i = (y * width + x) * 4
300 bytes[i] = Math.round((255 * x) / (width - 1))
301 bytes[i + 1] = Math.round((255 * y) / (height - 1))
302 bytes[i + 2] = 200
303 bytes[i + 3] = 255
304 }
305 }
306
307 return toBase64(bytes)
308}
309
310// The Components tab of /mods: every element with every prop, the colours, the sites, what a mod
311// does outside a drawing, and the named components of each installed mod.
312export function componentsTab(
313 ui: DemoUi,
314 k: Kit,
315 surface: RenderSurface,
316 columns: number,
317 lang: ModsLang,
318 state: DemoState,
319 actions: DemoActions,
320 mods: readonly { noun: string; name: string; slug?: string }[],
321 catalog: DemoCatalog | null,
322): RenderElement {
323 const { Box, Text, Button, Input, Select, Link, Code, Markdown } = ui
324 const w = DEMO_WORDS[lang]
325 const group = GROUPS.find(id => id === state.group) ?? 'elements'
326 const element = ELEMENTS.find(id => id === state.element) ?? 'box'
327 const inner = Math.max(24, columns - 6)
328 const chip = (label: string) => (
329 <Box key={`chip-${label}`} borderStyle="round" borderColor="subtle" paddingX={1}>
330 <Text>{label}</Text>
331 </Box>
332 )
333
334 // One prop in one value, named the way to ask for it: `Box · justifyContent: center`.
335 const demo = (name: string, sample: RenderElement) => (
336 <Box key={`demo-${name}`} flexDirection="column">
337 {text(k, name, 'muted')}
338 <Box paddingLeft={2} paddingTop={0} flexDirection="column">
339 {sample}
340 </Box>
341 </Box>
342 )
343 const note = (line: string) => (
344 <Box key={`note-${line.slice(0, 24)}`}>{text(k, line, 'muted')}</Box>
345 )
346 const picker = (prefix: string, ids: readonly string[], open: string, labels: (id: string) => string, press: (id: string) => void) => (
347 <Box key={`picker-${prefix}`}>{choice(k, prefix, open, ids.map(id => ({ value: id, label: labels(id) })), press)}</Box>
348 )
349
350 const elementBody = (): RenderElement[] => {
351 switch (element) {
352 case 'box':
353 return [
354 ...(['row', 'column', 'row-reverse', 'column-reverse'] as const).map(direction =>
355 demo(`Box · flexDirection: ${direction}`, <Box flexDirection={direction} columnGap={1}>{['1', '2', '3'].map(chip)}</Box>),
356 ),
357 ...JUSTIFY.map(justify =>
358 demo(`Box · justifyContent: ${justify}`, <Box flexDirection="row" justifyContent={justify} width={Math.min(inner, 48)} borderStyle="single" borderColor="subtle">{['a', 'b', 'c'].map(chip)}</Box>),
359 ),
360 ...ALIGN.map(align =>
361 demo(`Box · alignItems: ${align}`, (
362 <Box flexDirection="row" alignItems={align} columnGap={1} height={5} borderStyle="single" borderColor="subtle">
363 {chip('short')}
364 <Box borderStyle="round" borderColor="subtle" paddingX={1} paddingY={1}>
365 <Text>tall</Text>
366 </Box>
367 </Box>
368 )),
369 ),
370 demo('Box · alignSelf: center', <Box flexDirection="row" height={5} borderStyle="single" borderColor="subtle"><Box alignSelf="center">{chip('self')}</Box>{chip('rest')}</Box>),
371 demo('Box · flexWrap: wrap', <Box flexDirection="row" flexWrap="wrap" columnGap={1} width={Math.min(inner, 30)}>{['one', 'two', 'three', 'four', 'five', 'six'].map(chip)}</Box>),
372 demo('Box · flexWrap: nowrap', <Box flexDirection="row" flexWrap="nowrap" columnGap={1} width={Math.min(inner, 30)} overflow="hidden">{['one', 'two', 'three', 'four', 'five', 'six'].map(chip)}</Box>),
373 demo('Box · flexGrow: 1 (middle)', <Box flexDirection="row" width={Math.min(inner, 48)}>{chip('fixed')}<Box flexGrow={1} borderStyle="round" borderColor="claude"><Text>grows</Text></Box>{chip('fixed')}</Box>),
374 demo('Box · flexShrink: 0 / 1', <Box flexDirection="row" width={Math.min(inner, 30)}><Box flexShrink={0}><Text>flexShrink 0 keeps its width</Text></Box><Box flexShrink={1}><Text wrap="truncate-end">flexShrink 1 gives way</Text></Box></Box>),
375 demo('Box · gap / columnGap / rowGap', <Box flexDirection="row" flexWrap="wrap" columnGap={4} rowGap={1} width={Math.min(inner, 40)}>{['a', 'b', 'c', 'd', 'e', 'f'].map(chip)}</Box>),
376 demo('Box · padding / paddingX / paddingY', <Box borderStyle="single" borderColor="subtle" paddingX={4} paddingY={1}><Text>paddingX 4, paddingY 1</Text></Box>),
377 demo('Box · margin / marginX / marginY / marginTop / marginLeft', <Box borderStyle="single" borderColor="subtle"><Box marginLeft={6} marginY={1}><Text>marginLeft 6, marginY 1</Text></Box></Box>),
378 demo('Box · width / height (number)', <Box width={20} height={3} borderStyle="single" borderColor="subtle"><Text>20 × 3</Text></Box>),
379 demo('Box · width: "50%"', <Box width="50%" borderStyle="single" borderColor="subtle"><Text>50%</Text></Box>),
380 demo('Box · minWidth / minHeight', <Box minWidth={24} minHeight={3} borderStyle="single" borderColor="subtle"><Text>min 24 × 3</Text></Box>),
381 ...BORDERS.map(style => demo(`Box · borderStyle: ${style}`, <Box borderStyle={style} paddingX={1}><Text>{style}</Text></Box>)),
382 demo('Box · borderColor', <Box borderStyle="round" borderColor="#d97757" paddingX={1}><Text>borderColor="#d97757"</Text></Box>),
383 demo('Box · borderDimColor', <Box borderStyle="round" borderDimColor paddingX={1}><Text>borderDimColor</Text></Box>),
384 demo('Box · backgroundColor', <Box backgroundColor="#2f3b52" paddingX={2}><Text color="#e6edf3">backgroundColor="#2f3b52"</Text></Box>),
385 demo('Box · overflow: hidden', <Box width={16} overflow="hidden" borderStyle="single" borderColor="subtle"><Text wrap="truncate">{w.wrapSample}</Text></Box>),
386 demo('Box · key + hover: { backgroundColor }', <Box key="hover-bg" paddingX={1} hover={{ backgroundColor: '#3b3d41' }}><Text hover={{ color: 'claude', bold: true }}>{`▸ ${w.hoverMe}`}</Text></Box>),
387 demo('Box · key + hover: { borderColor }', <Box key="hover-border" borderStyle="round" borderColor="subtle" paddingX={1} hover={{ borderColor: 'claude' }}><Text>{w.hoverMe}</Text></Box>),
388 demo('Box · hover: { scope } (a group lit together)', (
389 <Box flexDirection="row" columnGap={2}>
390 <Box paddingX={1} hover={{ scope: 'demo-scope', backgroundColor: '#3b3d41' }}><Text>{w.hoverMe}</Text></Box>
391 <Box paddingX={1} hover={{ scope: 'demo-scope', backgroundColor: '#3b3d41' }}><Text>…and this lights too</Text></Box>
392 </Box>
393 )),
394 demo('Box · position: absolute + top/left + display: none + hover: { display: flex }', (
395 <Box key="reveal" flexDirection="column">
396 <Text>{`▸ ${w.hoverMe}`}</Text>
397 <Box position="absolute" top={-1} left={16} display="none" hover={{ display: 'flex' }} backgroundColor="#6b7280" paddingX={1}>
398 <Text color="#ffffff">{w.card}</Text>
399 </Box>
400 </Box>
401 )),
402 ]
403 case 'text':
404 return [
405 ...(['bold', 'italic', 'underline', 'strikethrough', 'inverse', 'dimColor'] as const).map(style =>
406 demo(`Text · ${style}`, <Text {...{ [style]: true }}>{style}</Text>),
407 ),
408 demo('Text · color (hex)', <Text color="#d97757">color="#d97757"</Text>),
409 demo('Text · color (theme key)', <Text color="success">color="success"</Text>),
410 demo('Text · backgroundColor', <Text backgroundColor="#d97757" color="#ffffff">backgroundColor="#d97757"</Text>),
411 demo('Text · inline Text inside Text', <Text>plain, <Text bold color="claude">bold accent</Text>, plain again</Text>),
412 ...WRAPS.map(mode => demo(`Text · wrap: ${mode}`, <Box width={Math.min(inner, 40)}><Text wrap={mode}>{w.wrapSample}</Text></Box>)),
413 demo('Text · hover: { color, bold, underline }', <Box key="text-hover"><Text hover={{ color: 'claude', bold: true, underline: true }}>{w.hoverMe}</Text></Box>),
414 ]
415 case 'button':
416 return [
417 demo('Button · label (default chrome)', <Button key="demo-default" label="label" onPress={actions.click} />),
418 demo('Button · variant: primary', <Button key="demo-primary" label="primary" variant="primary" onPress={actions.click} />),
419 demo('Button · variant: secondary', <Button key="demo-secondary" label="secondary" variant="secondary" onPress={actions.click} />),
420 demo('Button · plain', <Button key="demo-plain" label="plain" plain onPress={actions.click} />),
421 demo('Button · plain + dimColor', <Button key="demo-dim" label="dimColor" plain dimColor onPress={actions.click} />),
422 demo('Button · hotkey: "z"', <Button key="demo-hotkey" label="hotkey z" hotkey="z" plain onPress={actions.click} />),
423 demo('Button · role: dismiss', <Button key="demo-dismiss" label="dismiss" role="dismiss" onPress={actions.click} />),
424 demo('Button · hover: { color } (inside a keyed Box)', <Box key="button-hover"><Button key="demo-hover" label="hover" plain hover={{ color: 'claude' }} onPress={actions.click} /></Box>),
425 <Text key="pressed" color="success">{w.pressed(state.clicks)}</Text>,
426 note(w.autoFocusNote),
427 note(w.actionNote),
428 ]
429 case 'input':
430 return [
431 demo('Input · label + placeholder + submitLabel + onInput + onSubmit', (
432 <Box flexDirection="column">
433 <Input key="demo-input" label="label " placeholder="placeholder" value={state.typed} submitLabel="submitLabel" onInput={actions.type} onSubmit={actions.submit} />
434 <Text color="subtle">{w.typed(state.typed)}</Text>
435 <Text color="success">{w.submitted(state.submitted)}</Text>
436 </Box>
437 )),
438 demo('Input · value (prefilled)', <Input key="demo-input-value" label="value " value="prefilled text" onSubmit={actions.submit} />),
439 note(w.autoFocusNote),
440 ]
441 case 'select':
442 return [
443 demo('Select · label + options + value + onSelect', (
444 <Box flexDirection="column">
445 <Select key="demo-select" label="label" value={state.picked} options={[{ value: 'a', label: 'Alpha' }, { value: 'b', label: 'Beta' }, { value: 'c', label: 'Gamma' }]} onSelect={actions.pick} />
446 <Text color="success">{w.picked(state.picked)}</Text>
447 </Box>
448 )),
449 demo('Select · options without labels', <Select key="demo-select-bare" options={[{ value: 'one' }, { value: 'two' }]} value="one" onSelect={actions.pick} />),
450 note(w.autoFocusNote),
451 ]
452 case 'link':
453 return [
454 demo('Link · href + label', <Link href="https://github.com/mvacoimbra/claude-mods" label="claude-mods on GitHub" />),
455 demo('Link · href alone', <Link href="https://github.com/mvacoimbra/claude-mods-hub" />),
456 ]
457 case 'code':
458 return [
459 demo('Code · source + language: ts', <Code language="ts" source={"export const limit = (n: number) => n <= 10"} />),
460 demo('Code · language: python', <Code language="python" source={'def limit(n: int) -> bool:\n return n <= 10'} />),
461 demo('Code · language: json', <Code language="json" source={'{ "name": "mods", "version": "0.3.0" }'} />),
462 demo('Code · language: bash', <Code language="bash" source={'claude plugin install mods@claude-mods'} />),
463 demo('Code · path + startLine', <Code language="ts" path="hooks/register.tsx" startLine={42} source={'on(\'session.start\', async ($, e, next) => next(e))'} />),
464 demo('Code · format: diff', <Code format="diff" language="ts" source={'@@ -1,2 +1,2 @@\n-const limit = 10\n+const limit = 20\n export { limit }'} />),
465 demo('Code · wrap: wrap', <Box width={Math.min(inner, 40)}><Code language="ts" wrap="wrap" source={'const message = "a line long enough that it has to wrap onto the next line"'} /></Box>),
466 demo('Code · wrap: truncate-end', <Box width={Math.min(inner, 40)}><Code language="ts" wrap="truncate-end" source={'const message = "a line long enough that it has to wrap onto the next line"'} /></Box>),
467 ]
468 case 'markdown':
469 return [
470 demo('Markdown · text', <Markdown text={'### Heading\n\n- **bold**, *italic*, `code`, ~~strike~~\n- [a link](https://github.com/mvacoimbra/claude-mods)\n\n> a quote\n\n| Mod | Tab |\n|---|---|\n| cache-tax | Keep warm |\n\n```ts\nconst x = 1\n```'} />),
471 demo('Markdown · dimColor', <Markdown text={'Dimmed **markdown**, for secondary text.'} dimColor />),
472 demo('Markdown · pressableLinks + onLinkPress', (
473 <Box flexDirection="column">
474 <Markdown key="demo-markdown-links" text={'Press [this link](https://example.com/press) in the pane.'} pressableLinks={['https://example.com/press']} onLinkPress={actions.link} />
475 <Text color="success">{w.linkPresses(state.links)}</Text>
476 </Box>
477 )),
478 ]
479 case 'svg':
480 return ui.Svg === undefined
481 ? [note(w.notHere(surface, 'desktop, vscode, mobile'))]
482 : [
483 demo('Svg · source + alt + width + height', <ui.Svg source={SVG_DEMO} alt="a sample ring and bar" width={240} height={64} />),
484 demo('Svg · isInteractive (CSS :hover, <title> tooltips, SMIL)', <ui.Svg source={SVG_HOVER} alt="three boxes that light on hover" width={240} height={48} isInteractive />),
485 ]
486 case 'raster':
487 return ui.Raster === undefined
488 ? [note(w.notHere(surface, 'terminal'))]
489 : [demo('Raster · columns + rows + cells', <ui.Raster key="demo-raster" columns={Math.min(48, inner)} rows={2} cells={rasterCells(Math.min(48, inner))} />)]
490 case 'image':
491 return ui.Image === undefined
492 ? [note(w.notHere(surface, 'terminal'))]
493 : [demo('Image · source: { rgba, width, height } + columns + rows + alt', <ui.Image key="demo-image" source={{ rgba: imagePixels(64, 16), width: 64, height: 16 }} columns={16} rows={2} alt="[a gradient]" />), note(w.pngNote)]
494 case 'client':
495 return ui.Client === undefined
496 ? [note(w.notHere(surface, 'terminal, desktop'))]
497 : [demo('Client · module + props (pointer events, its own state)', <ui.Client key="demo-client" module="./demo-client.tsx" props={{ hint: w.clientHint } satisfies DemoClientProps} />)]
498 }
499 }
500
501 const body = (): RenderElement[] => {
502 switch (group) {
503 case 'elements':
504 return [
505 picker('element', ELEMENTS, element, id => id[0]!.toUpperCase() + id.slice(1), actions.element),
506 note(w.helps[element]),
507 ...elementBody(),
508 ]
509 case 'colors':
510 return [
511 note(w.colorsHelp),
512 <Box key="colors-grid" flexDirection="row" flexWrap="wrap" columnGap={2}>
513 {THEME_KEYS.map(key => (
514 <Box key={`color-${key}`} flexDirection="row" columnGap={1} width={24}>
515 <Text backgroundColor={key}>{' '}</Text>
516 <Text color={key}>{key}</Text>
517 </Box>
518 ))}
519 </Box>,
520 ]
521 case 'sites':
522 return [
523 note(w.sitesHelp),
524 <Box key="sites-list" flexDirection="column">
525 {SITES.map(site => (
526 <Box key={`site-${site}`} flexDirection="row" columnGap={2}>
527 <Box width={18} flexShrink={0}>
528 {text(k, site, 'accent')}
529 </Box>
530 <Box flexShrink={1}>
531 {text(k, w.sites[site], 'muted')}
532 </Box>
533 </Box>
534 ))}
535 </Box>,
536 ]
537 case 'outside':
538 return [
539 note(w.outside.help),
540 <Box key="outside-list" flexDirection="column">
541 {w.outside.rows.map(([call, what]) => (
542 <Box key={`outside-${call}`} flexDirection={columns >= 72 ? 'row' : 'column'} columnGap={2}>
543 <Box width={columns >= 72 ? 40 : undefined} flexShrink={0}>
544 {text(k, call, 'accent')}
545 </Box>
546 <Box flexShrink={1}>
547 {text(k, what, 'muted')}
548 </Box>
549 </Box>
550 ))}
551 </Box>,
552 <Box key="outside-try">{text(k, w.outside.try, undefined, { bold: true })}</Box>,
553 <Box key="outside-actions" flexDirection="row" flexWrap="wrap" columnGap={2}>
554 <Button key="demo-toast" label={w.outside.toast} onPress={actions.toast} />
555 <Button key="demo-notify" label={w.outside.notify} onPress={actions.notify} />
556 <Button key="demo-ask" label={w.outside.ask} onPress={actions.ask} />
557 <Button key="demo-status" label={w.outside.status} onPress={actions.status} />
558 <Button key="demo-copy" label={w.outside.copy} onPress={actions.copy} />
559 </Box>,
560 ]
561 case 'mods': {
562 if (mods.length === 0) {
563 return [note(w.mods.none)]
564 }
565
566 const open = catalog?.noun ?? mods[0]!.noun
567
568 return [
569 note(w.mods.help),
570 picker('catalog', mods.map(mod => mod.noun), open, noun => mods.find(mod => mod.noun === noun)?.name ?? noun, actions.mod),
571 ...(catalog === null || catalog.entries === null
572 ? [note(catalog?.failed === undefined ? w.mods.noCatalog(catalog?.name ?? open) : w.mods.failed(catalog.failed))]
573 : [
574 <Box key="catalog-count" flexDirection="row" columnGap={2}>
575 {text(k, w.mods.count(catalog.entries.length), 'muted')}
576 {text(k, mods.find(mod => mod.noun === catalog.noun)?.slug ?? '', 'faint')}
577 </Box>,
578 ...catalog.entries.map((entry, i) => (
579 <Box key={`catalog-${catalog.noun}-${entry.id}`} flexDirection="column">
580 <Box flexDirection="row" columnGap={2} flexWrap="wrap">
581 {text(k, `${i + 1}. ${entry.name}`, undefined, { bold: true })}
582 {text(k, entry.id, 'faint')}
583 </Box>
584 {entry.help === undefined ? null : text(k, entry.help, 'muted')}
585 <Box paddingLeft={2} flexDirection="column">
586 {entry.tree as RenderElement}
587 </Box>
588 </Box>
589 )),
590 ]),
591 ]
592 }
593 }
594 }
595
596 return (
597 <Box flexDirection="column" rowGap={1} paddingX={1}>
598 {text(k, w.intro, 'muted')}
599 {picker('group', GROUPS, group, id => w.groups[id as (typeof GROUPS)[number]], actions.group)}
600 {body()}
601 </Box>
602 )
603}
604hooks/i18n.ts 131 lines1import type { ModsLang } from '../types'
2
3// Every word the hub draws itself; a mod's view brings its own words in the language asked.
4export type HubStrings = {
5 title: string
6 settings: string
7 components: string
8 componentsHelp: string
9 settingsHelp: string
10 toast: string
11 status: (time: string) => string
12 open: string
13 allMods: string
14 installed: string
15 oneMod: string
16 manyMods: (count: number) => string
17 more: string
18 on: string
19 off: string
20 turnOn: string
21 turnOff: string
22 apply: string
23 choose: string
24 general: string
25 openAtStart: string
26 openAtStartHelp: string
27 language: string
28 languageHelp: string
29 scopeTitle: string
30 scopeUser: string
31 scopeUserHelp: string
32 scopeSession: string
33 scopeSessionHelp: string
34 sessionOnly: string
35 keysTitle: string
36 noMods: string
37 noModsHint: string
38 nothingToSet: string
39 couldNotDraw: string
40 failed: (error: string) => string
41 missing: (names: string) => string
42 footer: string
43 command: string
44}
45
46const en: HubStrings = {
47 title: 'Mods',
48 settings: 'Settings',
49 components: 'Components',
50 componentsHelp: 'Every element a mod can draw, and each mod’s named parts',
51 settingsHelp: 'Language, and where settings are kept',
52 toast: 'This is a toast: $.ui.toast(text)',
53 status: time => `mods: $.ui.status set at ${time}`,
54 open: 'Open',
55 allMods: 'All mods',
56 installed: 'Installed',
57 oneMod: '1 mod',
58 manyMods: count => `${count} mods`,
59 more: 'More',
60 on: 'On',
61 off: 'Off',
62 turnOn: 'Turn on',
63 turnOff: 'Turn off',
64 apply: 'Apply',
65 choose: 'Choose…',
66 general: 'General',
67 openAtStart: 'Open at start',
68 openAtStartHelp: 'Opens this pane whenever a session starts',
69 language: 'Language',
70 languageHelp: 'For this pane and every mod view that speaks it',
71 scopeTitle: 'Where settings live',
72 scopeUser: 'Your settings',
73 scopeUserHelp: 'Saved for your user: the same in every session and project, and picked up by open sessions within seconds',
74 scopeSession: 'This session',
75 scopeSessionHelp: 'A few things only make sense for the session they run in, such as keeping its prompt cache warm. They are marked “this session only”.',
76 sessionOnly: 'this session only',
77 keysTitle: 'Keys',
78 noMods: 'No mod that joins this pane is installed.',
79 noModsHint: 'Install one from the claude-mods marketplace and it shows up here.',
80 nothingToSet: 'Nothing to set here.',
81 couldNotDraw: 'could not draw',
82 failed: error => `This mod could not draw its view: ${error}`,
83 missing: names => `Not installed: ${names}`,
84 footer: 'Esc closes · Tab and the arrows move · 1–9 open a mod · 0 goes back',
85 command: 'Open the mods pane: every installed mod in one place',
86}
87
88const pt: HubStrings = {
89 title: 'Mods',
90 settings: 'Ajustes',
91 components: 'Componentes',
92 componentsHelp: 'Cada elemento que um mod desenha, e as peças nomeadas de cada mod',
93 settingsHelp: 'Idioma, e onde os ajustes ficam guardados',
94 toast: 'Isto é um toast: $.ui.toast(texto)',
95 status: time => `mods: $.ui.status definido às ${time}`,
96 open: 'Abrir',
97 allMods: 'Todos os mods',
98 installed: 'Instalados',
99 oneMod: '1 mod',
100 manyMods: count => `${count} mods`,
101 more: 'Mais',
102 on: 'Ligado',
103 off: 'Desligado',
104 turnOn: 'Ligar',
105 turnOff: 'Desligar',
106 apply: 'Aplicar',
107 choose: 'Escolher…',
108 general: 'Geral',
109 openAtStart: 'Abrir ao iniciar',
110 openAtStartHelp: 'Abre este painel sempre que uma sessão começa',
111 language: 'Idioma',
112 languageHelp: 'Deste painel e de cada mod que o fala',
113 scopeTitle: 'Onde os ajustes ficam',
114 scopeUser: 'Seus ajustes',
115 scopeUserHelp: 'Guardados para o seu usuário: iguais em toda sessão e projeto, e as sessões abertas pegam a mudança em segundos',
116 scopeSession: 'Esta sessão',
117 scopeSessionHelp: 'Algumas coisas só fazem sentido na sessão em que rodam, como manter o cache dela aquecido. Elas vêm marcadas “só nesta sessão”.',
118 sessionOnly: 'só nesta sessão',
119 keysTitle: 'Teclas',
120 noMods: 'Nenhum mod que entra neste painel está instalado.',
121 noModsHint: 'Instale um pelo marketplace claude-mods e ele aparece aqui.',
122 nothingToSet: 'Nada para ajustar aqui.',
123 couldNotDraw: 'não desenhou',
124 failed: error => `Este mod não conseguiu desenhar a tela: ${error}`,
125 missing: names => `Não instalados: ${names}`,
126 footer: 'Esc fecha · Tab e as setas navegam · 1–9 abrem um mod · 0 volta',
127 command: 'Abre o painel de mods: todos os mods instalados num lugar só',
128}
129
130export const HUB_STRINGS: Readonly<Record<ModsLang, HubStrings>> = { en, pt }
131hooks/demo-client.tsx 16 lines1import type { ClientModule } from 'claude-code'
2
3export type DemoClientProps = { hint: string }
4
5// A region drawn by a surface module rather than the hooks module: it hears the pointer itself,
6// which no element does, and draws where it is.
7const DemoClient: ClientModule<DemoClientProps, { x: number; y: number; over: boolean }> = (props, surface) => {
8 const { Text } = surface.elements
9 surface.onPointer(e => surface.setState({ x: Math.round(e.x), y: Math.round(e.y), over: e.type !== 'leave' }))
10 const state = surface.state
11
12 return <Text color={state?.over ? 'claude' : 'subtle'}>{state?.over ? `pointer at column ${state.x}, row ${state.y}` : props.hint}</Text>
13}
14
15export default DemoClient
16types/index.d.ts 58 lines1// What a mod's noun hands the hub (PROTOCOL.md): one tab, drawn by the hub.
2
3export type ModsLang = 'en' | 'pt'
4
5export type ModsSurface = 'terminal' | 'desktop' | 'vscode' | 'mobile'
6
7export type ModsPanelArgs = { lang: ModsLang; surface: ModsSurface; columns: number }
8
9export type ModsTone = 'ok' | 'warn' | 'err' | 'muted' | 'accent'
10
11// Where a setting lives: the user's (every session and project, the default) or this session's
12// alone. The hub marks the session's.
13export type ModsScope = 'user' | 'session'
14
15export type ModsItem =
16 | { kind: 'toggle'; id: string; label: string; help?: string; value: boolean; scope?: ModsScope }
17 | { kind: 'choice'; id: string; label: string; help?: string; value: string; options: { value: string; label: string }[]; scope?: ModsScope }
18 // `label` is the button's; `title`, when given, names the row, with `help` under it.
19 | { kind: 'action'; id: string; label: string; title?: string; help?: string; primary?: boolean; scope?: ModsScope }
20 | { kind: 'input'; id: string; label: string; help?: string; placeholder?: string; submitLabel?: string; scope?: ModsScope }
21 | { kind: 'info'; label: string; value: string; tone?: ModsTone }
22 | { kind: 'text'; text: string; tone?: ModsTone }
23 | { kind: 'swatch'; id: string; label: string; color: string; selected: boolean }
24 | { kind: 'tree'; tree: unknown }
25
26export type ModsSection = { id: string; title: string; items: ModsItem[] }
27
28export type ModsPanel = {
29 title: string
30 summary?: string
31 tone?: ModsTone
32 // A progress the summary reports, drawn as a ring beside it.
33 percent?: number
34 sections: ModsSection[]
35 refreshMs?: number
36}
37
38export type ModsActArgs = { id: string; value?: string }
39
40export type ModsActResult = { message?: string }
41
42// One named component a mod draws, for the Components tab (PROTOCOL.md, the catalog).
43export type ModsCatalogEntry = { id: string; name: string; help?: string; tree: unknown }
44
45declare module 'claude-code' {
46 interface PluginState {
47 mods: {
48 // The open view: 'overview', 'components', 'settings' or a mod's noun.
49 tab: string
50 // Bumped after a press or a refresh tick, so the pane draws again from fresh panels.
51 revision: number
52 lang: ModsLang
53 // The Components tab's open section and what its demos were last given.
54 demo: { group: string; element: string; mod: string; clicks: number; typed: string; submitted: string; picked: string; links: number }
55 }
56 }
57}
58