SLOPSHOPPER

mod-manager

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.

newpanecommandprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mod-manager
│ ┃ Mods ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./controls.tsx │ ┃ ──────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Reading the installed plugins… ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ 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 │ ┃ │ ┃ › /mods │ ┃ ──────────────────────────────────────────── ⎿ mod-manager: Click a mod to read about it, its switch to turn it │ ┃ │ ┃ Pick a plugin on the left. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Mods
▣ client module ./controls.tsx ──────────────────────────────────────────────────────── ▣ cli Reading the installed plugins… ──────────────────────────────────────────────────────── Pick a plugin on the left.
README

🧩 mod-manager

한국어 · English

설치한 Claude Code mod를 한 패널에서 관리합니다. 무엇이 깔려 있는지 둘러보고, README를 읽고, 하나씩 켜고 끄고, 각 mod의 패널을 버튼으로 바로 열 수 있어요.

<img src="../../images/mod-manager-ko.png" alt="mod-manager" width="100%">

기능

  • 둘러보기: 마켓플레이스별로 묶은 목록. ● 켜짐 / ○ 꺼짐, 버전, 줄 끝 ON OFF 스위치.
  • 상세: 고른 mod의 버전, 마켓플레이스, 설치 범위, 설명, 그리고 README(제목 · 목록 · 코드 블록 스타일, 스크롤).
  • 켜기 / 끄기: 스위치를 누르면 claude plugin enable|disable을 실행합니다. 같은 플러그인이 여러 범위에 깔려 있으면 이 프로젝트에 실제로 적용되는 것(local › project › user)을 바꿔요. 상태줄의 ⟳ Reload로 바로 적용합니다.
  • 패널 열기: mod가 등록한 슬래시 명령이 /diffs 같은 버튼으로 나오고, 누르면 그 mod의 패널이 열립니다.
  • Mods / All: 기본은 hooks 모듈이 있는 mod만, All로 스킬 · MCP · LSP 플러그인까지 전부.
  • 안전장치: mod-manager 자신(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가 거절되면 상태줄 안내대로 직접 입력하세요.
  • 명령 버튼으로 연 패널은 "직접 입력"이 아니라 mod가 실행한 명령으로 처리돼서, 터미널이 좁으면(144칸 미만, 전에 연 적 있는 패널은 110칸 미만) 바로 자리 잡지 않을 수 있어요. 그럴 땐 그 명령을 직접 입력하세요.
  • 목록은 claude plugin list --json을 읽어 만듭니다. 처음 여는 데 1~2초 걸릴 수 있어요.
Source 10 files
hooks/register.tsx 360 lines
1// 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}
360
hooks/catalog.ts 91 lines
1// 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}
91
hooks/list-client.tsx 123 lines
1// 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
123
hooks/markdown.ts 64 lines
1// 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}
64
hooks/text.ts 175 lines
1// 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}
175
hooks/view.tsx 356 lines
1// 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}
356
hooks/controls.tsx 111 lines
1// 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
111
hooks/scrollbar.tsx 102 lines
1// 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
102
hooks/theme.ts 29 lines
1// 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
29
types/index.d.ts 40 lines
1// 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