SLOPSHOPPER

mods

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…

newpanecommandprocesstimer
v0.5.1no licenseupdated 2026-10-24jz-wilson/claude-mods/mods
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mods
│ ┃ Mods ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ Off means off in every chat. Hidden keeps it ⏺ Read(src/auth.ts) │ ┃ of sight. Changes apply within 3 seconds. ⎿ Read 6 lines │ ┃ New mods appear here by themselves. ⏺ Update(src/auth.ts) │ ┃ ctrl+x tab or click to focus · /mods toggle| ⎿ 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 │ ⎿ mods: Opened the Mods pane. Click a button, or Tab to it and pre │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Mods
Off means off in every chat. Hidden keeps it on but out of sight. Changes apply within 3 seconds. New mods appear here by themselves. ctrl+x tab or click to focus · /mods toggle|show|open|i…
README

claude-mods

Mods for Claude Code.

ModWhat it does
cache-barStatus-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.
filetreeFork 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-deskTask list above the prompt that follows Claude's to-do list, plus a side pane with one live card per subagent.
helpin-perPane listing open Helpin PER-* tasks; pick one to have Claude start working on it.
cache-clockCountdown above the prompt until the chat's cache goes cold, with a Keep warm button.
handoffWhen 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).
reflectWhen you correct Claude, offers to save the fix as a lasting rule in CLAUDE.md or a rules folder.
chat-inventorySide 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-optimizerBefore 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-refinerA 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 setting

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.

Install

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 and claude-hud

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:

  • backs up settings.json to settings.json.bak-cache-bar;
  • moves the current statusLine.command (claude-hud's, for example) into inner in ~/.claude/plugins/cache-bar/config.json;
  • points 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.

Development

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-bar
Source 2 files
hooks/register.tsx 606 lines
1import 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}
606
types/index.d.ts 9 lines
1/** 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