The MODZ hub: opens on the right when a session starts; agents, files, plan, tests, checkpoints, replay, usage, look and every mod, one click away

The MODZ panel: one live line from every installed mod, and one click to open any mod's own pane.
/plugin marketplace add partalemre/MODZ
/plugin install modz@modz
Or in one step: /plugin install modz --marketplace partalemre/MODZ.
Install this first, then the mods you want. Start a new session, or run /reload-plugins in an open one.
The panel opens on the right when a session starts, and again any time with /modz.
1 to 9, then 0).| Section | Row | Mod |
|---|---|---|
| Work | Agents | savvy-progress |
| Files | filetree | |
| Plan | plan-steps | |
| Transceiver | transceiver | |
| Safety | Tests | test-badge |
| Checkpoints | checkpoint | |
| Replay | replay-theater | |
| Meter | Usage | hud-pro |
| Look | skins | |
| Mods | modz (the catalog below) |
A row shows for a mod the marketplace lists as on, or for a mod that has reported a line. Each mod reports one short line (60 characters at most) with a tone: idle, live, ok, warn or bad. Mods that draw no pane of their own (blast-radius, secret-guard, session-budget, cache-tax, done-ping, reflect-mod, terminal-browser) have no row.
The Mods pane (the last row, or its tab beside the panel) lists every mod in the marketplace: its version, whether it is on, whether its plugin.json could be read (healthy or broken), its group (Look, Safety, Meter, Flow), where it draws (desktop, terminal), and the project it is adapted from. The head says how many are on and whether all are healthy. It reads the marketplace's marketplace.json and each mod's plugin.json from disk, and says "Catalog could not be read" if it can't find them.
The desktop app draws the panel as pictures; the terminal draws the same as text.
/modz opens the panel and brings it forward.| Option | Default | Meaning |
|---|---|---|
openOnStart | true | Open the MODZ panel on the right at every session start. |
The panel's theme follows Claude Code's theme setting.
Uses no model tokens. It only reads local files and the lines the other mods report.
The interface is English. Set MODZ_LANG=tr in the env block of ~/.claude/settings.json for Turkish.
hooks/register.tsx 286 lines1// MODZ: the one pane that opens by itself (DESIGN.md §9). It lists every mod's pane with the
2// mod's own live line, and asks the owning mod to open its pane when a row is pressed. The hub,
3// its catalog ("Modlar") and every mod's pane take turns in one panel (kit/stage.ts): whichever
4// opens last is the one shown, the rest close, so they never pile up as tabs.
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, Register } from 'claude-code'
7
8import type { HubCard } from '../types'
9import { autoUpdateSettings, installOf } from './auto-update'
10import type { Drawing } from './draw'
11import { linkCheck, loadCatalog, marketRoot } from './catalog'
12import type { Market } from './catalog'
13import { cardOf, focusOf, hiddenRowsOf, MODS_PANE, modsTitle, presenceOf, rowsOf } from './hub'
14import { hubTree } from './hub-pane'
15import type { HubActions } from './hub-pane'
16import { EMPTY_LAYOUT, layoutOf, moveUp, setHidden } from './layout'
17import type { HubLayout } from './layout'
18import { setLang } from './kit/lang'
19import { HUB_PANE, paneOf } from './kit/stage'
20import { isSame } from './kit/same'
21import { pinned } from './kit/pin'
22import { stableDrawings } from './kit/stable'
23import { modzTree } from './pane'
24import type { Theme } from './theme'
25import { hubWords } from './words'
26
27const PANE = HUB_PANE
28const TITLE = 'MODZ'
29
30const OPEN = { plugin: 'modz', key: 'open' } as const
31const STAGE = { plugin: 'modz', key: 'stage' } as const
32const cardsAtom = atom({ plugin: 'modz', key: 'cards' } as const, {} as Record<string, HubCard>)
33// The person's arrangement, mirrored from `$.store` (kept across sessions) so a change redraws the hub.
34const layoutAtom = atom({ plugin: 'modz', key: 'layout' } as const, EMPTY_LAYOUT)
35const editingAtom = atom({ plugin: 'modz', key: 'isEditing' } as const, false)
36const ON = { plugin: 'modz', key: 'isOn' } as const
37const LAYOUT_KEY = 'layout'
38const DEFAULT_COLUMNS = 80
39
40// Claude Code's theme names say light or dark; anything else follows the system in the Svg.
41function themeOf(value: unknown): Theme {
42 const name = String(value ?? '').toLowerCase()
43 if (name.includes('light')) return 'light'
44
45 return name.includes('dark') ? 'dark' : 'auto'
46}
47
48async function themeNow($: EngineInterface): Promise<Theme> {
49 const rows = await $.config.list().catch(() => [])
50
51 return themeOf(rows.find(row => row.key === 'theme')?.value)
52}
53
54async function enabledPlugins($: EngineInterface): Promise<Record<string, unknown>> {
55 try {
56 const value = (await $.settings.read()).enabledPlugins
57
58 return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {}
59 } catch {
60 $.ui.log('modz: settings could not be read; enabled state unknown', { to: 'debug' })
61
62 return {}
63 }
64}
65
66// Mods are enabled by a skills-folder symlink (~/.claude/skills, or the project's
67// .claude/skills in a cloud session), which settings never mention.
68async function skillLinks($: EngineInterface): Promise<(name: string) => Promise<boolean>> {
69 const home = await $.env.get('HOME').catch(() => undefined)
70 const cwd = await $.session.cwd().catch(() => undefined)
71
72 return linkCheck({ roots: [home, cwd], exists: path => $.fs.exists(path) })
73}
74
75// A catalog that cannot be read is an empty one: the hub still draws (the engine closes a pane whose drawing
76// throws, and the person would lose the way back to every other pane).
77async function marketOf($: EngineInterface): Promise<Market> {
78 try {
79 const stat = await $.fs.stat($.plugin.root, { resolve: true }).catch(() => undefined)
80 const root = marketRoot(stat?.realPath ?? $.plugin.root)
81
82 return await loadCatalog({ read: path => $.fs.read(path), root, enabledPlugins: await enabledPlugins($), hasLink: await skillLinks($) })
83 } catch (cause) {
84 $.ui.log(`modz: the catalog could not be read: ${String(cause)}`, { to: 'debug' })
85
86 return { name: '', mods: [] }
87 }
88}
89
90// The hub's and the catalog's drawings, kept per what they show (stable.ts); forgotten when the pane opens again.
91const hubDrawings = stableDrawings<Drawing>()
92const catalogDrawings = stableDrawings<Drawing>()
93
94// A pane that will not close is logged, never thrown: the one being shown is already up.
95const closeQuietly = ($: EngineInterface, id: string) =>
96 $.ui.close({ id }).catch(cause => $.ui.log(`modz: ${id} could not be closed: ${String(cause)}`, { to: 'debug' }))
97
98// Puts one of the hub's own panes in the panel and closes the other; every mod's open pane closes too.
99async function show($: EngineInterface, pane: typeof PANE | typeof MODS_PANE): Promise<void> {
100 const title = pane === PANE ? TITLE : modsTitle()
101 const drawings = pane === PANE ? hubDrawings : catalogDrawings
102 drawings.reset()
103 await $.ui.open({ id: pane, title, focus: true })
104 await $.state.set(STAGE, { pane, at: Date.now() })
105 await closeQuietly($, pane === PANE ? MODS_PANE : PANE)
106}
107
108const openHub = ($: EngineInterface) => show($, PANE)
109
110async function saveLayout($: EngineInterface, change: (layout: HubLayout) => HubLayout): Promise<void> {
111 const next = change(await read($, layoutAtom))
112 await update($, layoutAtom, () => next)
113 await $.store.set(LAYOUT_KEY, next)
114}
115
116// Each press of the hub, written where it is kept; a failed write is logged, never thrown into the drawing.
117// The press waits for the work, so the panel has swapped by the time it settles.
118function actionsOf($: EngineInterface): HubActions {
119 const run = (what: string, work: () => Promise<unknown>) =>
120 work().catch(cause => $.ui.log(`modz: ${what} failed: ${String(cause)}`, { to: 'debug' }))
121
122 return {
123 onOpen: pane => run(`opening ${pane}`, () => openPane($, pane)),
124 onEdit: isEditing => run('customizing', () => update($, editingAtom, () => isEditing)),
125 onMove: opts => run('moving a row', () => saveLayout($, layout => moveUp(layout, opts))),
126 onHide: opts => run('hiding a row', () => saveLayout($, layout => setHidden(layout, opts))),
127 onReset: () => run('resetting the hub', () => saveLayout($, () => EMPTY_LAYOUT)),
128 }
129}
130
131// A row press puts that pane in the panel: the catalog is this mod's own pane; any other is its
132// mod's to open, asked through `modz.open`. The hub steps aside once the mod says its pane is
133// shown, so a mod that does not answer leaves the hub where it is rather than an empty panel.
134async function openPane($: EngineInterface, pane: string): Promise<void> {
135 if (pane === MODS_PANE) {
136 await show($, MODS_PANE)
137
138 return
139 }
140 answered = null
141 await $.state.set(OPEN, { pane, at: Date.now() })
142 // The mod answered inside this write, where the engine hands the hub's own hook no event (`.catch`, re-entry).
143 if (answered !== null) await stepAside($, answered)
144}
145
146// The pane a mod said it opened while the hub's own write was still running; read once that write settles.
147let answered: string | null = null
148
149// A mod's pane holds the panel: the hub's panes close and every other mod's pane follows (kit/stage.ts).
150async function stepAside($: EngineInterface, pane: string): Promise<void> {
151 answered = null
152 await $.state.set(STAGE, { pane, at: Date.now() })
153 await closeQuietly($, PANE)
154 await closeQuietly($, MODS_PANE)
155}
156
157// An empty catalog is one that could not be read: the hub says so rather than "0 mods on".
158function metaOf(market: Market): string {
159 if (market.mods.length === 0) return hubWords().unreadable
160 const on = market.mods.filter(mod => mod.isEnabled === true).length
161
162 return hubWords().modsOn(on)
163}
164
165async function textOf($: EngineInterface, path: string): Promise<string | undefined> {
166 return (await $.fs.exists(path)) ? String(await $.fs.read(path)) : undefined
167}
168
169// Turns on Claude Code's own auto-update for the marketplace this copy was installed from (auto-update.ts).
170async function keepUpdated($: EngineInterface): Promise<void> {
171 const install = installOf($.plugin.root)
172 if (install === undefined) return
173 const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await $.env.get('HOME')}/.claude`
174 const settingsPath = `${configDir}/settings.json`
175 const next = autoUpdateSettings({
176 market: install.market,
177 settingsText: await textOf($, settingsPath),
178 knownText: await textOf($, `${install.pluginsDir}/known_marketplaces.json`),
179 })
180 if (next === undefined) return
181 await $.fs.write(settingsPath, next)
182 $.ui.log(`modz: auto-update turned on for the ${install.market} marketplace`, { to: 'debug' })
183}
184
185type On = Parameters<Register>[0]
186type Options = Parameters<Register>[1]
187
188function hubHooks(on: On, options: Options): void {
189 on('session.start', async ($, e, next) => {
190 // The interface language (kit/lang.ts). Spelled out: the engine admits an env read by a literal name only.
191 setLang(await $.env.get('MODZ_LANG').catch(() => undefined))
192 const started = await next(e)
193 const saved = layoutOf(await $.store.get(LAYOUT_KEY).catch(() => undefined))
194 await update($, layoutAtom, () => saved)
195 await $.state.set(ON, true)
196 await $.command.register({ name: 'modz', description: hubWords().command })
197 if (options.openOnStart !== false) {
198 hubDrawings.reset()
199 await $.ui.open({ id: PANE, title: TITLE })
200 }
201 if (options.keepUpdated !== false) {
202 void keepUpdated($).catch(cause => $.ui.log(`modz: auto-update could not be turned on: ${String(cause)}`, { to: 'debug' }))
203 }
204
205 return started
206 })
207
208 // Every mod reports its line under its own `hub` key; the hub keeps the latest of each.
209 on('state.set', { key: 'hub' }, async ($, e, next) => {
210 const done = await next(e)
211 const card = cardOf(e.value)
212 // Widened from the declared reporters: the hub also hears a mod it does not list, and never itself.
213 const from: string = e.plugin
214 if (card === null || from === 'modz') return done
215 // An unchanged card is not kept again: the write would redraw the hub for nothing.
216 if (isSame((await read($, cardsAtom))[from], card)) return done
217 await update($, cardsAtom, cards => ({ ...cards, [from]: card }))
218
219 return done
220 }).catch(($, e, next) => next(e))
221
222 // A mod's "MODZ" button: the hub comes forward, and opens again if it was closed. Any mod may ask.
223 // The hub comes back once the mod's write has settled: inside it, the mod's own hooks hear nothing (re-entry),
224 // so its pane would miss the stage and stay behind as a tab.
225 on('state.set', { key: 'home' }, async ($, e, next) => {
226 const done = await next(e)
227 const from: string = e.plugin
228 if (from !== 'modz') {
229 $.clock.after(0, () => void openHub($).catch(cause => $.ui.log(`modz: the hub could not come back: ${String(cause)}`, { to: 'debug' })))
230 }
231
232 return done
233 }).catch(($, e, next) => next(e))
234
235 // A mod's pane is open: it takes the panel. Said inside the hub's own open request, the engine runs only the
236 // `.catch` (re-entry, where `$` is closed), which notes the pane for `openPane` to act on once its write settles.
237 on('state.set', { key: 'shown' }, async ($, e, next) => {
238 const done = await next(e)
239 const from: string = e.plugin
240 const pane = paneOf(e.value)
241 if (from !== 'modz' && pane !== null) await stepAside($, pane)
242
243 return done
244 }).catch(($, e, next) => {
245 const from: string = e.plugin
246 if (next.error.kind === 're-entry' && from !== 'modz') answered = paneOf(e.value) ?? answered
247
248 return next(e)
249 })
250
251 on('command.run', { command: 'modz' }, async $ => {
252 await openHub($)
253
254 return { text: hubWords().opened }
255 })
256}
257
258function paneHooks(on: On): void {
259 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
260 const ui = $.ui.resolve(e)
261 const market = await marketOf($)
262 const theme = await themeNow($)
263 const columns = e.props.bodyColumns || e.viewport?.columns || DEFAULT_COLUMNS
264 const [cards, layout, isEditing] = [await read($, cardsAtom), await read($, layoutAtom), await read($, editingAtom)]
265 const presence = presenceOf(market)
266 const rows = rowsOf(cards, presence, layout)
267 const view = { rows, hidden: hiddenRowsOf(cards, presence, layout), focus: focusOf(rows), meta: metaOf(market), isEditing }
268
269 return pinned(e, hubTree(ui, { ...view, surface: e.surface, columns, theme, actions: actionsOf($), stable: hubDrawings }))
270 })
271
272 on('ui.render', { component: 'Pane', requestId: MODS_PANE }, async ($, e) => {
273 const ui = $.ui.resolve(e)
274 const columns = e.props.bodyColumns || e.viewport?.columns || DEFAULT_COLUMNS
275
276 const onHome = () => void openHub($).catch(cause => $.ui.log(`modz: the hub could not be opened: ${String(cause)}`, { to: 'debug' }))
277
278 return pinned(e, modzTree(ui, { market: await marketOf($), surface: e.surface, columns, theme: await themeNow($), onHome, stable: catalogDrawings }))
279 })
280}
281
282export const register: Register = (on, options) => {
283 hubHooks(on, options)
284 paneHooks(on)
285}
286hooks/auto-update.ts 74 lines1// Keeps every installed mod current at no cost: Claude Code's own background auto-update refreshes a
2// marketplace and every plugin installed from it, transceiver's own repo included, but it is off for a
3// third-party marketplace until someone turns it on. The hub turns it on once, in the user settings
4// entry the engine reads first, and never touches a choice the person already made there.
5
6type Json = Record<string, unknown>
7
8export type UpdatePlan = {
9 // The marketplace's name, as `plugin@marketplace` ends.
10 market: string
11 // The user settings file's text, or undefined when there is none.
12 settingsText: string | undefined
13 // known_marketplaces.json's text: where the marketplace came from when settings do not say.
14 knownText: string | undefined
15}
16
17// A marketplace read from a local folder loads in place and is always current; nothing to update.
18const LOCAL_SOURCES = new Set(['directory', 'file'])
19
20function isRecord(value: unknown): value is Json {
21 return typeof value === 'object' && value !== null && !Array.isArray(value)
22}
23
24function parsed(text: string | undefined): Json | undefined {
25 if (text === undefined || text.trim() === '') return {}
26 try {
27 const value: unknown = JSON.parse(text)
28
29 return isRecord(value) ? value : undefined
30 } catch {
31 return undefined
32 }
33}
34
35function isRemote(source: unknown): boolean {
36 return isRecord(source) && typeof source.source === 'string' && !LOCAL_SOURCES.has(source.source)
37}
38
39// The settings entry to write, or undefined when there is nothing to change.
40function entryOf(plan: UpdatePlan, declared: unknown): Json | undefined {
41 if (isRecord(declared)) {
42 if ('autoUpdate' in declared || !isRemote(declared.source)) return undefined
43
44 return { ...declared, autoUpdate: true }
45 }
46 const known = parsed(plan.knownText)?.[plan.market]
47 const source = isRecord(known) ? known.source : undefined
48
49 return isRemote(source) ? { source, autoUpdate: true } : undefined
50}
51
52// The new settings text, or undefined to leave the file as it is (unreadable settings included).
53export function autoUpdateSettings(plan: UpdatePlan): string | undefined {
54 const settings = parsed(plan.settingsText)
55 if (settings === undefined || plan.market === '') return undefined
56 const markets = isRecord(settings.extraKnownMarketplaces) ? settings.extraKnownMarketplaces : {}
57 const entry = entryOf(plan, markets[plan.market])
58 if (entry === undefined) return undefined
59
60 return `${JSON.stringify({ ...settings, extraKnownMarketplaces: { ...markets, [plan.market]: entry } }, null, 2)}\n`
61}
62
63// An installed copy lives at <plugins>/cache/<marketplace>/<plugin>/<version>.
64const CACHED = /^(.*)\/cache\/([^/]+)\/[^/]+\/[^/]+\/?$/
65
66// The marketplace a cached install came from and the plugins folder holding known_marketplaces.json;
67// undefined for a copy that loads in place (a skills link, a --plugin-dir, a checkout): always current.
68export function installOf(root: string): { market: string; pluginsDir: string } | undefined {
69 const match = CACHED.exec(root.replaceAll('\\', '/'))
70 if (match?.[1] === undefined || match[2] === undefined) return undefined
71
72 return { pluginsDir: match[1], market: match[2] }
73}
74hooks/draw.ts 158 lines1// The desktop pane's Svg: the head, a group's eyebrow and one row per mod (monogram tile, name,
2// version pill, health dot, one line of description and a quiet line of surfaces and source).
3import type { ModInfo } from './catalog'
4import { upper } from './kit/lang'
5import { eyebrowWidth } from './kit/svg-text'
6import { textWidth } from './kit/svg-metrics'
7import { monogram } from './meta'
8import type { Group } from './meta'
9import { entering, MOTION_CSS } from './kit/motion'
10import type { Cue } from './kit/motion'
11import { svgDoc } from './theme'
12import type { RoleName, Theme } from './theme'
13import { catalogWords, placeLine } from './words'
14
15export type Drawing = { source: string; alt: string; height: number }
16
17// A catalog drawing's cue as the catalog opens (motion.ts); absent, it is drawn still.
18const settled = (spec: { width: number; height: number; theme: Theme; body: string; cue?: Cue }) =>
19 spec.cue === undefined
20 ? svgDoc(spec)
21 : svgDoc({ width: spec.width, height: spec.height, theme: spec.theme, body: entering(spec.body, spec.cue), css: MOTION_CSS })
22
23const HEAD = 48
24const GROUP = 22
25const ROW = 66
26const TILE = 36
27const TEXT_X = TILE + 12
28const PILL = 18
29export const PILL_HEIGHT = PILL
30const PILL_PAD = 8
31const GAP = 6
32const NOTCH = 6
33const DOT_ROOM = 16
34
35export function esc(text: string): string {
36 return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
37}
38
39const MONO_EM = 0.6
40const WEIGHT_BODY = 500
41
42// Measured SF Pro advances (the kit's table), so a badge sits right after the name it follows.
43export function measure(text: string, size: number, weight = WEIGHT_BODY): number {
44 return textWidth(text, size, weight)
45}
46
47const monoMeasure = (text: string, size: number): number => [...text].length * size * MONO_EM
48
49export function fit(text: string, width: number, font: { size: number; weight?: number }): string {
50 const { size, weight = WEIGHT_BODY } = font
51 if (measure(text, size, weight) <= width) return text
52 const chars = [...text]
53 while (chars.length > 1 && measure(`${chars.join('').trimEnd()}…`, size, weight) > width) chars.pop()
54
55 return `${chars.join('').trimEnd()}…`
56}
57
58export function eyebrow(spec: { y: number; width: number; label: string; role: RoleName | null; meta: string }): string {
59 const label = upper(spec.label)
60 const end = NOTCH + 6 + eyebrowWidth(label)
61 const metaStart = spec.width - measure(spec.meta, 12)
62 const notch = spec.role === null ? 'f-strong' : `fg-${spec.role}`
63 const rule = metaStart - end > 24 ? `<rect x="${end + 8}" y="${spec.y - 4}" width="${metaStart - end - 16}" height="1" class="f-line"/>` : ''
64
65 return `<rect x="0.5" y="${spec.y - 7.5}" width="${NOTCH}" height="${NOTCH}" rx="1.5" class="${notch}"/><text x="${NOTCH + 6.5}" y="${spec.y}" class="eb ${spec.role === null ? 'lo' : `fg-${spec.role}`}">${esc(label)}</text>${rule}<text x="${spec.width}" y="${spec.y}" text-anchor="end" class="lb lo">${esc(spec.meta)}</text>`
66}
67
68type PillSpec = { right: number; y: number; text: string; role: RoleName | null; isMono?: boolean; isLeft?: boolean }
69
70// A badge whose right edge is at `right` (its left edge, given `isLeft`); neutral without a role.
71export function pill(spec: PillSpec): { svg: string; width: number } {
72 const textSize = spec.isMono === true ? monoMeasure(spec.text, 10.5) : measure(spec.text, 10.5, 600)
73 const width = Math.ceil(textSize + PILL_PAD * 2)
74 const x = spec.isLeft === true ? spec.right : spec.right - width
75 const frame = spec.role === null ? 'inset line' : `bg-${spec.role} ln-${spec.role}`
76 const ink = spec.role === null ? 'mid' : `fg-${spec.role}`
77 const font = spec.isMono === true ? ' num' : ''
78
79 return {
80 svg: `<rect x="${x + 0.5}" y="${spec.y + 0.5}" width="${width - 1}" height="${PILL - 1}" rx="${PILL / 2}" class="${frame}"/><text x="${x + width / 2}" y="${spec.y + 12.5}" text-anchor="middle" style="font-size:10.5px;font-weight:600" class="${ink}${font}">${esc(spec.text)}</text>`,
81 width,
82 }
83}
84
85export function headSvg(spec: { width: number; theme: Theme; title: string; meta: string; metaRole: RoleName | null; cue?: Cue }): Drawing {
86 const meta = spec.meta === '' ? '' : `<text x="${spec.width}" y="40" text-anchor="end" class="lb ${spec.metaRole === null ? 'lo' : `fg-${spec.metaRole}`}">${esc(spec.meta)}</text>`
87 const title = fit(spec.title, spec.width - measure(spec.meta, 12) - GAP * 2, { size: 17, weight: 620 })
88 const allMods = catalogWords().allMods
89 const body = eyebrow({ y: 12, width: spec.width, label: allMods, role: 'edit', meta: '' }) + `<text x="0" y="40" class="hl hi">${esc(title)}</text>${meta}`
90
91 return { source: settled({ width: spec.width, height: HEAD, theme: spec.theme, body, cue: spec.cue }), alt: `${allMods}: ${spec.title}${spec.meta === '' ? '' : ` · ${spec.meta}`}`, height: HEAD }
92}
93
94// Each group keeps one colour, on its eyebrow notch and on every monogram tile beneath it.
95export const GROUP_ROLE: Record<Group, RoleName | null> = { look: 'edit', safety: 'ok', meter: 'read', flow: 'run', other: null }
96
97export function groupSvg(spec: { width: number; theme: Theme; group: Group; count: number; cue?: Cue }): Drawing {
98 const words = catalogWords()
99 const label = words.groups[spec.group]
100 const count = words.mods(spec.count)
101 const body = eyebrow({ y: 15, width: spec.width, label, role: GROUP_ROLE[spec.group], meta: count })
102
103 return { source: settled({ width: spec.width, height: GROUP, theme: spec.theme, body, cue: spec.cue }), alt: `${label}: ${count}`, height: GROUP }
104}
105
106// The right edge says only what is unusual: a problem or a switched-off mod gets a word,
107// an unknown state a dashed ring, and an on mod one quiet green dot.
108function stateMark(mod: ModInfo, right: number): { svg: string; width: number } {
109 const words = catalogWords()
110 if (!mod.isHealthy) return pill({ right, y: 9, text: words.brokenWord, role: 'danger' })
111 if (mod.isEnabled === false) return pill({ right, y: 9, text: words.offWord, role: null })
112 const cx = right - DOT_ROOM / 2
113 if (mod.isEnabled === null) return { svg: `<circle cx="${cx}" cy="18" r="3.5" fill="none" stroke-width="1.3" stroke-dasharray="2 1.6" class="strong"/>`, width: DOT_ROOM }
114
115 return { svg: `<circle cx="${cx}" cy="18" r="3.5" class="fg-ok"/>`, width: DOT_ROOM }
116}
117
118function stateLabel(mod: ModInfo): string {
119 const words = catalogWords()
120 if (!mod.isHealthy) return words.brokenWord
121 if (mod.isEnabled === null) return words.stateUnknown
122
123 return mod.isEnabled ? words.onWord : words.offWord
124}
125
126function monogramTile(mod: ModInfo): string {
127 const role = mod.isHealthy ? GROUP_ROLE[mod.group] : 'danger'
128 const top = (ROW - TILE) / 2
129 const frame = role === null ? 'inset strong' : `bg-${role} ln-${role}`
130 const ink = role === null ? 'mid' : `fg-${role}`
131 const fade = mod.isEnabled === false ? ' opacity=".5"' : ''
132
133 return `<g${fade}><rect x="0.5" y="${top + 0.5}" width="${TILE - 1}" height="${TILE - 1}" rx="9" class="${frame}"/><text x="${TILE / 2}" y="${top + 22.5}" text-anchor="middle" style="font-size:13px;font-weight:700;letter-spacing:.04em" class="num ${ink}">${esc(monogram(mod.name))}</text></g>`
134}
135
136export function rowSvg(spec: { width: number; theme: Theme; mod: ModInfo; cue?: Cue }): Drawing {
137 const { mod, width } = spec
138 const mark = stateMark(mod, width)
139 // A mod that tracks commits has no version to show, so its row has no pill.
140 const versionText = mod.version === null ? '' : `v${mod.version}`
141 const versionWidth = versionText === '' ? 0 : monoMeasure(versionText, 10.5) + PILL_PAD * 2
142 const nameRoom = width - TEXT_X - mark.width - versionWidth - GAP * 2
143 const name = fit(mod.name, nameRoom, { size: 14, weight: 600 })
144 const version = versionText === '' ? { svg: '' } : pill({ right: TEXT_X + measure(name, 14, 600) + GAP, y: 9, text: versionText, role: null, isMono: true, isLeft: true })
145 const lineRoom = width - TEXT_X - 4
146 const body = [
147 monogramTile(mod),
148 `<text x="${TEXT_X}" y="22" style="font-size:14px;font-weight:600" class="${mod.isEnabled === false ? 'lo' : 'hi'}">${esc(name)}</text>`,
149 version.svg,
150 mark.svg,
151 `<text x="${TEXT_X}" y="41" class="lb ${mod.isHealthy ? 'mid' : 'fg-danger'}">${esc(fit(mod.description, lineRoom, { size: 12 }))}</text>`,
152 `<text x="${TEXT_X}" y="58" class="mi lo">${esc(fit(placeLine(mod), lineRoom, { size: 10.5 }))}</text>`,
153 ].join('')
154 const alt = `${[mod.name, versionText].filter(Boolean).join(' ')} · ${placeLine(mod)} · ${stateLabel(mod)} · ${mod.description}`
155
156 return { source: settled({ width, height: ROW, theme: spec.theme, body, cue: spec.cue }), alt, height: ROW }
157}
158hooks/catalog.ts 171 lines1import { pick } from './kit/lang'
2import { BOTH, META, githubName } from './meta'
3import type { Group, Surface } from './meta'
4import { catalogWords } from './words'
5
6type ReadFile = (path: string) => Promise<unknown>
7type HasLink = (name: string) => Promise<boolean>
8
9export type ModInfo = {
10 name: string
11 // The manifest's or the entry's version; null when neither pins one and the mod tracks commits.
12 version: string | null
13 description: string
14 group: Group
15 surfaces: Surface[]
16 // "owner/repo" of the project a mod is built on; null for one made in MODZ.
17 upstream: string | null
18 isHealthy: boolean
19 // settings' enabledPlugins, else a skills link (user's or the project's); null when neither says.
20 isEnabled: boolean | null
21}
22
23// A marketplace entry as written; any field may be missing or of another type.
24type Entry = { name?: unknown; source?: unknown; version?: unknown; description?: unknown }
25
26type Manifest = { version?: unknown; description?: unknown; repository?: unknown; homepage?: unknown; author?: { url?: unknown } }
27
28export type CatalogOptions = {
29 read: ReadFile
30 root: string
31 // settings' enabledPlugins, keyed "name@market"; narrowed to this marketplace here.
32 enabledPlugins?: Record<string, unknown>
33 // Whether a skills-folder link to the mod exists: how this repo's mods are enabled.
34 hasLink?: HasLink
35}
36
37export type Market = { name: string; mods: ModInfo[] }
38
39const SKILLS_DIR = '.claude/skills'
40
41// A mod is linked from the user's skills folder (every session on this machine) or from the
42// project's (a cloud session gets only what the repo carries). Blank roots are skipped.
43export function linkCheck(spec: { roots: (string | undefined)[]; exists: (path: string) => Promise<boolean> }): HasLink {
44 const dirs = spec.roots.filter((root): root is string => Boolean(root)).map(root => `${root}/${SKILLS_DIR}`)
45
46 return async name => {
47 for (const dir of dirs) if (await spec.exists(`${dir}/${name}`).catch(() => false)) return true
48
49 return false
50 }
51}
52
53// The mod lives in MODZ/mods/modz; given its real path (not the ~/.claude/skills
54// symlink), the marketplace root is two levels up.
55export function marketRoot(realPluginRoot: string): string {
56 return realPluginRoot.replace(/\/mods\/[^/]+\/?$/, '')
57}
58
59const SAFE_NAME = /^[\w.-]+$/
60
61function isRecord(value: unknown): value is Record<string, unknown> {
62 return typeof value === 'object' && value !== null && !Array.isArray(value)
63}
64
65function text(value: unknown): string | undefined {
66 return typeof value === 'string' && value !== '' ? value : undefined
67}
68
69// Undefined when the file is missing, not JSON, or JSON that is not an object.
70async function readJson(read: ReadFile, path: string): Promise<Record<string, unknown> | undefined> {
71 try {
72 const value: unknown = JSON.parse(String(await read(path)))
73
74 return isRecord(value) ? value : undefined
75 } catch {
76 return undefined
77 }
78}
79
80function upstreamOf(name: string, manifest: Manifest | undefined): string | null {
81 return githubName(manifest?.repository) ?? githubName(manifest?.homepage) ?? META[name]?.upstream ?? githubName(manifest?.author?.url)
82}
83
84type Facts = { name: string; entry: Entry; manifest: Manifest | undefined; isEnabled: boolean | null; problem?: string }
85
86function infoOf(facts: Facts): ModInfo {
87 const { name, entry, manifest, problem } = facts
88 const meta = META[name]
89 const summary = meta?.summary === undefined ? undefined : pick(meta.summary)
90
91 return {
92 name,
93 version: text(manifest?.version) ?? text(entry.version) ?? null,
94 description: problem ?? summary ?? text(manifest?.description) ?? text(entry.description) ?? (manifest ? '' : catalogWords().unreadable),
95 group: meta?.group ?? 'other',
96 surfaces: meta?.surfaces ?? BOTH,
97 upstream: upstreamOf(name, manifest),
98 // A manifest without a version is still a readable one.
99 isHealthy: manifest !== undefined && problem === undefined,
100 isEnabled: facts.isEnabled,
101 }
102}
103
104// settings saying false wins; true, or a skills link, means on; otherwise nobody says.
105async function enabledOf(spec: { name: string; flag: unknown; hasLink: HasLink }): Promise<boolean | null> {
106 if (spec.flag === false) return false
107 if (spec.flag === true) return true
108 const isLinked = SAFE_NAME.test(spec.name) && (await spec.hasLink(spec.name).catch(() => false))
109
110 return isLinked ? true : null
111}
112
113// An object source ({ source: "github", repo }) points away from this checkout; only a string is a local path.
114function remoteProblem(source: unknown): string {
115 const repo = isRecord(source) ? text(source.repo) ?? text(source.url) : undefined
116 const remote = catalogWords().remote
117
118 return repo === undefined ? remote : `${remote} (${repo})`
119}
120
121async function entryInfo(spec: { entry: unknown; index: number; options: CatalogOptions; enabled: Record<string, unknown> }): Promise<ModInfo> {
122 const { index, options, enabled } = spec
123 const hasLink = options.hasLink ?? (async () => false)
124 const entry: Entry = isRecord(spec.entry) ? spec.entry : {}
125 const name = text(entry.name)
126 const invalid = catalogWords().invalid
127 if (name === undefined) return infoOf({ name: catalogWords().entry(index + 1), entry, manifest: undefined, isEnabled: null, problem: invalid })
128
129 const isEnabled = await enabledOf({ name, flag: enabled[name], hasLink })
130 if (typeof entry.source !== 'string') return infoOf({ name, entry, manifest: undefined, isEnabled, problem: entry.source === undefined ? invalid : remoteProblem(entry.source) })
131 const dir = `${options.root}/${entry.source.replace(/^\.\//, '')}`
132
133 return infoOf({ name, entry, manifest: await readJson(options.read, `${dir}/.claude-plugin/plugin.json`), isEnabled })
134}
135
136export async function loadCatalog(options: CatalogOptions): Promise<Market> {
137 const market = await readJson(options.read, `${options.root}/.claude-plugin/marketplace.json`)
138 const name = text(market?.name) ?? 'modz'
139 const plugins: unknown = market?.plugins
140 const entries: unknown[] = Array.isArray(plugins) ? plugins : []
141 const enabled = Object.fromEntries(
142 Object.entries(options.enabledPlugins ?? {})
143 .filter(([key]) => key.endsWith(`@${name}`))
144 .map(([key, value]) => [key.slice(0, -name.length - 1), value]),
145 )
146 const mods = await Promise.all(entries.map((entry, index) => entryInfo({ entry, index, options, enabled })))
147
148 return { name, mods }
149}
150
151// "18 mods · all on", "18 mods · 15 on", "18 mods · 3 on · 15 unknown", or just the count when nothing is known.
152export function headline(mods: ModInfo[]): string {
153 const words = catalogWords()
154 const known = mods.filter(mod => mod.isEnabled !== null)
155 const total = words.mods(mods.length)
156 if (known.length === 0) return total
157 const on = known.filter(mod => mod.isEnabled === true).length
158 const state = on === known.length ? words.allOn : words.on(on)
159 const unknown = mods.length - known.length
160
161 return unknown === 0 ? `${total} · ${state}` : `${total} · ${state} · ${words.unknown(unknown)}`
162}
163
164// The catalog head's right side: problems are named, a clean catalog says so quietly.
165export function healthOf(mods: ModInfo[]): { text: string; isBad: boolean } {
166 const words = catalogWords()
167 const bad = mods.filter(mod => !mod.isHealthy).length
168
169 return bad === 0 ? { text: words.allHealthy, isBad: false } : { text: words.broken(bad), isBad: true }
170}
171hooks/hub.ts 167 lines1// The hub's model: which panes it lists, how a mod's reported card reads, and what leads the head.
2// Pure: no `$`, so the tests and both surfaces share it.
3import type { HubCard, HubTone } from '../types'
4import type { Market } from './catalog'
5import { ICON_NAMES } from './kit/icons'
6import type { IconName } from './kit/icons'
7import { arrange, EMPTY_LAYOUT, isHidden } from './layout'
8import type { HubLayout } from './layout'
9import type { RoleName } from './theme'
10import { badgeWord, paneWords } from './words'
11import type { Section } from './words'
12
13export type { Section }
14
15// `badge` is a short word beside the title ("Beta"); `isGuest` marks a row a mod the hub does not know named itself.
16export type HubEntry = {
17 pane: string
18 mod: string
19 title: string
20 icon: IconName
21 role: RoleName
22 section: Section
23 hint: string
24 badge: string | null
25 isGuest: boolean
26}
27
28export const SECTIONS: Section[] = ['work', 'safety', 'meter']
29
30// The catalog is this mod's own second pane, a tab beside the hub.
31export const MODS_PANE = 'modz-mods'
32
33type EntryBase = Omit<HubEntry, 'title' | 'hint' | 'badge' | 'isGuest'> & { isBeta?: true }
34
35const BASES: EntryBase[] = [
36 { pane: 'agents', mod: 'savvy-progress', icon: 'sparkle', role: 'run', section: 'work' },
37 { pane: 'files', mod: 'filetree', icon: 'folder', role: 'read', section: 'work' },
38 { pane: 'plan', mod: 'plan-steps', icon: 'list', role: 'plan', section: 'work' },
39 { pane: 'transceiver', mod: 'transceiver', icon: 'bell', role: 'read', section: 'work', isBeta: true },
40 { pane: 'tests', mod: 'test-badge', icon: 'check', role: 'ok', section: 'safety' },
41 { pane: 'checkpoints', mod: 'checkpoint', icon: 'bookmark', role: 'ok', section: 'safety' },
42 { pane: 'replay', mod: 'replay-theater', icon: 'rewind', role: 'plan', section: 'safety' },
43 { pane: 'usage', mod: 'hud-pro', icon: 'gauge', role: 'warn', section: 'meter' },
44 { pane: 'effort', mod: 'effort-router', icon: 'bolt', role: 'run', section: 'meter' },
45 { pane: 'handoff', mod: 'handoff', icon: 'flag', role: 'plan', section: 'meter' },
46 { pane: 'skins', mod: 'skins', icon: 'eye', role: 'edit', section: 'meter' },
47 { pane: MODS_PANE, mod: 'modz', icon: 'cube', role: 'edit', section: 'meter' },
48]
49
50// The hub's own rows with their title and hint in the interface language, read when drawn.
51export const entries = (): HubEntry[] =>
52 BASES.map(({ isBeta, ...base }) => ({ ...base, ...paneWords(base.pane), badge: isBeta ? badgeWord() : null, isGuest: false }))
53
54const KNOWN_MODS = new Set(BASES.map(base => base.mod))
55
56// A row for a mod the hub does not list, from what its own card says: a new mod shows up without a change here.
57function guestEntry(mod: string, card: HubCard): HubEntry {
58 const icon = ICON_NAMES.includes(card.icon as IconName) ? (card.icon as IconName) : 'cube'
59 const section = SECTIONS.includes(card.section as Section) ? (card.section as Section) : 'work'
60
61 return { pane: card.pane, mod, title: card.title ?? mod, hint: card.hint ?? '', icon, role: 'edit', section, badge: card.badge ?? null, isGuest: true }
62}
63
64export const modsTitle = (): string => paneWords(MODS_PANE).title
65
66const TONES: HubTone[] = ['idle', 'live', 'ok', 'warn', 'bad']
67const DETAIL_MAX = 60
68const TITLE_MAX = 32
69const BADGE_MAX = 12
70// What `$.ui.open` admits as a pane id, so a row's keys stay well formed.
71const PANE_ID = /^[\w-]{1,64}$/
72
73const textOf = (value: unknown, max: number): string | undefined =>
74 typeof value === 'string' && value.trim() !== '' ? value.trim().slice(0, max) : undefined
75
76// A card as another mod wrote it: anything malformed is dropped rather than drawn; a row's own words are kept short.
77export function cardOf(value: unknown): HubCard | null {
78 if (typeof value !== 'object' || value === null) return null
79 const card = value as Partial<Record<keyof HubCard, unknown>>
80 if (typeof card.pane !== 'string' || !PANE_ID.test(card.pane) || typeof card.detail !== 'string' || !TONES.includes(card.tone as HubTone)) return null
81 const own = {
82 title: textOf(card.title, TITLE_MAX),
83 hint: textOf(card.hint, DETAIL_MAX),
84 icon: textOf(card.icon, TITLE_MAX),
85 section: SECTIONS.includes(card.section as Section) ? (card.section as Section) : undefined,
86 badge: textOf(card.badge, BADGE_MAX),
87 }
88 const named = Object.fromEntries(Object.entries(own).filter(([, v]) => v !== undefined))
89
90 return { pane: card.pane, detail: card.detail.slice(0, DETAIL_MAX), tone: card.tone as HubTone, at: Number(card.at) || 0, ...named }
91}
92
93export const ROLE_OF_TONE: Record<HubTone, RoleName | null> = { idle: null, live: 'run', ok: 'ok', warn: 'warn', bad: 'danger' }
94
95const RANK: Record<HubTone, number> = { bad: 4, warn: 3, live: 2, ok: 1, idle: 0 }
96
97export type HubRow = { entry: HubEntry; card: HubCard | null; hotkey: string | null }
98
99// What the catalog says of a mod: on (or not said), switched off, or not listed at all.
100export type Presence = 'listed' | 'off' | 'unlisted'
101
102export function presenceOf(market: Market): (mod: string) => Presence {
103 const listed = new Map(market.mods.map(mod => [mod.name, mod.isEnabled]))
104
105 return name => {
106 const isEnabled = listed.get(name)
107 if (isEnabled === false) return 'off'
108
109 // A mod nobody says is on (a marketplace install lists every mod, installed or not) shows once it reports.
110 return isEnabled === true ? 'listed' : 'unlisted'
111 }
112}
113
114// The terminal's row hotkeys: the digits in keyboard order, so a tenth row still gets one.
115const HOTKEYS = '1234567890'
116
117// A pane belongs to one mod, so only that mod's own card fills its row: a card left behind by a
118// removed mod (or another mod claiming the pane) is never drawn. The catalog is always offered; a
119// mod the catalog does not list (Transceiver, a mod from elsewhere, or an unreadable catalog) shows once it
120// reports, and a mod the hub does not know at all gets a row of its own from its card.
121function candidates(cards: Record<string, HubCard>, presence: (mod: string) => Presence): Array<Omit<HubRow, 'hotkey'>> {
122 const own = (entry: HubEntry) => {
123 const card = cards[entry.mod]
124
125 return card !== undefined && card.pane === entry.pane ? card : null
126 }
127 const known = entries().filter(entry => {
128 const state = presence(entry.mod)
129
130 return entry.pane === MODS_PANE || state === 'listed' || (state === 'unlisted' && own(entry) !== null)
131 })
132 const taken = new Set(entries().map(entry => entry.pane))
133 const guests = Object.entries(cards)
134 .filter(([mod, card]) => mod !== 'modz' && !KNOWN_MODS.has(mod) && presence(mod) !== 'off' && !taken.has(card.pane))
135 .map(([mod, card]) => guestEntry(mod, card))
136
137 return [...known, ...guests].map(entry => ({ entry, card: own(entry) }))
138}
139
140const panedOf = <T extends { entry: HubEntry }>(rows: T[]) => rows.map(row => ({ ...row, pane: row.entry.pane }))
141
142// The rows the hub draws, in the person's order with the rows they hid left out; the digits follow what is drawn.
143export function rowsOf(cards: Record<string, HubCard>, presence: (mod: string) => Presence, layout: HubLayout = EMPTY_LAYOUT): HubRow[] {
144 const shown = arrange(panedOf(candidates(cards, presence)), layout).filter(row => !isHidden(layout, row.pane))
145
146 return shown.map(({ entry, card }, i) => ({ entry, card, hotkey: HOTKEYS[i] ?? null }))
147}
148
149// The rows the person hid, offered back while they customize the hub.
150export function hiddenRowsOf(cards: Record<string, HubCard>, presence: (mod: string) => Presence, layout: HubLayout): HubRow[] {
151 return candidates(cards, presence)
152 .filter(row => isHidden(layout, row.entry.pane))
153 .map(({ entry, card }) => ({ entry, card, hotkey: null }))
154}
155
156export type Focus = { row: HubRow; tone: HubTone }
157
158// What leads the head: the most pressing card (a failure, then a warning, then live work), newest first.
159export function focusOf(rows: HubRow[]): Focus | null {
160 const ranked = rows
161 .filter(row => row.card !== null && row.card.detail !== '' && RANK[row.card.tone] >= RANK.live)
162 .sort((a, b) => RANK[b.card!.tone] - RANK[a.card!.tone] || b.card!.at - a.card!.at)
163 const top = ranked[0]
164
165 return top === undefined || top.card === null ? null : { row: top, tone: top.card.tone }
166}
167hooks/hub-pane.tsx 247 lines1// The MODZ hub pane: the head card, then each section's rows with an "Open" button that asks the
2// owning mod to open its pane. On the desktop the buttons sit over the right edge of the drawing they belong to
3// (a drawing cannot be pressed), so a row is one surface: its hover and its button share one edge. "Customize" turns the buttons into Up and Hide, and lists the hidden rows
4// with Show. The catalog ("Mods") is a pane of its own (register.tsx).
5import type { Drawing } from './draw'
6import type { Cue } from './kit/motion'
7import type { Focus, HubRow, Section } from './hub'
8import { ROLE_OF_TONE, SECTIONS } from './hub'
9import { hubHeadSvg, hubRowSvg, sectionSvg } from './hub-draw'
10import { GLYPH } from './kit/icons'
11import { upper } from './kit/lang'
12import type { Theme } from './theme'
13import type { Stable } from './kit/stable'
14import { hubWords } from './words'
15
16// The resolved element table of the surface that draws the pane.
17type Ui = Record<string, any>
18
19// What a press asks of the hub; register.tsx writes each one where it is kept.
20export type HubActions = {
21 onOpen: (pane: string) => unknown
22 onEdit: (isEditing: boolean) => unknown
23 onMove: (opts: { pane: string; panes: string[] }) => unknown
24 onHide: (opts: { pane: string; isHidden: boolean }) => unknown
25 onReset: () => unknown
26}
27
28export type HubView = {
29 rows: HubRow[]
30 hidden: HubRow[]
31 focus: Focus | null
32 meta: string
33 surface: string
34 columns: number
35 theme: Theme
36 isEditing: boolean
37 actions: HubActions
38 // The drawings kept since the hub was put in the panel (stable.ts), so an unchanged one keeps its frame.
39 stable: Stable<Drawing>
40}
41
42const PX_PER_COLUMN = 8
43const WIDTH_MIN = 280
44const WIDTH_MAX = 640
45// Room the native buttons take over a row's right edge: one "Open", or "Up" and "Hide" while customizing.
46const BUTTON_ROOM = 64
47const EDIT_BUTTON_ROOM = 136
48// The focus button's room in the head: its label's width and the button's own padding and inset.
49const FOCUS_CHROME = 48
50const FOCUS_CHAR_PX = 7
51// Where an overlaid button sits, in layout cells from the drawing's right and bottom edges.
52const INSET = 1
53// The head's mark and its text enter first; the sections and rows follow.
54const HEAD_STEPS = 4
55
56export const hubWidth = (columns: number): number => Math.max(WIDTH_MIN, Math.min(WIDTH_MAX, columns * PX_PER_COLUMN - PX_PER_COLUMN))
57
58const inSection = (rows: HubRow[], section: Section) => rows.filter(row => row.entry.section === section)
59
60// The one primary button: straight to whatever leads the head ("Open Tests"). Not while customizing.
61function focusButton(ui: Ui, view: HubView) {
62 const { Box, Button } = ui
63 const focus = view.focus
64 if (focus === null || view.isEditing) return null
65 const pane = focus.row.entry.pane
66
67 return (
68 <Box key="focus" flexDirection="row">
69 <Button key="open-focus" label={focusLabel(focus)} variant="primary" onPress={() => view.actions.onOpen(pane)} />
70 </Box>
71 )
72}
73
74const focusLabel = (focus: Focus) => hubWords().openPane(focus.row.entry.title)
75
76const focusRoom = (view: HubView) =>
77 view.focus === null || view.isEditing ? 0 : focusLabel(view.focus).length * FOCUS_CHAR_PX + FOCUS_CHROME
78
79// A row's buttons: "Open" (the terminal shows its digit), or while customizing "Up" (not on a section's
80// first row) and "Hide"; a hidden row offers "Show".
81function rowButtons(ui: Ui, view: HubView, opts: { row: HubRow; panes: string[] | null }) {
82 const { Button } = ui
83 const { row, panes } = opts
84 const pane = row.entry.pane
85 const words = hubWords()
86 if (panes === null) return [<Button key={`show-${pane}`} label={words.show} variant="secondary" onPress={() => view.actions.onHide({ pane, isHidden: false })} />]
87 if (!view.isEditing) {
88 const hotkey = view.surface === 'desktop' || row.hotkey === null ? {} : { hotkey: row.hotkey }
89
90 return [<Button key={`open-${pane}`} label={words.open} variant="secondary" {...hotkey} onPress={() => view.actions.onOpen(pane)} />]
91 }
92 const up = panes.indexOf(pane) > 0 ? [<Button key={`up-${pane}`} label={words.up} variant="secondary" onPress={() => view.actions.onMove({ pane, panes })} />] : []
93
94 return [...up, <Button key={`hide-${pane}`} label={words.hide} variant="secondary" onPress={() => view.actions.onHide({ pane, isHidden: true })} />]
95}
96
97// "Customize" under the rows; while customizing, "Done" and "Reset" with a line saying what the buttons do.
98function editBar(ui: Ui, view: HubView) {
99 const { Box, Button, Text } = ui
100 const words = hubWords()
101 if (!view.isEditing) {
102 return (
103 <Box key="edit" flexDirection="row">
104 <Button key="customize" label={words.customize} variant="secondary" onPress={() => view.actions.onEdit(true)} />
105 </Box>
106 )
107 }
108
109 return (
110 <Box key="edit" flexDirection="column" rowGap={1}>
111 <Text key="edit-line" dimColor>{words.editLine}</Text>
112 <Box key="edit-buttons" flexDirection="row" columnGap={1}>
113 <Button key="customize-done" label={words.done} variant="primary" onPress={() => view.actions.onEdit(false)} />
114 <Button key="customize-reset" label={words.reset} variant="secondary" onPress={() => view.actions.onReset()} />
115 </Box>
116 </Box>
117 )
118}
119
120type Group = { key: string; label: string; rows: HubRow[]; isHiddenGroup: boolean }
121
122// The sections that have rows, and while customizing the hidden rows as one more group at the end.
123function groupsOf(view: HubView): Group[] {
124 const words = hubWords()
125 const sections = SECTIONS.map(section => ({ key: section, label: words.sections[section], rows: inSection(view.rows, section), isHiddenGroup: false }))
126 const hidden = view.isEditing && view.hidden.length > 0 ? [{ key: 'hidden', label: words.hidden, rows: view.hidden, isHiddenGroup: true }] : []
127
128 return [...sections, ...hidden].filter(group => group.rows.length > 0)
129}
130
131// What a row's drawing shows: its words and tone, not when its card was written.
132function rowShows(row: HubRow, look: { width: number; theme: Theme; buttonRoom: number }) {
133 const card = row.card === null ? null : { detail: row.card.detail, tone: row.card.tone }
134
135 return { ...look, entry: row.entry, card }
136}
137
138// A drawing with native controls laid over its right edge; the box hugs the drawing so the edge is its own.
139function overlaid(ui: Ui, opts: { key: string; drawing: unknown; controls: unknown; at: 'middle' | 'bottom' }) {
140 const { Box } = ui
141 const place = opts.at === 'bottom' ? { bottom: INSET, right: INSET } : { top: INSET, right: INSET }
142
143 return (
144 <Box key={opts.key} alignSelf="flex-start">
145 {opts.drawing}
146 {opts.controls === null ? null : <Box position="absolute" {...place} flexDirection="row" columnGap={1}>{opts.controls}</Box>}
147 </Box>
148 )
149}
150
151function desktopTree(ui: Ui, view: HubView) {
152 const { Box, Svg } = ui
153 const width = hubWidth(view.columns)
154 const draw = (drawing: Drawing, key: string, drawnWidth: number) => (
155 <Svg key={key} source={drawing.source} alt={drawing.alt} width={drawnWidth} height={drawing.height} isInteractive />
156 )
157 const buttonRoom = view.isEditing ? EDIT_BUTTON_ROOM : BUTTON_ROOM
158 const headRoom = focusRoom(view)
159 // Each drawing's place in the opening cascade, top to bottom: the head's own pieces take the first few.
160 let order = HEAD_STEPS
161 const kept = (key: string, what: unknown, build: (cue: Cue) => Drawing) => {
162 const at = order++
163
164 return view.stable.draw(key, what, phase => build({ phase, order: at }))
165 }
166 const focus = view.focus === null ? null : { pane: view.focus.row.entry.pane, tone: view.focus.tone, detail: view.focus.row.card?.detail }
167 const head = view.stable.draw('head', { width, theme: view.theme, focus, meta: view.meta, headRoom }, phase =>
168 hubHeadSvg({ width, theme: view.theme, focus: view.focus, meta: view.meta, buttonRoom: headRoom, cue: { phase, order: 0 } }))
169
170 return (
171 <Box flexDirection="column" rowGap={1}>
172 {overlaid(ui, { key: 'head-card', drawing: draw(head, 'head', width), controls: focusButton(ui, view), at: 'bottom' })}
173 {groupsOf(view).map(group => {
174 const panes = group.isHiddenGroup ? null : group.rows.map(row => row.entry.pane)
175
176 return (
177 <Box key={`section-${group.key}`} flexDirection="column">
178 {draw(kept(`eyebrow-${group.key}`, { width, theme: view.theme, label: group.label }, cue => sectionSvg({ width, theme: view.theme, label: group.label, cue })), `eyebrow-${group.key}`, width)}
179 {group.rows.map(row => {
180 const shows = rowShows(row, { width, theme: view.theme, buttonRoom })
181 const drawing = draw(kept(`row-${row.entry.pane}`, shows, cue => hubRowSvg({ width, theme: view.theme, row, buttonRoom, cue })), `svg-${row.entry.pane}`, width)
182
183 return overlaid(ui, { key: `row-${row.entry.pane}`, drawing, controls: rowButtons(ui, view, { row, panes }), at: 'middle' })
184 })}
185 </Box>
186 )
187 })}
188 {editBar(ui, view)}
189 </Box>
190 )
191}
192
193const TONE_COLOR: Record<string, string> = { run: 'permission', ok: 'success', warn: 'warning', danger: 'error' }
194
195function terminalRow(ui: Ui, view: HubView, opts: { row: HubRow; panes: string[] | null }) {
196 const { Box, Text } = ui
197 const { row } = opts
198 const card = row.card
199 const role = card === null ? null : ROLE_OF_TONE[card.tone]
200 const hasDetail = card !== null && card.detail !== ''
201
202 return (
203 <Box key={`row-${row.entry.pane}`} flexDirection="row" columnGap={1}>
204 <Text color="claude">{GLYPH[row.entry.icon]}</Text>
205 <Text bold>{row.entry.title}</Text>
206 {row.entry.badge === null ? null : <Text color="permission">{row.entry.badge}</Text>}
207 {hasDetail ? <Text color={role === null ? undefined : TONE_COLOR[role]} wrap="truncate">{card.detail}</Text> : <Text dimColor wrap="truncate">{row.entry.hint}</Text>}
208 <Box flexGrow={1} />
209 {rowButtons(ui, view, opts)}
210 </Box>
211 )
212}
213
214function terminalTree(ui: Ui, view: HubView) {
215 const { Box, Text } = ui
216 const focus = view.focus
217 const words = hubWords()
218
219 return (
220 <Box flexDirection="column" rowGap={1}>
221 <Box flexDirection="row" columnGap={2}>
222 <Text color="claude" bold>MODZ</Text>
223 {focus === null ? <Text dimColor>{words.idleTitle}</Text> : <Text dimColor>{`${words.now} · ${focus.row.entry.title}`}</Text>}
224 {focus === null ? null : <Text color={TONE_COLOR[ROLE_OF_TONE[focus.tone] ?? '']} wrap="truncate">{focus.row.card?.detail ?? ''}</Text>}
225 <Box flexGrow={1} />
226 <Text dimColor>{view.meta}</Text>
227 </Box>
228 {focusButton(ui, view)}
229 {groupsOf(view).map(group => {
230 const panes = group.isHiddenGroup ? null : group.rows.map(row => row.entry.pane)
231
232 return (
233 <Box key={`section-${group.key}`} flexDirection="column">
234 <Text bold dimColor>{upper(group.label)}</Text>
235 {group.rows.map(row => terminalRow(ui, view, { row, panes }))}
236 </Box>
237 )
238 })}
239 {editBar(ui, view)}
240 </Box>
241 )
242}
243
244export function hubTree(ui: Ui, view: HubView) {
245 return view.surface === 'desktop' && 'Svg' in ui ? desktopTree(ui, view) : terminalTree(ui, view)
246}
247hooks/layout.ts 57 lines1// The person's arrangement of the hub: which rows they moved up and which they hid. Pure: the register file
2// keeps it in `$.store` across sessions and in state for the drawing.
3import type { HubLayout } from '../types'
4
5export type { HubLayout }
6
7export const EMPTY_LAYOUT: HubLayout = { hidden: [], order: [] }
8
9// A pane id as the hub keys a row by: what `$.ui.open` admits.
10const PANE_ID = /^[\w-]{1,64}$/
11const MAX_PANES = 64
12
13const panesOf = (value: unknown): string[] =>
14 Array.isArray(value) ? [...new Set(value.filter((pane): pane is string => typeof pane === 'string' && PANE_ID.test(pane)))].slice(0, MAX_PANES) : []
15
16// What the store held, read back: anything malformed counts as no arrangement.
17export function layoutOf(value: unknown): HubLayout {
18 if (typeof value !== 'object' || value === null) return EMPTY_LAYOUT
19 const raw = value as Partial<Record<keyof HubLayout, unknown>>
20
21 return { hidden: panesOf(raw.hidden), order: panesOf(raw.order) }
22}
23
24type Placed = { pane: string }
25
26// The items in the person's order: a pane they placed sits at its place, the rest keep theirs after it.
27export function arrange<T extends Placed>(items: readonly T[], layout: HubLayout): T[] {
28 const rank = (item: T) => {
29 const at = layout.order.indexOf(item.pane)
30
31 return at === -1 ? layout.order.length : at
32 }
33
34 return items.map((item, i) => ({ item, i })).sort((a, b) => rank(a.item) - rank(b.item) || a.i - b.i).map(({ item }) => item)
35}
36
37// Moves `pane` one place up among `panes` (one section's rows as drawn); the order then names every row there.
38export function moveUp(layout: HubLayout, opts: { panes: readonly string[]; pane: string }): HubLayout {
39 const panes = [...opts.panes]
40 const at = panes.indexOf(opts.pane)
41 if (at <= 0) return layout
42 const above = panes[at - 1] as string
43 panes[at - 1] = opts.pane
44 panes[at] = above
45 const rest = layout.order.filter(pane => !panes.includes(pane))
46
47 return { ...layout, order: [...panes, ...rest] }
48}
49
50export function setHidden(layout: HubLayout, opts: { pane: string; isHidden: boolean }): HubLayout {
51 const others = layout.hidden.filter(pane => pane !== opts.pane)
52
53 return { ...layout, hidden: opts.isHidden ? [...others, opts.pane] : others }
54}
55
56export const isHidden = (layout: HubLayout, pane: string): boolean => layout.hidden.includes(pane)
57hooks/kit/lang.ts 24 lines1// MODZ kit — the interface language: English, or Turkish when the person sets MODZ_LANG=tr (the "env" of
2// ~/.claude/settings.json). One value per mod: its register file reads the variable at session.start with
3// `setLang(await $.env.get('MODZ_LANG'))` (the engine only admits an env read whose name is a literal), and every
4// drawing reads it back through `pick` and `upper`.
5export type Lang = 'en' | 'tr'
6
7let current: Lang = 'en'
8
9export const langOf = (value: unknown): Lang => (typeof value === 'string' && /^tr/i.test(value.trim()) ? 'tr' : 'en')
10
11export function setLang(value: unknown): Lang {
12 current = langOf(value)
13
14 return current
15}
16
17export const lang = (): Lang => current
18
19// The current language's entry of a table that fills in both: `pick({ en: 'Open', tr: 'Aç' })`.
20export const pick = <T>(table: Readonly<Record<Lang, T>>): T => table[current]
21
22// Capitals for eyebrows and labels: Turkish keeps its dotted İ, English never gets one.
23export const upper = (text: string): string => text.toLocaleUpperCase(current)
24hooks/kit/stage.ts 30 lines1// MODZ kit — one panel (DESIGN.md §9). The hub and every mod's pane take turns in one place instead of
2// piling up as tabs: a mod that opens its pane writes its own `{ plugin: '<mod>', key: 'shown' }` (`{ pane, at }`);
3// the hub then steps aside and writes `{ plugin: 'modz', key: 'stage' }` naming that pane, and every other mod
4// whose pane is open closes it. The hub coming back (its row, "← MODZ", /modz) writes the stage as `modz`.
5
6export const HUB_PANE = 'modz'
7
8export type Stage = { pane: string; at: number }
9
10// The pane a `modz.open`, `modz.stage` or `shown` value names; null for anything malformed.
11export const paneOf = (value: unknown): string | null => {
12 if (typeof value !== 'object' || value === null) return null
13 const pane = (value as { pane?: unknown }).pane
14
15 return typeof pane === 'string' && pane !== '' ? pane : null
16}
17
18// Whether a `modz.open` write asks for this pane.
19export const isAskedFor = (value: unknown, pane: string): boolean => paneOf(value) === pane
20
21// Whether a `modz.stage` write puts another pane in the panel, so this one closes.
22export function isOffStage(value: unknown, pane: string): boolean {
23 const staged = paneOf(value)
24
25 return staged !== null && staged !== pane
26}
27
28// What a mod writes under its own `shown` key once its pane is open.
29export const shownOf = (pane: string, at: number): Stage => ({ pane, at })
30hooks/kit/same.ts 15 lines1// MODZ kit — structural equality for plain state values (JSON-like: objects, arrays, primitives).
2// A `$.state` write redraws every reader, and every redraw hands out new press handles, so a
3// value that did not change is not written again.
4export function isSame(a: unknown, b: unknown): boolean {
5 if (Object.is(a, b)) return true
6 if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
7 if (Array.isArray(a) !== Array.isArray(b)) return false
8 const left = a as Record<string, unknown>
9 const right = b as Record<string, unknown>
10 const keys = Object.keys(left)
11 if (keys.length !== Object.keys(right).length) return false
12
13 return keys.every(key => Object.prototype.hasOwnProperty.call(right, key) && isSame(left[key], right[key]))
14}
15hooks/kit/pin.ts 48 lines1// MODZ kit — pinned press handles. Every element the JSX runtime builds gets a fresh handle, and the
2// host releases a drawing's old handles the moment it redraws. A desktop holds the tree it last
3// painted, so a click that lands just after any redraw (a state write, a focus flip, a poll) names a
4// released handle and the engine drops it ("ui_press not handled"). The host keeps a handle alive
5// across redraws when the new tree carries the same one, so each element here keeps the first handle
6// its site and key ever got: every tree a surface still holds stays pressable, with the latest closure.
7import type { RenderElement } from 'claude-code'
8
9export type PinSite = { surface: string; component?: string; requestId?: string }
10
11type Node = { type?: string; props?: { key?: unknown }; press?: { plugin: string; handle: number }; children?: unknown[] }
12
13// Far above the runtime's own counter, which starts at 1 and climbs by one per element drawn.
14const FIRST_PINNED = 1_000_000_000
15const pins = new Map<string, number>()
16
17function handleFor(id: string): number {
18 let handle = pins.get(id)
19 if (handle === undefined) {
20 handle = FIRST_PINNED + pins.size
21 pins.set(id, handle)
22 }
23
24 return handle
25}
26
27function pinNode(node: unknown, ctx: { site: string; seen: Map<string, number> }): unknown {
28 if (typeof node !== 'object' || node === null) return node
29 const el = node as Node
30 const children = el.children?.map(child => pinNode(child, ctx))
31 const withKids = children === undefined ? el : { ...el, children }
32 // Only this plugin's own elements: the runtime names the plugin of every tree `next` returned.
33 if (el.press?.plugin !== '' || typeof el.props?.key !== 'string') return withKids
34 const base = `${ctx.site}\u0000${el.type}\u0000${el.props.key}`
35 const nth = ctx.seen.get(base) ?? 0
36 ctx.seen.set(base, nth + 1)
37
38 return { ...withKids, press: { plugin: '', handle: handleFor(`${base}\u0000${nth}`) } }
39}
40
41// Return a render hook's tree through this: `return pinned(e, tree)`.
42export function pinned<T extends RenderElement | null | undefined>(site: PinSite, tree: T): T {
43 if (tree === null || tree === undefined) return tree
44 const ctx = { site: `${site.surface}\u0000${site.component ?? ''}\u0000${site.requestId ?? ''}`, seen: new Map<string, number>() }
45
46 return pinNode(tree, ctx) as T
47}
48hooks/kit/stable.ts 33 lines1// MODZ kit — keeps each drawing's source fixed per what it shows (motion.ts): the desktop rebuilds an interactive
2// Svg's frame whenever its source changes, which would restart its loops and flash it blank. A drawing is built
3// again only when what it shows changes; its first build since the pane opened enters, a later one sweeps.
4
5// A build is either a drawing's first since the pane opened or one for a change of what it shows.
6export type Built = 'enter' | 'change'
7
8type Kept<T> = { what: string; drawing: T }
9
10export type Stable<T> = {
11 // Forgets every drawing: the pane opened again, so each one enters anew.
12 reset: () => void
13 // The drawing under `key` for `what` it shows (any plain value): the kept one while that is unchanged.
14 draw: (key: string, what: unknown, build: (phase: Built) => T) => T
15}
16
17export function stableDrawings<T>(): Stable<T> {
18 const kept = new Map<string, Kept<T>>()
19
20 return {
21 reset: () => kept.clear(),
22 draw: (key, what, build) => {
23 const signature = typeof what === 'string' ? what : JSON.stringify(what)
24 const before = kept.get(key)
25 if (before !== undefined && before.what === signature) return before.drawing
26 const drawing = build(before === undefined ? 'enter' : 'change')
27 kept.set(key, { what: signature, drawing })
28
29 return drawing
30 },
31 }
32}
33