Your installed Claude Code mods in one pane: browse them with their READMEs, switch each on or off, and open their panels. /mods opens it.

한국어 · English
설치한 Claude Code mod를 한 패널에서 관리합니다. 무엇이 깔려 있는지 둘러보고, README를 읽고, 하나씩 켜고 끄고, 각 mod의 패널을 버튼으로 바로 열 수 있어요.
<img src="../../images/mod-manager-ko.png" alt="mod-manager" width="100%">
● 켜짐 / ○ 꺼짐, 버전, 줄 끝 ON OFF 스위치.claude plugin enable|disable을 실행합니다. 같은 플러그인이 여러 범위에 깔려 있으면 이 프로젝트에 실제로 적용되는 것(local › project › user)을 바꿔요. 상태줄의 ⟳ Reload로 바로 적용합니다./diffs 같은 버튼으로 나오고, 누르면 그 mod의 패널이 열립니다.All로 스킬 · MCP · LSP 플러그인까지 전부.this)과 이 세션에서 개발 중인 mod(dev)에는 스위치가 없어요. 끄면 이 패널로 되돌릴 방법이 없어서요./plugin install mod-manager --marketplace speardragon/ray-claude-code-mods
| 입력 | 동작 |
|---|---|
/mods | 패널 켜기 / 끄기 (토글, 켤 때마다 설치 목록을 새로 읽음) |
/mods on · /mods off | 패널 켜기 · 끄기 |
/mods refresh | 설치 목록 다시 읽기 |
| 목록 줄 클릭 | 그 mod의 상세 보기 |
줄 끝 ON / OFF, 상세의 ● Enabled | 켜기 / 끄기 |
상세의 /명령 버튼 | 그 명령 실행 (mod의 패널 열기) |
툴바 Mods │ All · ⟳ Refresh | 필터 전환 · 다시 읽기 |
상태줄 ⟳ Reload | /reload-plugins로 바뀐 상태 적용 |
목록 클릭 후 ↑ ↓ | mod 이동, Space 켜기 / 끄기, Enter 첫 번째 명령 실행 |
| 휠 · 스크롤바 | 커서가 올라간 컬럼(목록 또는 README)만 스크롤 |
/reload-plugins 뒤에 적용됩니다. ⟳ Reload가 거절되면 상태줄 안내대로 직접 입력하세요.claude plugin list --json을 읽어 만듭니다. 처음 여는 데 1~2초 걸릴 수 있어요.hooks/register.tsx 360 lines1// mod-manager: the installed Claude Code mods (and, under All, every plugin) in one pane.
2// Left, the list grouped by marketplace, each with a switch; right, the picked one: its scope,
3// its slash commands as buttons (a mod's panel opens from its command) and its README.
4// A switch runs `claude plugin enable|disable` at the scope that applies here; the change takes
5// hold at the next /reload-plugins, which the footer offers. The manager's own row and this
6// session's dev mods have no switch: turning those off would take the pane or the session's work
7// away with no way back from here.
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register } from 'claude-code'
10
11import type { Filter, ModCommand, ModEntry, Scroll } from '../types'
12import { applicable, entry, parseList, sortEntries } from './catalog'
13import type { ListRow } from './list-client'
14import { SWITCH } from './list-client'
15import { mdRows } from './markdown'
16import type { MdRow } from './markdown'
17import { textWidth, truncate } from './text'
18import { detailHead, layout, Pane } from './view'
19import type { Layout } from './view'
20
21const PANE = 'mod-manager'
22const TITLE = 'Mods'
23const DOCK_COLUMNS = 110
24const CLI_TIMEOUT_MS = 60_000
25
26// Held by the host, so a hot reload keeps them.
27const entries = atom({ plugin: 'mod-manager', key: 'entries' } as const, [] as ModEntry[])
28const loadedAt = atom({ plugin: 'mod-manager', key: 'loadedAt' } as const, 0)
29const selected = atom({ plugin: 'mod-manager', key: 'selected' } as const, null as string | null)
30const filter = atom({ plugin: 'mod-manager', key: 'filter' } as const, 'mods' as Filter)
31const scroll = atom({ plugin: 'mod-manager', key: 'scroll' } as const, { list: 0, detail: 0, detailFor: null } as Scroll)
32const busy = atom({ plugin: 'mod-manager', key: 'busy' } as const, '')
33const note = atom({ plugin: 'mod-manager', key: 'note' } as const, '')
34const needsReload = atom({ plugin: 'mod-manager', key: 'needsReload' } as const, false)
35
36// The module's own bookkeeping; a reload starts it over.
37const session = { cwd: '', command: 'mods', self: '', loading: null as Promise<void> | null }
38const drawn = { layout: null as Layout | null, listTotal: 0, readmeTotal: 0, readmeRows: 0, order: [] as string[], shown: null as string | null }
39const readmes = new Map<string, string | null>() // README text by plugin root
40const mdCache = new Map<string, { width: number; rows: MdRow[] }>()
41
42const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n))
43const errorText = (error: unknown) => (error instanceof Error ? error.message : String(error))
44
45async function readFile($: EngineInterface, path: string): Promise<string | null> {
46 const text = await $.fs.read(path).catch(() => null)
47 return typeof text === 'string' ? text : null
48}
49
50// Each plugin's slash commands, as the session lists them now.
51async function commandsByPlugin($: EngineInterface): Promise<Map<string, ModCommand[]>> {
52 const all = await $.command.list().catch(() => [])
53 const by = new Map<string, ModCommand[]>()
54 for (const c of all) {
55 if (c.source !== 'plugin' || !c.plugin) continue
56 const name = c.plugin.split('@')[0]!
57 by.set(name, [...(by.get(name) ?? []), { name: c.name, description: c.description }])
58 }
59 return by
60}
61
62// The mods this session hot-reloads from its own folder: not installed, so not in the CLI's list.
63async function sessionMods($: EngineInterface, commands: Map<string, ModCommand[]>, installed: Set<string>): Promise<ModEntry[]> {
64 const parent = $.plugin.root.slice(0, $.plugin.root.lastIndexOf('/'))
65 if (!/\/dev-mods\/[^/]+$/.test(parent)) return []
66 const listed = await $.fs.list(parent).catch(() => [])
67 const found = await Promise.all(
68 listed.map(async item => {
69 const root = `${parent}/${item.name}`
70 const pluginJson = await readFile($, `${root}/.claude-plugin/plugin.json`)
71 if (pluginJson === null || installed.has(item.name)) return null
72 const hooksJson = await readFile($, `${root}/hooks/hooks.json`)
73 return entry({ id: item.name, scope: 'session', enabled: true, root }, { hooksJson, pluginJson }, commands.get(item.name) ?? [])
74 }),
75 )
76 return found.filter((e): e is ModEntry => e !== null)
77}
78
79async function loadCatalog($: EngineInterface) {
80 session.loading ??= (async () => {
81 await update($, busy, () => 'reading plugins')
82 try {
83 const run = await $.process.run(['claude', 'plugin', 'list', '--json'], { timeoutMs: CLI_TIMEOUT_MS })
84 if (run.exitCode !== 0) throw new Error(run.stderr.trim() || `claude plugin list exited ${run.exitCode}`)
85 const rows = applicable(parseList(run.stdout), session.cwd)
86 const commands = await commandsByPlugin($)
87 const installed = await Promise.all(
88 rows.map(async row => {
89 const [hooksJson, pluginJson] = await Promise.all([
90 readFile($, `${row.installPath}/hooks/hooks.json`),
91 readFile($, `${row.installPath}/.claude-plugin/plugin.json`),
92 ])
93 const scope = row.scope === 'local' || row.scope === 'project' ? row.scope : 'user'
94 return entry({ id: row.id, scope, enabled: row.enabled, root: row.installPath, version: row.version }, { hooksJson, pluginJson }, row.enabled ? (commands.get(row.id.split('@')[0]!) ?? []) : [])
95 }),
96 )
97 const names = new Set(installed.map(e => e.name))
98 const local = await sessionMods($, commands, names)
99 await update($, entries, () => sortEntries([...installed, ...local]))
100 await update($, loadedAt, () => Date.now())
101 await update($, note, n => (n.startsWith('Could not read') ? '' : n))
102 } catch (error) {
103 await update($, note, () => `Could not read the plugins: ${errorText(error)}`)
104 } finally {
105 await update($, busy, () => '')
106 session.loading = null
107 }
108 })()
109 return session.loading
110}
111
112async function toggle($: EngineInterface, id: string) {
113 const list = await read($, entries)
114 const target = list.find(e => e.id === id)
115 if (!target || target.scope === 'session' || target.name === session.self || (await read($, busy))) return
116 const verb = target.enabled ? 'disable' : 'enable'
117 await update($, busy, () => `${verb === 'enable' ? 'enabling' : 'disabling'} ${target.name}`)
118 try {
119 const run = await $.process.run(['claude', 'plugin', verb, target.id, '--scope', target.scope], { timeoutMs: CLI_TIMEOUT_MS })
120 if (run.exitCode !== 0) throw new Error((run.stderr || run.stdout).trim().split('\n')[0] || `exit ${run.exitCode}`)
121 await update($, entries, l => l.map(e => (e.id === id ? { ...e, enabled: !e.enabled } : e)))
122 await update($, needsReload, () => true)
123 await update($, note, () => `${target.name} ${verb}d (${target.scope}). Reload to apply.`)
124 } catch (error) {
125 await update($, note, () => `Could not ${verb} ${target.name}: ${errorText(error)}`)
126 } finally {
127 await update($, busy, () => '')
128 }
129}
130
131async function reloadPlugins($: EngineInterface) {
132 try {
133 await $.command.run({ command: 'reload-plugins' })
134 await update($, needsReload, () => false)
135 await update($, note, () => '')
136 await loadCatalog($)
137 } catch (error) {
138 await update($, note, () => `Type /reload-plugins to apply (${errorText(error)})`)
139 }
140}
141
142// Runs a plugin's command, as if typed: a mod's panel opens this way.
143async function runCommand($: EngineInterface, name: string) {
144 try {
145 const result = await $.command.run({ command: name })
146 await update($, note, () => (result.text ? `/${name}: ${result.text.split('\n')[0]}` : ''))
147 } catch (error) {
148 await update($, note, () => `/${name}: ${errorText(error)}`)
149 }
150}
151
152async function pick($: EngineInterface, id: string) {
153 await update($, selected, () => id)
154 await update($, scroll, s => ({ ...s, detail: 0, detailFor: id }))
155}
156
157async function step($: EngineInterface, by: number) {
158 const order = drawn.order
159 if (order.length === 0) return
160 const at = drawn.shown ? order.indexOf(drawn.shown) : -1
161 await pick($, order[clamp(at + by, 0, order.length - 1)]!)
162}
163
164async function scrollBy($: EngineInterface, region: 'list' | 'detail', by: number) {
165 const at = drawn.layout
166 if (!at) return
167 if (region === 'list') {
168 const max = Math.max(0, drawn.listTotal - at.listRows)
169 await update($, scroll, s => ({ ...s, list: clamp(s.list + by, 0, max) }))
170 return
171 }
172 const max = Math.max(0, drawn.readmeTotal - drawn.readmeRows)
173 const shown = drawn.shown
174 await update($, scroll, s => ({ ...s, detail: clamp((s.detailFor === shown ? s.detail : 0) + by, 0, max), detailFor: shown }))
175}
176
177async function act($: EngineInterface, action: string) {
178 const page = Math.max(1, drawn.readmeRows - 2)
179 if (action === 'filter') return update($, filter, f => (f === 'mods' ? 'all' : 'mods'))
180 if (action === 'refresh') return loadCatalog($)
181 if (action === 'toggle') return drawn.shown ? toggle($, drawn.shown) : undefined
182 // A command runs once the session is idle: not awaited, so a running turn does not hold this hook.
183 if (action === 'reload') return void reloadPlugins($)
184 if (action === 'up') return scrollBy($, 'detail', -page)
185 if (action === 'down') return scrollBy($, 'detail', page)
186 if (action.startsWith('run:')) return void runCommand($, action.slice(4))
187 return undefined
188}
189
190// A key a surface module passed on: in the list the arrows walk it, space switches the picked
191// plugin and Enter runs its first command; elsewhere the arrows scroll the README.
192async function pressKey($: EngineInterface, element: string, key: string) {
193 const inList = element === 'list' || element === 'lbar'
194 if (inList && (key === 'up' || key === 'k')) return step($, -1)
195 if (inList && (key === 'down' || key === 'j')) return step($, 1)
196 if (key === ' ' || key === 'space') return act($, 'toggle')
197 if (key === 'return') {
198 const shown = (await read($, entries)).find(e => e.id === drawn.shown)
199 return shown?.commands[0] ? void runCommand($, shown.commands[0].name) : undefined
200 }
201 if (key === 'up' || key === 'k') return scrollBy($, 'detail', -1)
202 if (key === 'down' || key === 'j') return scrollBy($, 'detail', 1)
203 if (key === 'pageup') return act($, 'up')
204 if (key === 'pagedown') return act($, 'down')
205 if (key === 'r') return loadCatalog($)
206 return undefined
207}
208
209function readmeOf(root: string, width: number): MdRow[] {
210 const text = readmes.get(root)
211 if (!text) return []
212 const kept = mdCache.get(root)
213 if (kept && kept.width === width) return kept.rows
214 const rows = mdRows(text, width)
215 mdCache.set(root, { width, rows })
216 return rows
217}
218
219const isOpen = async ($: EngineInterface) => (await $.ui.panes().catch(() => [])).some(p => p.id === PANE)
220
221// /mods toggles the pane; on and off say which; refresh reads the installed plugins again.
222async function command($: EngineInterface, args: string) {
223 const arg = args.trim().toLowerCase()
224 if (arg === 'off' || arg === 'close' || (arg === '' && (await isOpen($)))) {
225 await $.ui.close({ id: PANE }).catch(() => undefined)
226 return { text: `Closed. /${session.command} opens it again.` }
227 }
228 if (arg === 'refresh') {
229 await loadCatalog($)
230 return { text: `${(await read($, entries)).length} plugins read.` }
231 }
232 const opened = await $.ui.open({ id: PANE, title: TITLE, columns: DOCK_COLUMNS, focus: true })
233 void loadCatalog($)
234 if (!opened.isPlaced) return { text: `The pane could not be shown (${opened.reason}).` }
235 return { text: 'Click a mod to read about it, its switch to turn it on or off, a command to open its panel.' }
236}
237
238export const register: Register = on => {
239 on('session.start', async ($, e, next) => {
240 const r = await next(e)
241 session.cwd = e.cwd
242 session.self = $.plugin.name
243 for (const name of ['mods', 'mod-manager']) {
244 const ok = await $.command
245 .register({ name, description: 'Browse your installed mods, switch them on or off, open their panels', argumentHint: '[on | off | refresh]' })
246 .then(() => true)
247 .catch(() => false)
248 if (ok) {
249 session.command = name
250 break
251 }
252 }
253 return r
254 })
255
256 on('command.run', { command: 'mods' }, async ($, e) => command($, e.args))
257 on('command.run', { command: 'mod-manager' }, async ($, e) => command($, e.args))
258
259 // What the pane's surface modules post: a picked row, a switch, a control's action, a
260 // scrollbar's offset, or a key. Every `$` call here is this hook's own.
261 on('ui.message', { requestId: PANE }, async ($, e) => {
262 const data = (e.data ?? {}) as { pick?: unknown; toggle?: unknown; action?: unknown; press?: unknown; offset?: unknown }
263 if (typeof data.pick === 'string') await pick($, data.pick)
264 else if (typeof data.toggle === 'string') {
265 await pick($, data.toggle)
266 await toggle($, data.toggle)
267 } else if (typeof data.action === 'string') await act($, data.action)
268 else if (typeof data.press === 'string') await pressKey($, e.element, data.press)
269 else if (typeof data.offset === 'number') {
270 const offset = data.offset
271 if (e.element === 'lbar') await update($, scroll, s => ({ ...s, list: clamp(offset, 0, Math.max(0, drawn.listTotal - (drawn.layout?.listRows ?? 0))) }))
272 if (e.element === 'dbar') await update($, scroll, s => ({ ...s, detail: clamp(offset, 0, Math.max(0, drawn.readmeTotal - drawn.readmeRows)), detailFor: drawn.shown }))
273 }
274 return {}
275 })
276
277 // The wheel scrolls the column under it; the keys (no pointer) scroll the README.
278 on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e) => {
279 const at = drawn.layout
280 const overList = at !== null && e.pointer !== undefined && (at.isStacked ? e.pointer.row < at.listRows + 2 : e.pointer.column < at.listWidth)
281 await scrollBy($, overList ? 'list' : 'detail', e.by)
282 return {}
283 })
284
285 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
286 const els = $.ui.resolve(e)
287 const at = layout(e.props.bodyColumns, e.props.scroll.bodyRows)
288 const all = await read($, entries)
289 const mode = await read($, filter)
290 const shown = mode === 'mods' ? all.filter(x => x.isMod) : all
291 const offsets = await read($, scroll)
292 const picked = await read($, selected)
293 const target = shown.find(x => x.id === picked) ?? shown[0] ?? null
294
295 // The list: a heading per marketplace, a row per plugin cut to fit before its switch.
296 const inner = at.listWidth - 1
297 const rows: ListRow[] = []
298 let group = ''
299 for (const x of shown) {
300 const label = x.scope === 'session' ? 'this session' : x.marketplace || 'local'
301 if (label !== group) {
302 rows.push({ kind: 'group', label })
303 group = label
304 }
305 const tag = mode === 'all' && !x.isMod ? 'plugin' : ''
306 const version = x.version ? truncate(x.version, 10) : ''
307 const room = inner - (1 + 3 + 1 + textWidth(version) + (tag ? tag.length + 1 : 0) + SWITCH) - 1
308 rows.push({
309 kind: 'mod',
310 id: x.id,
311 name: truncate(x.name, Math.max(4, room)),
312 version,
313 isOn: x.enabled,
314 lock: x.scope === 'session' ? 'dev' : x.name === session.self ? 'self' : '',
315 tag,
316 isSelected: x.id === target?.id,
317 })
318 }
319 const listOffset = clamp(offsets.list, 0, Math.max(0, rows.length - at.listRows))
320
321 // The detail: the README read once per plugin, wrapped to the column.
322 let detail = null
323 if (target) {
324 if (!readmes.has(target.root)) readmes.set(target.root, await readFile($, `${target.root}/README.md`))
325 const shell = { entry: target, isSelf: target.name === session.self, readme: [] as MdRow[] }
326 const head = detailHead(shell, at.detailWidth)
327 drawn.readmeRows = Math.max(0, at.detailHeight - head.rows - 1)
328 detail = { ...shell, readme: readmeOf(target.root, Math.max(8, at.detailWidth - 1 - 3)) }
329 }
330 const readmeTotal = detail?.readme.length ?? 0
331 const detailOffset = target && offsets.detailFor === target.id ? clamp(offsets.detail, 0, Math.max(0, readmeTotal - drawn.readmeRows)) : 0
332
333 drawn.layout = at
334 drawn.listTotal = rows.length
335 drawn.readmeTotal = readmeTotal
336 drawn.order = shown.map(x => x.id)
337 drawn.shown = target?.id ?? null
338
339 return (
340 <Pane
341 m={{
342 els,
343 at,
344 filter: mode,
345 counts: { shown: shown.length, on: shown.filter(x => x.enabled).length },
346 list: rows.slice(listOffset, listOffset + at.listRows),
347 listTotal: rows.length,
348 listOffset,
349 detail,
350 detailOffset,
351 busy: await read($, busy),
352 note: await read($, note),
353 needsReload: await read($, needsReload),
354 isLoaded: (await read($, loadedAt)) > 0,
355 }}
356 />
357 )
358 })
359}
360hooks/catalog.ts 91 lines1// The installed plugins as `claude plugin list --json` reports them, narrowed to the ones that
2// apply in this project: user-scope installs and the project's own, one row per plugin, the
3// narrowest scope winning (local over project over user), as Claude Code itself resolves them.
4import type { ModCommand, ModEntry, ModScope } from '../types'
5
6export type Installed = {
7 id: string
8 version?: string
9 scope: string
10 enabled: boolean
11 installPath: string
12 projectPath?: string
13}
14
15const RANK: Record<string, number> = { local: 3, project: 2, user: 1 }
16
17export function parseList(json: string): Installed[] {
18 const data: unknown = JSON.parse(json)
19 if (!Array.isArray(data)) return []
20 return data.filter(
21 (row): row is Installed =>
22 typeof row === 'object' && row !== null && typeof (row as Installed).id === 'string' && typeof (row as Installed).installPath === 'string',
23 )
24}
25
26export function applicable(list: Installed[], cwd: string): Installed[] {
27 const here = list.filter(row => row.scope === 'user' || (row.projectPath !== undefined && row.projectPath === cwd))
28 const best = new Map<string, Installed>()
29 for (const row of here) {
30 const kept = best.get(row.id)
31 if (!kept || (RANK[row.scope] ?? 0) > (RANK[kept.scope] ?? 0)) best.set(row.id, row)
32 }
33 return [...best.values()]
34}
35
36export function splitId(id: string) {
37 const at = id.lastIndexOf('@')
38 return at > 0 ? { name: id.slice(0, at), marketplace: id.slice(at + 1) } : { name: id, marketplace: '' }
39}
40
41// A hooks.json naming at least one hooks module: the plugin is a mod.
42export function namesModules(hooksJson: string | null): boolean {
43 if (!hooksJson) return false
44 try {
45 const data = JSON.parse(hooksJson) as { modules?: unknown }
46 return Array.isArray(data.modules) && data.modules.length > 0
47 } catch {
48 return false
49 }
50}
51
52export function describe(pluginJson: string | null): { description: string; version: string } {
53 if (!pluginJson) return { description: '', version: '' }
54 try {
55 const data = JSON.parse(pluginJson) as { description?: unknown; version?: unknown }
56 return {
57 description: typeof data.description === 'string' ? data.description : '',
58 version: typeof data.version === 'string' ? data.version : '',
59 }
60 } catch {
61 return { description: '', version: '' }
62 }
63}
64
65export function entry(
66 row: { id: string; scope: ModScope; enabled: boolean; root: string; version?: string },
67 files: { hooksJson: string | null; pluginJson: string | null },
68 commands: ModCommand[],
69): ModEntry {
70 const { name, marketplace } = splitId(row.id)
71 const meta = describe(files.pluginJson)
72 return {
73 id: row.id,
74 name,
75 marketplace,
76 version: row.version || meta.version,
77 scope: row.scope,
78 enabled: row.enabled,
79 isMod: namesModules(files.hooksJson),
80 description: meta.description,
81 root: row.root,
82 commands,
83 }
84}
85
86// Mods first, then by marketplace and name; this session's own mods last.
87export function sortEntries(list: ModEntry[]): ModEntry[] {
88 const group = (e: ModEntry) => (e.scope === 'session' ? 'zzzz' : e.marketplace)
89 return [...list].sort((a, b) => group(a).localeCompare(group(b)) || a.name.localeCompare(b.name))
90}
91hooks/list-client.tsx 123 lines1// The installed plugins, drawn by a surface module: the row under the pointer takes a quiet
2// background, a click on a row posts it as picked, a click on its switch posts a toggle.
3import type { ClientModule } from 'claude-code'
4
5export type ListRow =
6 | { kind: 'group'; label: string }
7 | {
8 kind: 'mod'
9 id: string
10 name: string // already cut to fit
11 version: string
12 isOn: boolean
13 lock: '' | 'dev' | 'self' // a session mod or this manager: no switch
14 tag: string // 'plugin' for a plugin with no hooks module, shown under the All filter
15 isSelected: boolean
16 }
17
18export type ListProps = {
19 width: number
20 rows: ListRow[]
21 colors: {
22 bg: string
23 hover: string
24 pressed: string
25 selected: string
26 accent: string
27 fg: string
28 muted: string
29 subtle: string
30 on: string
31 onBg: string
32 offBg: string
33 }
34}
35
36export type ListMessage = { pick: string } | { toggle: string } | { press: string }
37
38export const SWITCH = 6 // the switch's cells at the row's end, its trailing space included
39
40type State = { hover: number | null; down: number | null }
41
42const cells = (s: string) =>
43 [...s].reduce((n, ch) => {
44 const cp = ch.codePointAt(0) ?? 0
45 const isWide = (cp >= 0x1100 && cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0xa4cf) || (cp >= 0xac00 && cp <= 0xd7a3) || (cp >= 0xff00 && cp <= 0xff60)
46 return n + (isWide ? 2 : 1)
47 }, 0)
48
49const ModList: ClientModule<ListProps, State> = (props, surface) => {
50 const { Box, Text } = surface.elements
51 const state = surface.state ?? { hover: null, down: null }
52 const c = props.colors
53 const modAt = (x: number, y: number) => {
54 const row = y >= 0 && x >= 0 && x < props.width ? props.rows[y] : undefined
55 return row?.kind === 'mod' ? y : null
56 }
57 const set = (next: State) => {
58 if (next.hover !== state.hover || next.down !== state.down) surface.setState(next)
59 }
60
61 surface.onPointer(event => {
62 const at = modAt(event.x, event.y)
63 if (event.type === 'leave') set({ hover: null, down: null })
64 else if (event.type === 'move' || event.type === 'enter') set({ ...state, hover: at })
65 else if (event.type === 'down' && event.button === 'left') set({ hover: at, down: at })
66 else if (event.type === 'up' && event.button === 'left') {
67 const row = at !== null && at === state.down ? props.rows[at] : undefined
68 set({ hover: at, down: null })
69 if (row?.kind !== 'mod') return
70 const onSwitch = event.x >= props.width - SWITCH
71 surface.post(onSwitch && !row.lock ? ({ toggle: row.id } satisfies ListMessage) : ({ pick: row.id } satisfies ListMessage))
72 }
73 })
74 surface.onKey(event => {
75 if (!event.ctrl && !event.meta) surface.post({ press: event.key } satisfies ListMessage)
76 })
77
78 return (
79 <Box flexDirection="column" width={props.width}>
80 {props.rows.map((row, i) => {
81 if (row.kind === 'group') {
82 const head = ` ${row.label} `
83 return (
84 <Text backgroundColor={c.bg} wrap="truncate-end">
85 <Text color={c.subtle}>{'──'}</Text>
86 <Text color={c.muted} weight="bold">
87 {head}
88 </Text>
89 <Text color={c.subtle}>{'─'.repeat(Math.max(0, props.width - 2 - cells(head)))}</Text>
90 </Text>
91 )
92 }
93 const bg = row.isSelected ? c.selected : state.down === i ? c.pressed : state.hover === i ? c.hover : c.bg
94 const tag = row.tag ? ` ${row.tag}` : ''
95 const used = 1 + 3 + cells(row.name) + 1 + cells(row.version) + cells(tag) + SWITCH
96 const sw = row.lock
97 ? { text: row.lock === 'dev' ? ' dev ' : ' this', color: c.subtle, bg }
98 : row.isOn
99 ? { text: ' ON ', color: c.on, bg: c.onBg }
100 : { text: ' OFF ', color: c.muted, bg: c.offBg }
101 return (
102 <Text backgroundColor={bg} wrap="truncate-end">
103 <Text color={c.accent}>{row.isSelected ? '▎' : ' '}</Text>
104 <Text color={row.isOn ? c.on : c.subtle}>{row.isOn ? ' ● ' : ' ○ '}</Text>
105 <Text color={row.isOn ? c.fg : c.muted} weight={row.isSelected ? 'bold' : undefined}>
106 {row.name}
107 </Text>
108 <Text color={c.subtle}>{` ${row.version}`}</Text>
109 <Text>{' '.repeat(Math.max(1, props.width - used))}</Text>
110 <Text color={c.subtle}>{tag}</Text>
111 <Text color={sw.color} backgroundColor={sw.bg} weight="bold">
112 {sw.text}
113 </Text>
114 <Text> </Text>
115 </Text>
116 )
117 })}
118 </Box>
119 )
120}
121
122export default ModList
123hooks/markdown.ts 64 lines1// A README as plain rows the detail column can window and scroll: headings, bullets, quotes and
2// code kept apart by style, inline markup reduced to its text, HTML tags and images dropped.
3import { wrapText } from './text'
4
5export type MdStyle = 'h1' | 'h2' | 'h3' | 'text' | 'bullet' | 'quote' | 'code' | 'rule' | 'blank'
6export type MdRow = { style: MdStyle; text: string }
7
8export const inline = (s: string) =>
9 s
10 .replace(/<[^>]+>/g, '')
11 .replace(/!\[[^\]]*\]\([^)]*\)/g, '')
12 .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
13 .replace(/\*\*([^*]+)\*\*/g, '$1')
14 .replace(/__([^_]+)__/g, '$1')
15 .replace(/`([^`]+)`/g, '$1')
16 .trimEnd()
17
18export function mdRows(markdown: string, width: number): MdRow[] {
19 const rows: MdRow[] = []
20 let inCode = false
21 const push = (style: MdStyle, text: string, indent = '') => {
22 const room = Math.max(8, width - indent.length)
23 wrapText(text, room).forEach((part, i) => rows.push({ style, text: (i === 0 ? indent : ' '.repeat(indent.length)) + part }))
24 }
25 for (const raw of markdown.split('\n')) {
26 if (/^\s*```/.test(raw)) {
27 inCode = !inCode
28 continue
29 }
30 if (inCode) {
31 rows.push({ style: 'code', text: raw.replace(/\t/g, ' ') })
32 continue
33 }
34 const line = inline(raw)
35 const heading = raw.match(/^(#{1,6})\s+(.*)$/)
36 if (heading) {
37 if (rows.length > 0 && rows[rows.length - 1]!.style !== 'blank') rows.push({ style: 'blank', text: '' })
38 const level = heading[1]!.length
39 push(level === 1 ? 'h1' : level === 2 ? 'h2' : 'h3', inline(heading[2]!))
40 continue
41 }
42 if (/^\s*([-*_])\s*\1\s*\1[\s\-*_]*$/.test(raw)) {
43 rows.push({ style: 'rule', text: '' })
44 continue
45 }
46 const bullet = raw.match(/^(\s*)[-*+]\s+(.*)$/) ?? raw.match(/^(\s*)\d+[.)]\s+(.*)$/)
47 if (bullet) {
48 push('bullet', inline(bullet[2]!), `${' '.repeat(bullet[1]!.length)}• `)
49 continue
50 }
51 if (/^\s*>/.test(raw)) {
52 push('quote', inline(raw.replace(/^\s*>\s?/, '')), '│ ')
53 continue
54 }
55 if (line.trim() === '') {
56 if (rows.length > 0 && rows[rows.length - 1]!.style !== 'blank') rows.push({ style: 'blank', text: '' })
57 continue
58 }
59 push('text', line)
60 }
61 while (rows.length > 0 && rows[rows.length - 1]!.style === 'blank') rows.pop()
62 return rows
63}
64hooks/text.ts 175 lines1// Terminal cell widths: Hangul, CJK and most emoji take two cells, combining marks none.
2// Rows are padded to the column's width so their background fills it, so widths must be right.
3
4const WIDE: [number, number][] = [
5 [0x1100, 0x115f], // Hangul Jamo
6 [0x2e80, 0x303e], // CJK radicals, punctuation
7 [0x3041, 0x33ff], // kana, Hangul compatibility jamo, CJK symbols
8 [0x3400, 0x4dbf], // CJK extension A
9 [0x4e00, 0x9fff], // CJK unified
10 [0xa000, 0xa4cf], // Yi
11 [0xa960, 0xa97f], // Hangul Jamo extended A
12 [0xac00, 0xd7a3], // Hangul syllables
13 [0xf900, 0xfaff], // CJK compatibility
14 [0xfe30, 0xfe4f], // CJK compatibility forms
15 [0xff00, 0xff60], // fullwidth forms
16 [0xffe0, 0xffe6],
17 [0x1f300, 0x1f64f], // emoji
18 [0x1f900, 0x1f9ff],
19 [0x1fa70, 0x1faff],
20 [0x20000, 0x3fffd], // CJK extensions B and later
21]
22
23const ZERO: [number, number][] = [
24 [0x0300, 0x036f], // combining diacritics
25 [0x200b, 0x200f], // zero-width space, joiners, marks
26 [0xfe00, 0xfe0f], // variation selectors
27 [0x1160, 0x11ff], // Hangul medial and final jamo
28]
29
30const within = (cp: number, ranges: [number, number][]) => ranges.some(([lo, hi]) => cp >= lo && cp <= hi)
31
32export function charWidth(ch: string): number {
33 const cp = ch.codePointAt(0) ?? 0
34 if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0)) return 0
35 if (within(cp, ZERO)) return 0
36 return within(cp, WIDE) ? 2 : 1
37}
38
39export const textWidth = (s: string) => [...s].reduce((n, ch) => n + charWidth(ch), 0)
40
41// Tabs to spaces at `size` stops, and every other control character dropped.
42export function expandTabs(s: string, size = 4): string {
43 let out = ''
44 let col = 0
45 for (const ch of s) {
46 const cp = ch.codePointAt(0) ?? 0
47 if (ch === '\t') {
48 const n = size - (col % size)
49 out += ' '.repeat(n)
50 col += n
51 } else if (cp >= 0x20 && !(cp >= 0x7f && cp < 0xa0)) {
52 out += ch
53 col += charWidth(ch)
54 }
55 }
56 return out
57}
58
59export const pad = (s: string, width: number) => s + ' '.repeat(Math.max(0, width - textWidth(s)))
60
61export const padStart = (s: string, width: number) => ' '.repeat(Math.max(0, width - textWidth(s))) + s
62
63// Cut to `width` cells, an ellipsis standing for what was cut.
64export function truncate(s: string, width: number): string {
65 if (width <= 0) return ''
66 if (textWidth(s) <= width) return s
67 let out = ''
68 let used = 0
69 for (const ch of s) {
70 const w = charWidth(ch)
71 if (used + w > width - 1) break
72 out += ch
73 used += w
74 }
75 return out + '…'
76}
77
78// Cut from the left, keeping the end (a path's file name).
79export function truncateStart(s: string, width: number): string {
80 if (width <= 0) return ''
81 if (textWidth(s) <= width) return s
82 const chars = [...s]
83 let out = ''
84 let used = 0
85 for (let i = chars.length - 1; i >= 0; i--) {
86 const w = charWidth(chars[i]!)
87 if (used + w > width - 1) break
88 out = chars[i] + out
89 used += w
90 }
91 return '…' + out
92}
93
94// A run of text, marked when it is the changed part of a line.
95export type Seg = { text: string; isMarked: boolean }
96
97// Lays segments out in rows of at most `width` cells, splitting a segment where a row ends.
98export function wrapSegs(segs: Seg[], width: number): Seg[][] {
99 const rows: Seg[][] = [[]]
100 let used = 0
101 for (const seg of segs) {
102 let run = ''
103 for (const ch of seg.text) {
104 const w = charWidth(ch)
105 if (used + w > width && used > 0) {
106 if (run) rows[rows.length - 1]!.push({ text: run, isMarked: seg.isMarked })
107 rows.push([])
108 run = ''
109 used = 0
110 }
111 run += ch
112 used += w
113 }
114 if (run) rows[rows.length - 1]!.push({ text: run, isMarked: seg.isMarked })
115 }
116 return rows
117}
118
119export const segsWidth = (segs: Seg[]) => segs.reduce((n, s) => n + textWidth(s.text), 0)
120
121// The cells [from, from + width) of a row of segments: what a window scrolled sideways shows.
122// A wide character cut by either edge becomes a space, so the columns stay where they were.
123export function sliceSegs(segs: Seg[], from: number, width: number): Seg[] {
124 const out: Seg[] = []
125 let col = 0
126 const end = from + width
127 for (const seg of segs) {
128 let text = ''
129 for (const ch of seg.text) {
130 const w = charWidth(ch)
131 const next = col + w
132 if (col >= from && next <= end) text += ch
133 else if (next > from && col < end) text += ' '.repeat(Math.min(next, end) - Math.max(col, from))
134 col = next
135 if (col >= end) break
136 }
137 if (text) out.push({ text, isMarked: seg.isMarked })
138 if (col >= end) break
139 }
140 return out
141}
142
143// A line with its first `n` cells dropped.
144export const dropCells = (s: string, n: number) =>
145 n <= 0 ? s : sliceSegs([{ text: s, isMarked: false }], n, Number.MAX_SAFE_INTEGER).map(seg => seg.text).join('')
146
147// A paragraph laid out in rows of at most `width` cells: broken after a space where one falls in
148// the row's last third, else wherever the row is full (Hangul and CJK need no space to break).
149export function wrapText(line: string, width: number): string[] {
150 const text = expandTabs(line)
151 if (width <= 0) return [text]
152 const rows: string[] = []
153 let row = ''
154 let used = 0
155 for (const ch of text) {
156 const w = charWidth(ch)
157 if (used + w > width && used > 0) {
158 const cut = row.lastIndexOf(' ')
159 if (cut > 0 && textWidth(row.slice(0, cut)) >= (width * 2) / 3) {
160 rows.push(row.slice(0, cut))
161 row = row.slice(cut + 1)
162 used = textWidth(row)
163 } else {
164 rows.push(row)
165 row = ''
166 used = 0
167 }
168 }
169 row += ch
170 used += w
171 }
172 rows.push(row)
173 return rows
174}
175hooks/view.tsx 356 lines1// What the pane draws: a toolbar, the installed plugins on the left, and the picked one on the
2// right: its name, scope and switch, its commands as buttons that open its panels, and its README.
3import type { Elements } from 'claude-code'
4
5import type { Filter, ModEntry } from '../types'
6import { spans } from './controls'
7import type { ControlsProps, Item, Part } from './controls'
8import type { ListProps, ListRow } from './list-client'
9import type { MdRow } from './markdown'
10import { thumbOf } from './scrollbar'
11import type { BarProps } from './scrollbar'
12import { pad, textWidth, truncate, wrapText } from './text'
13import { C } from './theme'
14
15export type Els = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'> & {
16 Client?: Elements['terminal']['Client']
17}
18
19export type Layout = {
20 width: number
21 height: number
22 isStacked: boolean
23 listWidth: number // the list's columns, its scrollbar included
24 listRows: number
25 detailWidth: number // the detail's columns, its scrollbar included
26 detailHeight: number // all the detail's rows
27}
28
29const TOOLBAR_ROWS = 2
30const SIDE_BY_SIDE = 72
31
32export function layout(width: number, height: number): Layout {
33 const body = Math.max(6, height - TOOLBAR_ROWS)
34 if (width < SIDE_BY_SIDE) {
35 const listRows = Math.max(4, Math.floor(body * 0.4))
36 return { width, height, isStacked: true, listWidth: width, listRows, detailWidth: width, detailHeight: body - listRows - 1 }
37 }
38 const listWidth = Math.max(32, Math.min(52, Math.round(width * 0.4)))
39 return { width, height, isStacked: false, listWidth, listRows: body, detailWidth: width - listWidth - 1, detailHeight: body }
40}
41
42export type Detail = {
43 entry: ModEntry
44 isSelf: boolean
45 readme: MdRow[] // wrapped to the detail's text width
46}
47
48export type PaneModel = {
49 els: Els
50 at: Layout
51 filter: Filter
52 counts: { shown: number; on: number }
53 list: ListRow[] // the rows in the list's window
54 listTotal: number
55 listOffset: number
56 detail: Detail | null
57 detailOffset: number
58 busy: string
59 note: string
60 needsReload: boolean
61 isLoaded: boolean
62}
63
64// The detail's fixed rows above the README, for the README's window to start under.
65export function detailHead(d: Detail, width: number) {
66 const text = Math.max(8, width - 3)
67 const description = d.entry.description ? wrapText(d.entry.description, text).slice(0, 4) : []
68 const commandRows = commandLines(d.entry, width).length
69 // header + rule + meta + description + blank + commands title + commands + rule
70 return { description, rows: 2 + 1 + description.length + 1 + 1 + Math.max(1, commandRows) + 1 }
71}
72
73// The commands as controls, laid in as many rows as the width needs.
74export function commandLines(entry: ModEntry, width: number): Item[][] {
75 const lines: Item[][] = [[]]
76 let used = 1
77 for (const command of entry.commands) {
78 const label = ` /${command.name} `
79 const w = textWidth(label) + 1
80 if (used + w > width - 1 && lines[lines.length - 1]!.length > 0) {
81 lines.push([])
82 used = 1
83 }
84 lines[lines.length - 1]!.push({ action: `run:${command.name}`, parts: [{ text: label, color: C.accent, bg: '#121d2f' }] }, { parts: [{ text: ' ' }] })
85 used += w
86 }
87 return lines[0]!.length === 0 ? [] : lines
88}
89
90// ---- building blocks --------------------------------------------------------------------------
91
92const text = (parts: Part[]): Item => ({ parts })
93const spacer: Item = { spacer: true }
94
95function ChromeRow({ m, id, w, bg, items }: { m: PaneModel; id: string; w: number; bg: string; items: Item[] }) {
96 const { Box, Text, Client } = m.els
97 const props: ControlsProps = {
98 width: w,
99 bg,
100 items: [{ parts: [{ text: ' ' }] }, ...items],
101 colors: { hover: C.controlHover, pressed: C.controlPressed, on: C.accent, fg: C.fg },
102 }
103 if (Client) return <Client key={id} module="./controls.tsx" props={props} width={w} height={1} />
104 const layout = spans(props.items, w)
105 return (
106 <Box flexDirection="row" width={w} height={1} overflow="hidden" backgroundColor={bg}>
107 {props.items.map((item, i) => {
108 const [a, b] = layout[i]!
109 if ('spacer' in item) return <Text backgroundColor={bg}>{' '.repeat(b - a)}</Text>
110 return (
111 <Text backgroundColor={bg}>
112 {item.parts.map(part => (
113 <Text color={part.color ?? C.fg} backgroundColor={part.bg ?? bg}>
114 {part.text}
115 </Text>
116 ))}
117 </Text>
118 )
119 })}
120 </Box>
121 )
122}
123
124function Bar({ m, id, length, total, view, offset, bg }: { m: PaneModel; id: string; length: number; total: number; view: number; offset: number; bg: string }) {
125 const { Box, Text, Client } = m.els
126 const props: BarProps = { axis: 'v', length, total, view, offset, colors: { track: C.track, thumb: C.thumb, active: C.thumbActive, bg } }
127 if (Client) return <Client key={id} module="./scrollbar.tsx" props={props} width={1} height={length} />
128 const { start, size, max } = thumbOf(props)
129 return (
130 <Box flexDirection="column" width={1}>
131 {Array.from({ length }, (_, i) => {
132 const isThumb = max > 0 && i >= start && i < start + size
133 return (
134 <Text color={isThumb ? C.thumb : C.track} backgroundColor={bg}>
135 {isThumb ? '┃' : '│'}
136 </Text>
137 )
138 })}
139 </Box>
140 )
141}
142
143// ---- the parts --------------------------------------------------------------------------------
144
145function Toolbar({ m }: { m: PaneModel }) {
146 const { Box, Text } = m.els
147 const segment = (label: string, isOn: boolean): Part =>
148 isOn ? { text: ` ${label} `, color: C.fg, bg: C.segment, bold: true } : { text: ` ${label} `, color: C.muted }
149 const what = m.filter === 'mods' ? 'mods' : 'plugins'
150 const items: Item[] = [
151 text([
152 { text: '❖ ', color: C.accent, bold: true },
153 { text: 'MODS', color: C.fg, bold: true },
154 { text: m.isLoaded ? ` ${m.counts.shown} ${what} · ${m.counts.on} on` : ' loading…', color: C.muted },
155 ...(m.busy ? [{ text: ` ⋯ ${m.busy}`, color: C.warn }] : []),
156 ]),
157 spacer,
158 { action: 'filter', parts: [{ text: ' ' }, segment('Mods', m.filter === 'mods'), segment('All', m.filter === 'all'), { text: ' ' }] },
159 text([{ text: ' ' }]),
160 { action: 'refresh', parts: [{ text: ' ⟳ Refresh ' }] },
161 text([{ text: ' ' }]),
162 ]
163 return (
164 <Box flexDirection="column" width={m.at.width}>
165 <ChromeRow m={m} id="toolbar" w={m.at.width} bg={C.header} items={items} />
166 <Text color={C.border} backgroundColor={C.canvas}>
167 {'─'.repeat(m.at.width)}
168 </Text>
169 </Box>
170 )
171}
172
173function ListColumn({ m }: { m: PaneModel }) {
174 const { Box, Text, Client } = m.els
175 const inner = m.at.listWidth - 1
176 const props: ListProps = {
177 width: inner,
178 rows: m.list,
179 colors: { bg: C.sidebar, hover: C.hover, pressed: C.pressed, selected: C.selected, accent: C.accent, fg: C.fg, muted: C.muted, subtle: C.subtle, on: C.on, onBg: C.onBg, offBg: C.offBg },
180 }
181 const empty = m.isLoaded ? (m.filter === 'mods' ? 'No mods installed. All shows every plugin.' : 'No plugins installed.') : 'Reading the installed plugins…'
182 const body =
183 m.listTotal === 0 ? (
184 <Box flexDirection="column" width={inner}>
185 <Text backgroundColor={C.sidebar}>{' '.repeat(inner)}</Text>
186 <Text backgroundColor={C.sidebar} color={C.muted}>
187 {pad(` ${truncate(empty, inner - 3)}`, inner)}
188 </Text>
189 </Box>
190 ) : Client ? (
191 <Client key="list" module="./list-client.tsx" props={props} width={inner} height={m.at.listRows} />
192 ) : (
193 <Box flexDirection="column" width={inner}>
194 {m.list.map(row =>
195 row.kind === 'group' ? (
196 <Text backgroundColor={C.sidebar} color={C.muted}>
197 {pad(` ${row.label}`, inner)}
198 </Text>
199 ) : (
200 <Text backgroundColor={row.isSelected ? C.selected : C.sidebar} color={row.isOn ? C.fg : C.muted}>
201 {pad(` ${row.isOn ? '●' : '○'} ${row.name} ${row.version}`, inner - 5)}
202 {row.isOn ? ' ON ' : ' OFF '}
203 </Text>
204 ),
205 )}
206 </Box>
207 )
208 return (
209 <Box flexDirection="row" width={m.at.listWidth} height={m.at.listRows} overflow="hidden" backgroundColor={C.sidebar}>
210 <Box flexDirection="column" width={inner} height={m.at.listRows} backgroundColor={C.sidebar}>
211 {body}
212 </Box>
213 <Bar m={m} id="lbar" length={m.at.listRows} total={m.listTotal} view={m.at.listRows} offset={m.listOffset} bg={C.sidebar} />
214 </Box>
215 )
216}
217
218const MD_COLOR: Record<MdRow['style'], { color: string; bg?: string; bold?: boolean }> = {
219 h1: { color: C.heading, bold: true },
220 h2: { color: C.heading, bold: true },
221 h3: { color: C.text, bold: true },
222 text: { color: C.text },
223 bullet: { color: C.text },
224 quote: { color: C.muted },
225 code: { color: C.code, bg: C.codeBg },
226 rule: { color: C.border },
227 blank: { color: C.text },
228}
229
230function DetailColumn({ m }: { m: PaneModel }) {
231 const { Box, Text } = m.els
232 const w = m.at.detailWidth
233 const inner = w - 1
234 if (!m.detail) {
235 return (
236 <Box flexDirection="column" width={w} height={m.at.detailHeight} backgroundColor={C.canvas}>
237 <Text backgroundColor={C.canvas}>{' '.repeat(w)}</Text>
238 <Text backgroundColor={C.canvas} color={C.muted}>
239 {pad(' Pick a plugin on the left.', w)}
240 </Text>
241 </Box>
242 )
243 }
244 const { entry, isSelf } = m.detail
245 const head = detailHead(m.detail, w)
246 const scope = entry.scope === 'session' ? 'this session (dev folder)' : `${entry.scope} scope`
247 const kind = entry.isMod ? 'mod · hooks module' : 'plugin · no hooks module'
248 const switchItem: Item = isSelf || entry.scope === 'session'
249 ? text([{ text: isSelf ? ' always on ' : ' on for this session ', color: C.subtle }])
250 : { action: 'toggle', parts: [entry.enabled ? { text: ' ● Enabled ', color: C.on, bg: C.onBg, bold: true } : { text: ' ○ Disabled ', color: C.muted, bg: C.offBg, bold: true }] }
251 const headItems: Item[] = [
252 text([
253 { text: entry.name, color: C.fg, bold: true },
254 { text: ` v${entry.version || '?'}`, color: C.muted },
255 ...(entry.marketplace ? [{ text: ` @${entry.marketplace}`, color: C.subtle }] : []),
256 ]),
257 spacer,
258 switchItem,
259 text([{ text: ' ' }]),
260 ]
261 const commands = commandLines(entry, w)
262 const readmeRows = m.at.detailHeight - head.rows - 1
263 const readme = m.detail.readme.slice(m.detailOffset, m.detailOffset + Math.max(0, readmeRows))
264 const footItems: Item[] = m.note
265 ? [text([{ text: truncate(m.note, Math.max(4, w - (m.needsReload ? 16 : 4))), color: C.warn }]), spacer, ...(m.needsReload ? [{ action: 'reload', parts: [{ text: ' ⟳ Reload ', color: C.fg }] } as Item] : [])]
266 : [
267 text([{ text: m.detail.readme.length > 0 ? `README ${Math.min(m.detail.readme.length, m.detailOffset + 1)}–${Math.min(m.detail.readme.length, m.detailOffset + Math.max(0, readmeRows))}/${m.detail.readme.length}` : 'No README', color: C.muted }]),
268 spacer,
269 ...(m.needsReload ? [{ action: 'reload', parts: [{ text: ' ⟳ Reload to apply ', color: C.warn }] } as Item, text([{ text: ' ' }])] : []),
270 { action: 'up', parts: [{ text: ' ▲ ' }] },
271 { action: 'down', parts: [{ text: ' ▼ ' }] },
272 ]
273 return (
274 <Box flexDirection="column" width={w} height={m.at.detailHeight} overflow="hidden" backgroundColor={C.canvas}>
275 <ChromeRow m={m} id="dhead" w={w} bg={C.canvas} items={headItems} />
276 <Text color={C.border} backgroundColor={C.canvas}>
277 {'─'.repeat(w)}
278 </Text>
279 <Text backgroundColor={C.canvas} color={C.muted}>
280 {pad(` ${truncate(`${kind} · ${scope}`, w - 3)}`, w)}
281 </Text>
282 {head.description.map(line => (
283 <Text backgroundColor={C.canvas} color={C.text}>
284 {pad(` ${line}`, w)}
285 </Text>
286 ))}
287 <Text backgroundColor={C.canvas}>{' '.repeat(w)}</Text>
288 <Text backgroundColor={C.canvas} color={C.muted} weight="bold">
289 {pad(entry.enabled ? ' PANELS & COMMANDS' : ' PANELS & COMMANDS (enable it to use them)', w)}
290 </Text>
291 {commands.length === 0 ? (
292 <Text backgroundColor={C.canvas} color={C.subtle}>
293 {pad(entry.enabled ? ' No slash commands.' : ' —', w)}
294 </Text>
295 ) : (
296 commands.map((line, i) => <ChromeRow m={m} id={`dcmd${i}`} w={w} bg={C.canvas} items={line} />)
297 )}
298 <Text color={C.border} backgroundColor={C.canvas}>
299 {'─'.repeat(w)}
300 </Text>
301 <Box flexDirection="row" width={w} height={Math.max(0, readmeRows)} backgroundColor={C.canvas}>
302 <Box flexDirection="column" width={inner} height={Math.max(0, readmeRows)} overflow="hidden" backgroundColor={C.canvas}>
303 {readme.map(row => {
304 const look = MD_COLOR[row.style]
305 const body = row.style === 'rule' ? '─'.repeat(Math.max(0, inner - 4)) : row.text
306 return (
307 <Text backgroundColor={C.canvas} wrap="truncate-end">
308 <Text>{' '}</Text>
309 <Text color={look.color} backgroundColor={look.bg ?? C.canvas} weight={look.bold ? 'bold' : undefined}>
310 {row.style === 'code' ? pad(body, inner - 3) : body}
311 </Text>
312 <Text>{' '.repeat(Math.max(0, inner - 2 - textWidth(row.style === 'code' ? pad(body, inner - 3) : body)))}</Text>
313 </Text>
314 )
315 })}
316 </Box>
317 {readmeRows > 0 && (
318 <Bar m={m} id="dbar" length={readmeRows} total={m.detail.readme.length} view={readmeRows} offset={m.detailOffset} bg={C.canvas} />
319 )}
320 </Box>
321 <ChromeRow m={m} id="dfoot" w={w} bg={C.header} items={footItems} />
322 </Box>
323 )
324}
325
326export function Pane({ m }: { m: PaneModel }) {
327 const { Box, Text } = m.els
328 const columns = m.at.isStacked ? (
329 <Box flexDirection="column" width={m.at.width} overflow="hidden" backgroundColor={C.canvas}>
330 <ListColumn m={m} />
331 <Text color={C.border} backgroundColor={C.canvas}>
332 {'─'.repeat(m.at.width)}
333 </Text>
334 <DetailColumn m={m} />
335 </Box>
336 ) : (
337 <Box flexDirection="row" width={m.at.width} overflow="hidden" backgroundColor={C.canvas}>
338 <ListColumn m={m} />
339 <Box flexDirection="column" width={1}>
340 {Array.from({ length: m.at.listRows }, () => (
341 <Text color={C.border} backgroundColor={C.canvas}>
342 │
343 </Text>
344 ))}
345 </Box>
346 <DetailColumn m={m} />
347 </Box>
348 )
349 return (
350 <Box flexDirection="column" width={m.at.width} height={m.at.height} overflow="hidden" backgroundColor={C.canvas}>
351 <Toolbar m={m} />
352 {columns}
353 </Box>
354 )
355}
356hooks/controls.tsx 111 lines1// One row of the pane's chrome (the toolbar, a header, the status row), drawn by a surface module
2// so a control under the pointer takes a quiet background (a Button would invert), a press darkens
3// it, and a click posts its action. Once clicked, the row takes the pane's keys and posts those.
4import type { ClientModule } from 'claude-code'
5
6export type Part = { text: string; color?: string; bg?: string; bold?: boolean; strike?: boolean }
7
8// A run of text, a control (with an `action`), or a spacer that takes the room left over.
9export type Item = { parts: Part[]; action?: string; isOn?: boolean } | { spacer: true }
10
11export type ControlsProps = {
12 width: number
13 bg: string
14 items: Item[]
15 colors: { hover: string; pressed: string; on: string; fg: string }
16}
17
18// A click on a control, or a key pressed while the row holds the focus.
19export type ControlsMessage = { action: string } | { press: string }
20
21type State = { hover: number | null; down: number | null }
22
23export const cellsOf = (s: string) =>
24 [...s].reduce((n, ch) => {
25 const cp = ch.codePointAt(0) ?? 0
26 if (cp < 0x20 || (cp >= 0x300 && cp <= 0x36f) || (cp >= 0x200b && cp <= 0x200f) || (cp >= 0xfe00 && cp <= 0xfe0f)) return n
27 const isWide =
28 (cp >= 0x1100 && cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0xa4cf) || (cp >= 0xac00 && cp <= 0xd7a3) ||
29 (cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0xfe30 && cp <= 0xfe4f) || (cp >= 0xff00 && cp <= 0xff60) ||
30 (cp >= 0x1f300 && cp <= 0x1faff)
31 return n + (isWide ? 2 : 1)
32 }, 0)
33
34const widthOf = (item: Item) => ('spacer' in item ? 0 : item.parts.reduce((n, p) => n + cellsOf(p.text), 0))
35
36// Each item's [start, end) columns, the spacers sharing what the rest leave.
37export function spans(items: Item[], width: number): [number, number][] {
38 const fixed = items.reduce((n, item) => n + widthOf(item), 0)
39 const spacers = items.filter(item => 'spacer' in item).length
40 const spare = Math.max(0, width - fixed)
41 let at = 0
42 let given = 0
43 return items.map(item => {
44 let w = widthOf(item)
45 if ('spacer' in item) {
46 given++
47 w = given === spacers ? spare - Math.floor(spare / spacers) * (spacers - 1) : Math.floor(spare / spacers)
48 }
49 const span: [number, number] = [at, at + w]
50 at += w
51 return span
52 })
53}
54
55const ControlRow: ClientModule<ControlsProps, State> = (props, surface) => {
56 const { Text } = surface.elements
57 const state = surface.state ?? { hover: null, down: null }
58 const layout = spans(props.items, props.width)
59 const hit = (x: number, y: number) => {
60 if (y !== 0) return null
61 const i = layout.findIndex(([a, b]) => x >= a && x < b)
62 const item = props.items[i]
63 return item && !('spacer' in item) && item.action ? i : null
64 }
65 const set = (next: State) => {
66 if (next.hover !== state.hover || next.down !== state.down) surface.setState(next)
67 }
68
69 surface.onPointer(event => {
70 const at = hit(event.x, event.y)
71 if (event.type === 'leave') set({ hover: null, down: null })
72 else if (event.type === 'move' || event.type === 'enter') set({ ...state, hover: at })
73 else if (event.type === 'down' && event.button === 'left') set({ hover: at, down: at })
74 else if (event.type === 'up' && event.button === 'left') {
75 const item = at !== null && at === state.down ? props.items[at] : undefined
76 set({ hover: at, down: null })
77 if (item && !('spacer' in item) && item.action) surface.post({ action: item.action } satisfies ControlsMessage)
78 }
79 })
80 surface.onKey(event => {
81 if (!event.ctrl && !event.meta) surface.post({ press: event.key } satisfies ControlsMessage)
82 })
83
84 return (
85 <Text backgroundColor={props.bg} wrap="truncate-end">
86 {props.items.map((item, i) => {
87 const [a, b] = layout[i]!
88 if ('spacer' in item) return <Text backgroundColor={props.bg}>{' '.repeat(b - a)}</Text>
89 const isActive = item.action !== undefined && (state.down === i || state.hover === i)
90 const lit = state.down === i ? props.colors.pressed : props.colors.hover
91 return (
92 <Text>
93 {item.parts.map(part => (
94 <Text
95 color={part.color ?? (item.isOn ? props.colors.on : props.colors.fg)}
96 backgroundColor={isActive && !part.bg ? lit : (part.bg ?? props.bg)}
97 weight={part.bold ? 'bold' : undefined}
98 strikethrough={part.strike}
99 >
100 {part.text}
101 </Text>
102 ))}
103 </Text>
104 )
105 })}
106 </Text>
107 )
108}
109
110export default ControlRow
111hooks/scrollbar.tsx 102 lines1// A thin scrollbar, vertical or horizontal, drawn by a surface module: the thumb follows a drag,
2// a click on the track jumps there, and once clicked it takes the arrow, page, Home and End keys.
3// It posts where the window should stand; the hooks module owns the offset.
4import type { ClientModule } from 'claude-code'
5
6export type BarProps = {
7 axis: 'v' | 'h'
8 length: number // cells along the bar
9 total: number // rows or columns of content
10 view: number // rows or columns the window shows
11 offset: number // where the window starts
12 colors: { track: string; thumb: string; active: string; bg: string }
13}
14
15// Where the window should stand, or a key the bar does not use, for the pane's keymap.
16export type BarMessage = { offset: number } | { press: string }
17
18type State = { isHot: boolean; grab: number | null } // grab: where on the thumb the drag took it
19
20export function thumbOf(p: Pick<BarProps, 'length' | 'total' | 'view' | 'offset'>) {
21 const max = Math.max(0, p.total - p.view)
22 if (max === 0 || p.length <= 0) return { start: 0, size: p.length, max }
23 const size = Math.max(1, Math.min(p.length, Math.round((p.length * p.view) / p.total)))
24 const start = Math.round(((p.length - size) * Math.min(p.offset, max)) / max)
25 return { start, size, max }
26}
27
28const Scrollbar: ClientModule<BarProps, State> = (props, surface) => {
29 const { Box, Text } = surface.elements
30 const state = surface.state ?? { isHot: false, grab: null }
31 const { start, size, max } = thumbOf(props)
32 const isV = props.axis === 'v'
33 const page = Math.max(1, props.view - 2)
34
35 // The offset whose thumb starts at `cell`, clamped to the content.
36 const offsetAt = (cell: number) => {
37 const room = props.length - size
38 return room <= 0 ? 0 : Math.max(0, Math.min(max, Math.round((cell / room) * max)))
39 }
40 const send = (offset: number) => {
41 const clamped = Math.max(0, Math.min(max, offset))
42 if (clamped !== props.offset) surface.post({ offset: clamped } satisfies BarMessage)
43 }
44
45 surface.onPointer(event => {
46 const at = isV ? event.y : event.x
47 if (event.type === 'enter' && !state.isHot) surface.setState({ ...state, isHot: true })
48 else if (event.type === 'leave' && state.grab === null && state.isHot) surface.setState({ ...state, isHot: false })
49 else if (event.type === 'down' && event.button === 'left' && max > 0) {
50 const onThumb = at >= start && at < start + size
51 const grab = onThumb ? at - start : Math.floor(size / 2)
52 surface.setState({ isHot: true, grab })
53 if (!onThumb) send(offsetAt(at - grab))
54 } else if (event.type === 'move' && state.grab !== null) send(offsetAt(at - state.grab))
55 else if (event.type === 'up' && state.grab !== null) surface.setState({ ...state, grab: null })
56 })
57
58 surface.onKey(event => {
59 const back = isV ? 'up' : 'left'
60 const fwd = isV ? 'down' : 'right'
61 const step = isV ? 1 : 4
62 if (event.key === back) send(props.offset - step)
63 else if (event.key === fwd) send(props.offset + step)
64 else if (event.key === 'pageup') send(props.offset - page)
65 else if (event.key === 'pagedown') send(props.offset + page)
66 else if (event.key === 'home') send(0)
67 else if (event.key === 'end') send(max)
68 else if (!event.ctrl && !event.meta) surface.post({ press: event.key } satisfies BarMessage)
69 })
70
71 const thumbColor = state.grab !== null || state.isHot ? props.colors.active : props.colors.thumb
72 const cell = (i: number) => {
73 const isThumb = max > 0 && i >= start && i < start + size
74 if (isV) return isThumb ? '┃' : '│'
75 return isThumb ? '━' : '─'
76 }
77 const colorOf = (i: number) => (max > 0 && i >= start && i < start + size ? thumbColor : props.colors.track)
78
79 if (isV) {
80 return (
81 <Box flexDirection="column" width={1}>
82 {Array.from({ length: props.length }, (_, i) => (
83 <Text color={colorOf(i)} backgroundColor={props.colors.bg}>
84 {cell(i)}
85 </Text>
86 ))}
87 </Box>
88 )
89 }
90 const before = max > 0 ? start : 0
91 const thumb = max > 0 ? size : 0
92 return (
93 <Text backgroundColor={props.colors.bg}>
94 <Text color={props.colors.track}>{'─'.repeat(before)}</Text>
95 <Text color={thumbColor}>{'━'.repeat(thumb)}</Text>
96 <Text color={props.colors.track}>{'─'.repeat(Math.max(0, props.length - before - thumb))}</Text>
97 </Text>
98 )
99}
100
101export default Scrollbar
102hooks/theme.ts 29 lines1// GitHub's dark theme (Primer dark default), as the other mods in this collection use it.
2export const C = {
3 canvas: '#0d1117',
4 sidebar: '#010409',
5 border: '#30363d',
6 fg: '#e6edf3',
7 text: '#c9d1d9',
8 muted: '#7d8590',
9 subtle: '#484f58',
10 accent: '#2f81f7',
11 selected: '#21262d', // the picked row, beside the accent bar
12 hover: '#161b22', // the row under the pointer
13 pressed: '#262c36', // a row while pressed
14 segment: '#1c3a5e', // the active half of a switch
15 header: '#161b22',
16 controlHover: '#262c36',
17 controlPressed: '#30363d',
18 track: '#21262d',
19 thumb: '#484f58',
20 thumbActive: '#8b949e',
21 on: '#3fb950', // an enabled plugin
22 onBg: '#1a4a29', // the ON switch's fill
23 offBg: '#21262d', // the OFF switch's fill
24 warn: '#d29922',
25 heading: '#e6edf3',
26 code: '#a5d6ff', // inline and fenced code in a README
27 codeBg: '#161b22',
28} as const
29types/index.d.ts 40 lines1// Where a plugin is installed, or `session` for a mod this session hot-reloads from its own folder.
2export type ModScope = 'user' | 'project' | 'local' | 'session'
3
4// A slash command a plugin registered: how its panel, if it has one, is opened.
5export type ModCommand = { name: string; description: string }
6
7// One installed plugin, as the pane lists it.
8export type ModEntry = {
9 id: string // name@marketplace; the name alone for a session mod
10 name: string
11 marketplace: string
12 version: string
13 scope: ModScope
14 enabled: boolean
15 isMod: boolean // carries a hooks module (hooks/hooks.json `modules`): a mod, not only skills or MCP
16 description: string
17 root: string // where its files are
18 commands: ModCommand[]
19}
20
21export type Filter = 'mods' | 'all'
22
23// Where the list's and the detail's windows start; the detail's offset is for one entry.
24export type Scroll = { list: number; detail: number; detailFor: string | null }
25
26declare module 'claude-code' {
27 interface PluginState {
28 'mod-manager': {
29 entries: ModEntry[]
30 loadedAt: number
31 selected: string | null
32 filter: Filter
33 scroll: Scroll
34 busy: string
35 note: string
36 needsReload: boolean
37 }
38 }
39}
40