SLOPSHOPPER

modz

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

newpanecommandtimer
★ 2v?MITupdated 2026-10-16partalemre/MODZ/mods/modz
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · modz
│ ┃ MODZ ✕ › fix the failing auth test and add an audit log call │ ┃ MODZ All quiet catalog could not be read │ ┃ ⏺ Read(src/auth.ts) │ ┃ METER ⎿ Read 6 lines │ ┃ ▣ Mods Every mod, its version and health [ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ [ Customize ] ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /modz │ ⎿ modz: Opened the MODZ pane. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · MODZ
MODZ All quiet catalog could not be read METER ▣ Mods Every mod, its version and health [ Open ] [ Customize ]
Pane · modz-mods
[ ← MODZ ] All mods Catalog could not be read marketplace.json is missing or lists no mods.
README

modz

The MODZ panel: one live line from every installed mod, and one click to open any mod's own pane.

Install

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

What you see

The panel opens on the right when a session starts, and again any time with /modz.

  • A head card that leads with what needs you most: a failure, then a warning, then live work. Idle, it says "All quiet". When something leads, a primary button opens that mod's pane (for example "Open Tests").
  • Rows in three sections, each with the mod's one-line report and an Open button. In the terminal the rows also have number keys (1 to 9, then 0).
SectionRowMod
WorkAgentssavvy-progress
Filesfiletree
Planplan-steps
Transceivertransceiver
SafetyTeststest-badge
Checkpointscheckpoint
Replayreplay-theater
MeterUsagehud-pro
Lookskins
Modsmodz (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.

Commands

  • /modz opens the panel and brings it forward.

Settings

OptionDefaultMeaning
openOnStarttrueOpen 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.

Language

The interface is English. Set MODZ_LANG=tr in the env block of ~/.claude/settings.json for Turkish.

Source 26 files
hooks/register.tsx 286 lines
1// 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}
286
hooks/auto-update.ts 74 lines
1// 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}
74
hooks/draw.ts 158 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
158
hooks/catalog.ts 171 lines
1import { 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}
171
hooks/hub.ts 167 lines
1// 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}
167
hooks/hub-pane.tsx 247 lines
1// 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}
247
hooks/layout.ts 57 lines
1// 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)
57
hooks/kit/lang.ts 24 lines
1// 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)
24
hooks/kit/stage.ts 30 lines
1// 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 })
30
hooks/kit/same.ts 15 lines
1// 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}
15
hooks/kit/pin.ts 48 lines
1// 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}
48
hooks/kit/stable.ts 33 lines
1// 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