A Mods pane that finds every mod on this Mac by itself, also the ones installed from a marketplace, turns each one on or off in every chat, and opens the…

Mods for Claude Code.
| Mod | What it does |
|---|---|
| cache-bar | Status-line bar under your existing status line showing how long the prompt cache stays warm, with a handoff countdown: cache ▰▰▰▱▱▱▱▱ 23m 182k · handoff in 18m. Turns yellow at a quarter of the TTL, red at the handoff threshold, and follows the drop to a 5-minute TTL. /cache-bar:setup installs it. |
| filetree | Fork of data-goblin/claude-code-filetree that shows only the files Claude touched this session; the header's ◆ button toggles to the whole tree. |
| helper-desk | Task list above the prompt that follows Claude's to-do list, plus a side pane with one live card per subagent. |
| helpin-per | Pane listing open Helpin PER-* tasks; pick one to have Claude start working on it. |
| cache-clock | Countdown above the prompt until the chat's cache goes cold, with a Keep warm button. |
| handoff | When a chat gets very long, writes a handoff prompt and starts a fresh chat with it (/refresh <focus>; the handoff tells the new chat to run /pickup). |
| reflect | When you correct Claude, offers to save the fix as a lasting rule in CLAUDE.md or a rules folder. |
| chat-inventory | Side pane, In this chat, listing the to-do list, connectors and calls per connector, files and skills this chat used. |
| screen-share | /screen-share hides names, emails, phone numbers and money amounts on screen while you record. |
| replay-theater | /replay steps through the file edits Claude made in the last turn, one diff at a time. |
| mods | /mods opens a pane to turn each mod on or off, including mods installed from other marketplaces, and to install mods a marketplace offers that are not installed yet. |
| effort-optimizer | Before Claude starts on a prompt, a quick Sonnet check picks the effort level it needs (Low to Max) and offers a one-click switch, or sets it by itself with /effort-optimizer auto. |
| prompt-refiner | A Refine button in the hint line under the prompt, any time in the chat. Press it and pick a rewrite style for the prompt in the box, like a phone keyboard's suggestions: Clearer, Shorter, Elaborate (fuller, same shape), Structured (the job, the why, the guardrails, done means), Ask me first (up to 3 questions on gaps), or My guidance. The rewrite shows above the prompt with what changed; Use it fills the box, Refine runs another pass. /refine <prompt> does the same. |
The eight mods other than replay-theater, from helper-desk to effort-optimizer, come from Ben AI's benai-mods (benai.co) and are redistributed with permission, with Windows fixes added here. replay-theater (one Ben AI change: /mods switch support) is Anthropic's sample mod from anthropics/claude-code-playground, under the Apache 2.0 license in its LICENSE.
Reflect saves a rule either to ~/.claude/CLAUDE.md (Save everywhere) or as its own note in a folder you choose (Save to vault). Set that folder as rules_folder when you install reflect, or later for reflect in /config. If it is empty, reflect asks for the folder the first time you press Save to vault.
At the prompt of a terminal session:
/plugin install helper-desk --marketplace jz-wilson/claude-mods
Answer y to add the marketplace, then pick a scope.
From a shell:
claude plugin install helper-desk --marketplace jz-wilson/claude-mods
claude plugin install cache-bar --marketplace jz-wilson/claude-mods
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with jz-wilson/claude-mods, then Add plugin.
cache-bar is a status-line script, so it takes over statusLine and runs the previous status line inside it. After installing, run /cache-bar:setup. It:
settings.json to settings.json.bak-cache-bar;statusLine.command (claude-hud's, for example) into inner in ~/.claude/plugins/cache-bar/config.json;statusLine.command at the newest installed cache-bar version, and sets refreshInterval: 60 so the countdown repaints while you are idle.The previous status line prints first, unchanged, and the bar goes on its own line underneath. If the inner command fails, the bar still prints. Before the first request of a session there is no cache yet, so the bar is hidden; after expiry it shows a dim cold.
The same config file sets the handoff threshold and the context floor:
{ "inner": "…", "handoffMinutes": 5, "floorTokens": 80000 }
The threshold is capped at a fifth of the TTL, so it fits the 5-minute TTL too. Under floorTokens of context the bar says no handoff (<floor).
To undo, copy settings.json.bak-cache-bar back over settings.json, or set statusLine.command back to the inner value.
Each mod is a folder with .claude-plugin/plugin.json. CI runs claude plugin validate on every mod for each push and pull request, then claude plugin test for mods with a hooks module and bun test for the rest. A merge to main that bumps a mod's version publishes a GitHub release tagged <mod>-v<version>.
claude plugin validate agent-tree
claude plugin test agent-tree
bun test cache-barhooks/register.tsx 606 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// The Mods pane: one row per knowledge-work mod, with an On/Off switch that works
4// in every chat (switches.json). Mods that draw something also get Shown/Hidden,
5// kept as "<name>:hidden": true in the same file. Showing a side pane, or the
6// Refresh panel, also leaves an open request for this chat.
7// The list is found on disk, not typed in: every folder under ~/.claude/mods with a
8// plugin.json and a hooks.json that has "modules", every CLAUDE_CODE_PLUGIN_DIRS
9// folder, and this chat's own ~/.claude/dev-mods/<session id>/ mods.
10
11const PANE = 'mods'
12const TITLE = 'Mods'
13const NOTE = { plugin: 'mods', key: 'note' } as const
14const POLL_MS = 3000
15const SCAN_MS = 10_000
16const BUTTONS_COLS = 26
17
18// hide: has a Shown/Hidden switch. ask: Show also leaves an open request. tip: the slash command.
19type Mod = { name: string; title: string; what: string; pane: boolean; hide: boolean; ask: boolean; tip: string }
20// A row in the pane: a known mod or one found on disk. switchable: its source reads switches.json.
21// market: not installed, offered by that marketplace.
22type Row = Mod & { switchable: boolean; chatOnly: boolean; market?: string }
23
24const MODS: readonly Mod[] = [
25 { name: 'cache-clock', title: 'Cache Clock', what: 'Counts down until the cache goes cold', pane: false, hide: true, ask: false, tip: '/cacheclock to show or hide' },
26 { name: 'handoff', title: 'Handoff (Refresh)', what: 'Moves a long chat to a fresh one', pane: false, hide: true, ask: true, tip: '/refresh to show it' },
27 { name: 'screen-share', title: 'Screen-Share Mode', what: 'Hides emails, phones and amounts on screen', pane: false, hide: false, ask: false, tip: '/screen-share to hide private data' },
28 { name: 'prompt-refiner', title: 'Prompt Refiner', what: 'Rewrites the prompt in the box in a style you pick', pane: false, hide: false, ask: false, tip: '/refine <prompt>, or the Refine button' },
29 { name: 'reflect', title: 'Reflect', what: 'Saves your corrections as lasting rules', pane: false, hide: false, ask: false, tip: '' },
30 { name: 'helper-desk', title: 'Helper Desk', what: 'Task list above the prompt, plus a card per subagent', pane: true, hide: true, ask: true, tip: '/helpers to open' },
31 { name: 'chat-inventory', title: 'Chat Inventory', what: 'Connectors, files and skills this chat used', pane: true, hide: true, ask: true, tip: '/inventory to open' },
32 // A status-line script, not a hooks mod, so the scan cannot find it; it reads switches.json itself.
33 { name: 'cache-bar', title: 'Cache Bar', what: 'Prompt-cache expiry bar under the status line', pane: false, hide: false, ask: false, tip: '' },
34]
35
36// Module variables reset on a reload; the switches themselves live in the file.
37let sid = ''
38let lastRaw = ''
39let cache: { at: number; rows: Row[] } | null = null
40// The session's folder, for plugins installed for one project.
41let cwd = ''
42// Installs under way, so a second press does not start another.
43const installing = new Set<string>()
44
45const NOT_SWITCHABLE = 'Not switchable yet. Ask Claude: add the /mods switch to this mod.'
46
47const homeOf = ($: EngineInterface) => $.plugin.root.replace(/\\/g, '/').split('/.claude/')[0] ?? ''
48const switchesPath = ($: EngineInterface) => `${homeOf($)}/.claude/mods/switches.json`
49
50function cut(s: string, n: number) {
51 if (n <= 0) return ''
52 return s.length <= n ? s : `${s.slice(0, Math.max(0, n - 1))}…`
53}
54
55async function log($: EngineInterface, where: string, err: unknown) {
56 try {
57 const dir = `${homeOf($)}/.claude/mods-logs`
58 const file = `${dir}/mods.log`
59 let old = ''
60 try {
61 old = String(await $.fs.read(file))
62 } catch {
63 old = ''
64 }
65 const when = new Date(await $.clock.now()).toISOString()
66 const msg = err instanceof Error ? err.message : String(err)
67 await $.fs.write(file, `${old}${when} ${where}: ${msg}\n`.slice(-50_000))
68 } catch {
69 // Nowhere left to report it.
70 }
71}
72
73async function readRaw($: EngineInterface) {
74 try {
75 const path = switchesPath($)
76 if (!(await $.fs.exists(path))) return ''
77 return String(await $.fs.read(path))
78 } catch {
79 return ''
80 }
81}
82
83function parse(raw: string): Record<string, unknown> {
84 try {
85 const json = JSON.parse(raw) as unknown
86 return json && typeof json === 'object' && !Array.isArray(json) ? (json as Record<string, unknown>) : {}
87 } catch {
88 return {}
89 }
90}
91
92async function readSwitches($: EngineInterface) {
93 return parse(await readRaw($))
94}
95
96const isOff = (json: Record<string, unknown>, name: string) => json[name] === false
97const isHidden = (json: Record<string, unknown>, name: string) => json[`${name}:hidden`] === true
98
99async function save($: EngineInterface, json: Record<string, unknown>) {
100 const text = `${JSON.stringify(json, null, 2)}\n`
101 await $.fs.write(switchesPath($), text)
102 lastRaw = text
103}
104
105// Read, change, write the whole file. Off is `false`; on deletes the key.
106async function flip($: EngineInterface, name: string) {
107 const json = await readSwitches($)
108 const turnOff = !isOff(json, name)
109 if (turnOff) json[name] = false
110 else delete json[name]
111 await save($, json)
112 return turnOff
113}
114
115// Hidden is `"<name>:hidden": true`; shown deletes the key.
116async function flipHidden($: EngineInterface, name: string) {
117 const json = await readSwitches($)
118 const key = `${name}:hidden`
119 const hide = !isHidden(json, name)
120 if (hide) json[key] = true
121 else delete json[key]
122 await save($, json)
123 return hide
124}
125
126async function askOpen($: EngineInterface, name: string) {
127 if (!sid) throw new Error('no session id')
128 await $.fs.write(`${homeOf($)}/.claude/mods/.open-${name}-${sid}`, '')
129}
130
131async function readText($: EngineInterface, path: string) {
132 try {
133 if (!(await $.fs.exists(path))) return ''
134 return String(await $.fs.read(path))
135 } catch {
136 return ''
137 }
138}
139
140// Folders (or links to folders) directly inside dir. A missing dir is an empty list.
141async function listDirs($: EngineInterface, dir: string) {
142 try {
143 const entries = await $.fs.list(dir)
144 return entries.filter(x => !x.name.startsWith('.') && (x.kind === 'dir' || x.isLink)).map(x => `${dir}/${x.name}`)
145 } catch {
146 return []
147 }
148}
149
150const slashes = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '')
151
152// Plugins installed from a marketplace, for this user or this folder: installed_plugins.json, not the
153// cache folder, which keeps a plugin's files after it is uninstalled.
154async function installed($: EngineInterface, home: string) {
155 const json = parse(await readText($, `${home}/.claude/plugins/installed_plugins.json`))
156 const plugins = json.plugins && typeof json.plugins === 'object' ? (json.plugins as Record<string, unknown>) : {}
157 const here = slashes(cwd).toLowerCase()
158 const dirs: string[] = []
159 const names = new Set<string>()
160 for (const [key, list] of Object.entries(plugins)) {
161 if (!Array.isArray(list)) continue
162 for (const x of list as { scope?: unknown; projectPath?: unknown; installPath?: unknown }[]) {
163 const project = typeof x?.projectPath === 'string' ? slashes(x.projectPath).toLowerCase() : ''
164 const mine = x?.scope === 'user' || (!!project && !!here && (here === project || here.startsWith(`${project}/`)))
165 if (!mine || typeof x.installPath !== 'string') continue
166 dirs.push(slashes(x.installPath))
167 names.add(key.split('@')[0] ?? key)
168 }
169 }
170 return { dirs, names }
171}
172
173// Each marketplace's mods, kept until its marketplace.json changes, so a scan reads one file per marketplace.
174const offered = new Map<string, { text: string; rows: Row[] }>()
175
176// Mods a known marketplace offers that are not installed: each listed plugin whose local source folder is a mod.
177async function available($: EngineInterface, home: string, have: Set<string>) {
178 const markets = parse(await readText($, `${home}/.claude/plugins/known_marketplaces.json`))
179 const out: Row[] = []
180 for (const [market, info] of Object.entries(markets)) {
181 const at = (info as { installLocation?: unknown } | null)?.installLocation
182 if (typeof at !== 'string') continue
183 const root = slashes(at)
184 const text = await readText($, `${root}/.claude-plugin/marketplace.json`)
185 let hit = offered.get(market)
186 if (!hit || hit.text !== text) {
187 const rows: Row[] = []
188 const listed = parse(text).plugins
189 for (const p of Array.isArray(listed) ? (listed as { name?: unknown; source?: unknown }[]) : []) {
190 if (typeof p?.name !== 'string' || typeof p.source !== 'string' || !p.source.startsWith('./')) continue
191 const row = await inspect($, `${root}/${slashes(p.source.slice(2))}`, false)
192 if (row) rows.push({ ...row, name: p.name, market })
193 }
194 hit = { text, rows }
195 offered.set(market, hit)
196 }
197 for (const row of hit.rows) {
198 if (have.has(row.name)) continue
199 have.add(row.name)
200 out.push(row)
201 }
202 }
203 return out.sort((a, b) => a.title.localeCompare(b.title))
204}
205
206// CLAUDE_CODE_PLUGIN_DIRS: folders split by ':' (';' on Windows, where ':' is in C:\), '~' meaning the home folder.
207async function envDirs($: EngineInterface) {
208 let raw: string | undefined
209 try {
210 raw = await $.env.get('CLAUDE_CODE_PLUGIN_DIRS')
211 } catch {
212 return []
213 }
214 if (!raw) return []
215 const home = homeOf($)
216 const sep = (await $.env.get('OS')) === 'Windows_NT' ? ';' : ':'
217 return raw
218 .split(sep)
219 .map(x => x.trim().replace(/\\/g, '/'))
220 .filter(Boolean)
221 .map(x => (x === '~' ? home : x.startsWith('~/') ? `${home}${x.slice(1)}` : x))
222 .map(x => x.replace(/\/+$/, ''))
223}
224
225function titleCase(name: string) {
226 return name
227 .split(/[-_\s]+/)
228 .filter(Boolean)
229 .map(w => w.charAt(0).toUpperCase() + w.slice(1))
230 .join(' ')
231}
232
233// Every hooks source file, tests and backups left out.
234async function hooksSource($: EngineInterface, dir: string) {
235 let text = ''
236 try {
237 const entries = await $.fs.list(`${dir}/hooks`)
238 for (const x of entries) {
239 if (x.kind !== 'file' || !/\.(tsx?|jsx?|mjs|cjs)$/.test(x.name) || /\.test\./.test(x.name)) continue
240 if (x.size > 2_000_000) continue
241 text += `${await readText($, `${dir}/hooks/${x.name}`)}\n`
242 }
243 } catch {
244 // An unreadable hooks folder means nothing is found in it.
245 }
246 return text
247}
248
249function commandsIn(source: string) {
250 const out: string[] = []
251 for (const m of source.matchAll(/command\.register\(\s*\{\s*name:\s*['"`]([^'"`]+)['"`]/g)) {
252 const name = m[1] ?? ''
253 if (name && !out.includes(name)) out.push(name)
254 }
255 return out
256}
257
258// A mod is a plugin.json plus a hooks.json with "modules". Anything else is not one.
259async function inspect($: EngineInterface, dir: string, chatOnly: boolean): Promise<Row | null> {
260 const pluginRaw = await readText($, `${dir}/.claude-plugin/plugin.json`)
261 if (!pluginRaw) return null
262 const hooksJson = parse(await readText($, `${dir}/hooks/hooks.json`))
263 if (!('modules' in hooksJson)) return null
264 const plugin = parse(pluginRaw)
265 const folder = dir.split('/').pop() ?? ''
266 const name = typeof plugin.name === 'string' && plugin.name ? plugin.name : folder
267 if (!name || name === 'mods') return null
268 const source = await hooksSource($, dir)
269 const known = MODS.find(m => m.name === name)
270 const commands = commandsIn(source).map(c => `/${c}`)
271 return {
272 name,
273 title: known?.title ?? titleCase(name),
274 what: known?.what ?? (typeof plugin.description === 'string' ? plugin.description : ''),
275 tip: known?.tip ?? commands.join(', '),
276 pane: known?.pane ?? false,
277 hide: known?.hide ?? false,
278 ask: known?.ask ?? false,
279 switchable: source.includes('switches.json'),
280 chatOnly,
281 }
282}
283
284// Known mods first in their old order, then new ones A to Z, then this chat's own, then ones not installed.
285async function scan($: EngineInterface): Promise<Row[]> {
286 const home = homeOf($)
287 const found = new Map<string, Row>()
288 const dirs: [string, boolean][] = []
289 const inst = await installed($, home)
290 for (const d of await listDirs($, `${home}/.claude/mods`)) dirs.push([d, false])
291 for (const d of await envDirs($)) dirs.push([d, false])
292 for (const d of inst.dirs) dirs.push([d, false])
293 if (sid) for (const d of await listDirs($, `${home}/.claude/dev-mods/${sid}`)) dirs.push([d, true])
294 const seen = new Set<string>()
295 for (const [dir, chatOnly] of dirs) {
296 if (seen.has(dir)) continue
297 seen.add(dir)
298 const row = await inspect($, dir, chatOnly)
299 // The same mod from two marketplaces: keep the copy that can be switched.
300 if (row && (!found.has(row.name) || (row.switchable && !found.get(row.name)?.switchable))) found.set(row.name, row)
301 }
302 // A known mod shows only when it is there: found on disk, or installed (cache-bar has no hooks to find).
303 const rows: Row[] = MODS.flatMap(m => {
304 const hit = found.get(m.name)
305 found.delete(m.name)
306 if (!hit && !inst.names.has(m.name)) return []
307 return [{ ...m, switchable: hit ? hit.switchable : true, chatOnly: hit ? hit.chatOnly : false }]
308 })
309 const rest = [...found.values()].sort((a, b) => a.title.localeCompare(b.title))
310 const have = new Set([...inst.names, ...rows.map(r => r.name), ...rest.map(r => r.name), 'mods'])
311 return [...rows, ...rest.filter(r => !r.chatOnly), ...rest.filter(r => r.chatOnly), ...(await available($, home, have))]
312}
313
314// The scan is kept for 10 seconds, so drawing stays fast.
315async function getRows($: EngineInterface, force = false) {
316 const now = await $.clock.now()
317 if (!force && cache && now - cache.at < SCAN_MS) return cache.rows
318 const rows = await scan($)
319 cache = { at: now, rows }
320 return rows
321}
322
323const sigOf = (rows: readonly Row[]) => rows.map(r => `${r.name}|${r.title}|${r.what}|${r.tip}|${r.switchable}|${r.chatOnly}|${r.market ?? ''}`).join('\n')
324
325// While the pane is open, look again every 10 seconds and redraw when the list changed.
326async function scanPoll($: EngineInterface) {
327 let open = false
328 try {
329 open = (await $.ui.panes()).some(p => p.id === PANE)
330 } catch {
331 open = false
332 }
333 if (!open) return
334 const before = sigOf(cache?.rows ?? [])
335 const rows = await getRows($, true)
336 if (sigOf(rows) !== before) $.ui.invalidate('ui.render')
337}
338
339// Another chat may flip a switch: redraw when the file changes.
340async function poll($: EngineInterface) {
341 const raw = await readRaw($)
342 if (raw === lastRaw) return
343 lastRaw = raw
344 $.ui.invalidate('ui.render')
345}
346
347// The four actions behind the buttons. Each returns what it did; a real outcome is also left in NOTE,
348// while an unknown name or a second install press changes nothing, as the buttons always did.
349async function say($: EngineInterface, text: string) {
350 await $.state.set(NOTE, text)
351 return text
352}
353
354async function toggleMod($: EngineInterface, name: string) {
355 const mod = (await getRows($)).find(m => m.name === name && m.switchable)
356 if (!mod) return `No switchable mod named ${name}.`
357 try {
358 const nowOff = await flip($, name)
359 return await say($, `${mod.title} is ${nowOff ? 'off' : 'on'} in every chat.`)
360 } catch (err) {
361 await log($, `toggle ${name}`, err)
362 return say($, `Could not change ${mod.title}. See ~/.claude/mods-logs/mods.log`)
363 }
364}
365
366async function showMod($: EngineInterface, name: string) {
367 const mod = (await getRows($)).find(m => m.name === name && m.hide && m.switchable)
368 if (!mod) return `${name} has no Shown/Hidden switch.`
369 try {
370 if (isOff(await readSwitches($), name)) return await say($, `${mod.title} is off. Turn it on first.`)
371 const nowHidden = await flipHidden($, name)
372 if (!nowHidden && mod.ask) await askOpen($, name)
373 return await say($, `${mod.title} is ${nowHidden ? 'hidden' : 'shown'} in every chat.`)
374 } catch (err) {
375 await log($, `show ${name}`, err)
376 return say($, `Could not change ${mod.title}. See ~/.claude/mods-logs/mods.log`)
377 }
378}
379
380// Install: a mod a marketplace offers, installed for this user; it loads on /reload-plugins.
381async function installMod($: EngineInterface, name: string) {
382 const mod = (await getRows($)).find(m => m.name === name && m.market)
383 if (!mod?.market) return `${name} is not offered by a marketplace.`
384 if (installing.has(name)) return `${mod.title} is already installing.`
385 installing.add(name)
386 try {
387 await $.state.set(NOTE, `Installing ${mod.title}…`)
388 const { exitCode, stdout, stderr } = await $.process.run(['claude', 'plugin', 'install', `${name}@${mod.market}`], { timeoutMs: 120_000 })
389 if (exitCode !== 0) throw new Error((stderr || stdout).trim() || `exit code ${exitCode}`)
390 await getRows($, true)
391 return await say($, `${mod.title} is installed. Run /reload-plugins to load it.`)
392 } catch (err) {
393 await log($, `install ${name}`, err)
394 return await say($, `Could not install ${mod.title}. See ~/.claude/mods-logs/mods.log`)
395 } finally {
396 installing.delete(name)
397 }
398}
399
400// Open: for a side pane that is On and Shown, asks this chat to open it again.
401async function openMod($: EngineInterface, name: string) {
402 const mod = (await getRows($)).find(m => m.name === name && m.pane && m.switchable)
403 if (!mod) return `${name} has no side pane to open.`
404 try {
405 const json = await readSwitches($)
406 if (isOff(json, name) || isHidden(json, name)) return await say($, `${mod.title} is ${isOff(json, name) ? 'off' : 'hidden'}. Turn it on and show it first.`)
407 await askOpen($, name)
408 return await say($, `${mod.title} opens in this chat within 3 seconds.`)
409 } catch (err) {
410 await log($, `open ${name}`, err)
411 return say($, `Could not open ${mod.title}. See ~/.claude/mods-logs/mods.log`)
412 }
413}
414
415const VERBS = ['toggle', 'show', 'open', 'install']
416
417async function runVerb($: EngineInterface, verb: string, name: string) {
418 if (verb === 'toggle') return toggleMod($, name)
419 if (verb === 'show') return showMod($, name)
420 if (verb === 'open') return openMod($, name)
421 return installMod($, name)
422}
423
424export const register: Register = on => {
425 on('session.start', async ($, e, next) => {
426 try {
427 sid = await $.session.id()
428 cwd = e.cwd ?? ''
429 cache = null
430 await $.command.register({ name: 'mods', description: 'Open the Mods pane: turn each mod on or off, or show or hide it, in every chat', immediate: true })
431 lastRaw = await readRaw($)
432 $.clock.every(POLL_MS, () => {
433 void poll($).catch(err => log($, 'poll', err))
434 })
435 $.clock.every(SCAN_MS, () => {
436 void scanPoll($).catch(err => log($, 'scan', err))
437 })
438 } catch (err) {
439 await log($, 'session start', err)
440 }
441 return next(e)
442 })
443
444 on('command.run', { command: 'mods' }, async ($, e) => {
445 const [verb = '', name = ''] = e.args.trim().split(/\s+/)
446 if (VERBS.includes(verb)) {
447 if (!name) return { text: `Usage: /mods ${verb} <mod name>` }
448 return { text: await runVerb($, verb, name) }
449 }
450 try {
451 await getRows($)
452 } catch (err) {
453 await log($, 'scan on open', err)
454 }
455 // focus: brings the pane to the front even when another pane's tab is showing, and hands it the keys,
456 // so Tab walks the buttons and Enter presses one; Esc goes back to the prompt.
457 await $.ui.open({ id: PANE, title: TITLE, focus: true })
458 return { text: 'Opened the Mods pane. Click a button, or Tab to it and press Enter (ctrl+x tab gives the pane the keys); Esc goes back to the prompt.' }
459 })
460
461 // Presses come here, not to onPress: a closure lives for one drawing only. /mods <action> <name> does the same.
462 on('ui.press', { plugin: 'mods', element: /^toggle-.+$/ }, async ($, e) => {
463 await toggleMod($, e.element.slice('toggle-'.length))
464 return { element: e.element }
465 })
466
467 on('ui.press', { plugin: 'mods', element: /^show-.+$/ }, async ($, e) => {
468 await showMod($, e.element.slice('show-'.length))
469 return { element: e.element }
470 })
471
472 on('ui.press', { plugin: 'mods', element: /^install-.+$/ }, async ($, e) => {
473 await installMod($, e.element.slice('install-'.length))
474 return { element: e.element }
475 })
476
477 on('ui.press', { plugin: 'mods', element: /^open-.+$/ }, async ($, e) => {
478 await openMod($, e.element.slice('open-'.length))
479 return { element: e.element }
480 })
481
482 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
483 const { Box, Text, Button } = $.ui.resolve(e)
484 const noop = () => undefined
485 const note = (await $.state.get(NOTE)).value ?? ''
486 const json = await readSwitches($)
487 const cols = Math.max(24, e.props.bodyColumns)
488 const left = Math.max(10, cols - BUTTONS_COLS)
489
490 let list: Row[] = []
491 try {
492 list = await getRows($)
493 } catch (err) {
494 await log($, 'scan', err)
495 list = MODS.map(m => ({ ...m, switchable: true, chatOnly: false }))
496 }
497
498 const rows = list.map(m => {
499 const off = m.switchable && isOff(json, m.name)
500 const hidden = isHidden(json, m.name)
501 const width = m.switchable || m.market ? left : cols
502 const about = (
503 <Box flexDirection="column" width={width}>
504 <Text weight="bold" dimColor={off} wrap="truncate-end">
505 {cut(m.title, width)}
506 </Text>
507 {m.what ? (
508 <Text dimColor wrap="truncate-end">
509 {cut(m.what, width)}
510 </Text>
511 ) : null}
512 {m.tip ? (
513 <Text dimColor italic wrap="truncate-end">
514 {cut(m.tip, width)}
515 </Text>
516 ) : null}
517 {m.chatOnly ? (
518 <Text dimColor italic wrap="truncate-end">
519 This chat only
520 </Text>
521 ) : null}
522 {m.market ? (
523 <Text dimColor italic wrap="truncate-end">
524 {cut(`Not installed · from ${m.market}`, width)}
525 </Text>
526 ) : null}
527 {m.switchable || m.market ? null : (
528 <Text dimColor wrap="wrap">
529 {NOT_SWITCHABLE}
530 </Text>
531 )}
532 </Box>
533 )
534 if (m.market) {
535 return (
536 <Box key={`row-${m.name}`} flexDirection="row" justifyContent="space-between" gap={1} marginTop={1}>
537 {about}
538 <Button key={`install-${m.name}`} onPress={noop}>
539 Install
540 </Button>
541 </Box>
542 )
543 }
544 if (!m.switchable) {
545 return (
546 <Box key={`row-${m.name}`} flexDirection="row" marginTop={1}>
547 {about}
548 </Box>
549 )
550 }
551 return (
552 <Box key={`row-${m.name}`} flexDirection="row" justifyContent="space-between" gap={1} marginTop={1}>
553 {about}
554 <Box flexDirection="row" gap={1}>
555 {m.pane ? (
556 off || hidden ? (
557 <Text dimColor>Open</Text>
558 ) : (
559 <Button key={`open-${m.name}`} onPress={noop}>
560 Open
561 </Button>
562 )
563 ) : null}
564 {m.hide ? (
565 off ? (
566 <Text dimColor>{hidden ? 'Hidden' : 'Shown'}</Text>
567 ) : (
568 <Button key={`show-${m.name}`} onPress={noop}>
569 {hidden ? <Text dimColor>Hidden</Text> : <Text color="success">Shown</Text>}
570 </Button>
571 )
572 ) : null}
573 <Button key={`toggle-${m.name}`} onPress={noop}>
574 {off ? <Text dimColor>Off</Text> : <Text color="success">On</Text>}
575 </Button>
576 </Box>
577 </Box>
578 )
579 })
580
581 return (
582 <Box flexDirection="column" width={cols}>
583 {rows}
584 <Box marginTop={1} flexDirection="column">
585 {note ? (
586 <Text color="success" wrap="truncate-end">
587 {cut(note, cols)}
588 </Text>
589 ) : null}
590 <Text dimColor wrap="wrap">
591 Off means off in every chat. Hidden keeps it on but out of sight. Changes apply within 3 seconds.
592 </Text>
593 <Text dimColor wrap="wrap">
594 New mods appear here by themselves.
595 </Text>
596 {!e.props.isFocused && (
597 <Text dimColor wrap="truncate-end">
598 {cut('ctrl+x tab or click to focus · /mods toggle|show|open|install <name>', cols)}
599 </Text>
600 )}
601 </Box>
602 </Box>
603 )
604 })
605}
606types/index.d.ts 9 lines1/** A short line under the list after a press: "Cache Clock is off in every chat." */
2export type Note = string
3
4declare module 'claude-code' {
5 interface PluginState {
6 mods: { note: Note }
7 }
8}
9