A Mod Tools button in the prompt footer that opens a panel for picking the model and effort, showing or hiding mod panes, and switching your installed mods on…

A Claude Code mod that puts a ◆ Mod Tools ▾ button at the bottom right of the prompt footer. Click it to open a small control panel above the prompt:
╭──────────────────────────────────────────────────────────────╮
│ ◆ M O D T O O L S Opus 5.5 · High │
│ │
│ MODEL Haiku 4.5 Sonnet 5.5 [Opus 5.5] Fable 5.1 │
│ EFFORT Low Medium [High] XHigh Max │
│ │
│ M O D S All on All off Show Enabled │
│ │
│ ○ Buffer Pane text snippets pane ○ Off │
│ ● Clean View simple checklist ● On │
│ ● Flightdeck agent dashboard [● Shown ] ● On │
│ ● Pinboard decisions, tasks & links pa ○ Hidden ● On │
│ ● Secret Redactor hides secrets & PII ● On │
│ ● Usage Pane plan limits & session us ○ Hidden ● On │
╰──────────────────────────────────────────────────────────────╯
───────────────────────────────────────────── Mod Control Panel ─
/model with its full ID; Opus keeps the 1M context window)./effort). Haiku doesn't use effort, so the row says so.hooks/hooks.json loads function-hook modules) gets an On/Off switch. Plugins that aren't mods, such as Codex, aren't listed, and neither is Mod Tools itself.claude plugin enable|disable <id> --scope user, then /reload-plugins./simple on|off. If the Clean View plugin is disabled, its switch enables it./flightdeck open|close, /pinboard, /pinboard close, /buffer-pane, /usage-pane open|close). A mod that's switched off, or that has no pane, leaves the cell empty.paneOpen value in its $.state, and Mod Tools reads it.Colors come from your Claude Code theme, so the panel follows whatever theme you've picked in /config.
If you can't click the footer, type /tools to open or close the panel.
claude plugin marketplace add jidhra/claude-mods
claude plugin install mod-tools@claude-mods --scope user
Or, inside Claude Code: /plugin install mod-tools --marketplace jidhra/claude-mods.
claude plugin validate .
claude plugin test .hooks/register.tsx 8 lines1import type { Register } from 'claude-code'
2
3import { registerModTools } from './mod-tools'
4
5export const register: Register = on => {
6 registerModTools(on)
7}
8hooks/mod-tools.tsx 511 lines1import { atom, read, update } from 'claude-code'
2import type { ConfigRow, EngineInterface, On } from 'claude-code'
3
4import type { ModToolsMod } from '../types'
5
6const COMMAND = 'tools'
7const FALLBACK_COMMAND = 'mod-tools'
8const TITLE = '◆ M O D T O O L S'
9const FOOTER_LABEL = 'Mod Tools'
10const RULE_LABEL = ' Mod Control Panel ─'
11const LABEL_CELLS = 8
12const MODS_HEADING = 'M O D S'
13
14type Engine = EngineInterface
15
16type ModelChoice = {
17 id: string
18 label: string
19 family: string
20 hasEffort: boolean
21}
22
23type EffortChoice = {
24 value: string
25 label: string
26}
27
28/** The models the panel offers, in the order drawn. Opus keeps the 1M context window. */
29export const MODELS: readonly ModelChoice[] = [
30 { id: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5', family: 'haiku', hasEffort: false },
31 { id: 'claude-sonnet-5-5', label: 'Sonnet 5.5', family: 'sonnet', hasEffort: true },
32 { id: 'claude-opus-5-5[1m]', label: 'Opus 5.5', family: 'opus', hasEffort: true },
33 { id: 'claude-fable-5-1', label: 'Fable 5.1', family: 'fable', hasEffort: true },
34]
35
36export const EFFORTS: readonly EffortChoice[] = [
37 { value: 'low', label: 'Low' },
38 { value: 'medium', label: 'Medium' },
39 { value: 'high', label: 'High' },
40 { value: 'xhigh', label: 'XHigh' },
41 { value: 'max', label: 'Max' },
42]
43
44const isOpenAtom = atom({ plugin: 'mod-tools', key: 'isOpen' } as const, false)
45const modelAtom = atom({ plugin: 'mod-tools', key: 'model' } as const, null)
46const effortAtom = atom({ plugin: 'mod-tools', key: 'effort' } as const, null)
47const modsAtom = atom({ plugin: 'mod-tools', key: 'mods' } as const, [])
48const cleanViewAtom = atom({ plugin: 'clean-view', key: 'cleanViewEnabled' } as const, true)
49
50/** Each mod with a pane publishes whether it is open; the engine lists a plugin's panes only to that plugin. */
51const flightdeckOpenAtom = atom({ plugin: 'flightdeck', key: 'paneOpen' } as const, false)
52const pinboardOpenAtom = atom({ plugin: 'pinboard', key: 'paneOpen' } as const, false)
53const bufferPaneOpenAtom = atom({ plugin: 'buffer-pane', key: 'paneOpen' } as const, false)
54const usagePaneOpenAtom = atom({ plugin: 'usage-pane', key: 'paneOpen' } as const, false)
55
56type PaneCommand = { command: string; args: string }
57
58/** The mods that have a pane: where Show reads whether it is open, and the commands that show and hide it. */
59export const PANES: Readonly<Record<string, { show: PaneCommand; hide: PaneCommand }>> = {
60 flightdeck: { show: { command: 'flightdeck', args: 'open' }, hide: { command: 'flightdeck', args: 'close' } },
61 pinboard: { show: { command: 'pinboard', args: '' }, hide: { command: 'pinboard', args: 'close' } },
62 // /buffer-pane toggles
63 'buffer-pane': { show: { command: 'buffer-pane', args: '' }, hide: { command: 'buffer-pane', args: '' } },
64 'usage-pane': { show: { command: 'usage-pane', args: 'open' }, hide: { command: 'usage-pane', args: 'close' } },
65}
66
67async function readPaneOpen($: Engine): Promise<Record<string, boolean>> {
68 return {
69 flightdeck: await read($, flightdeckOpenAtom),
70 pinboard: await read($, pinboardOpenAtom),
71 'buffer-pane': await read($, bufferPaneOpenAtom),
72 'usage-pane': await read($, usagePaneOpenAtom),
73 }
74}
75
76/** The bare plugin name: `pinboard@claude-mods` → `pinboard`. */
77const baseName = (id: string) => id.split('@')[0] ?? id
78
79async function setShown($: Engine, mod: ModToolsMod, isShown: boolean) {
80 const pane = PANES[baseName(mod.id)]
81 if (pane === undefined) {
82 return
83 }
84 await $.command.run(isShown ? pane.show : pane.hide)
85}
86
87// Cells of the two switch columns, so every row's buttons line up under their headings
88const SHOW_CELLS = 10
89const ENABLED_CELLS = 8
90
91/** Clean View stays loaded when switched off: its row runs /simple instead of disabling the plugin. */
92export const CLEAN_VIEW_ID = 'clean-view@claude-mods'
93const SELF_ID = 'mod-tools@claude-mods'
94
95/** Short captions for the mods this panel knows; any other mod shows its name alone. */
96const CAPTIONS: Readonly<Record<string, string>> = {
97 'clean-view': 'simple checklist',
98 flightdeck: 'agent dashboard',
99 'buffer-pane': 'text snippets pane',
100 pinboard: 'decisions, tasks & links pane',
101 'usage-pane': 'plan limits & session usage pane',
102 'secret-redactor': 'hides secrets & PII',
103}
104
105/** `clean-view@claude-mods` → `Clean View`. */
106export function modName(id: string): string {
107 return (id.split('@')[0] ?? id)
108 .split(/[-_]/)
109 .filter(word => word !== '')
110 .map(word => word[0]!.toUpperCase() + word.slice(1))
111 .join(' ')
112}
113
114type ListedPlugin = { id: string; enabled: boolean; installPath: string }
115
116/** A mod is a plugin whose hooks/hooks.json loads function-hook modules; classic-hook plugins (Codex) are not. */
117async function isMod($: Engine, installPath: string): Promise<boolean> {
118 try {
119 const manifest = asRecord(JSON.parse(String(await $.fs.read(`${installPath}/hooks/hooks.json`))))
120
121 return Array.isArray(manifest.modules) && manifest.modules.length > 0
122 } catch {
123 return false
124 }
125}
126
127/** Lists the installed mods (not this one) from `claude plugin list --json`. */
128async function refreshMods($: Engine) {
129 let listed: ListedPlugin[]
130 try {
131 const { exitCode, stdout } = await $.process.run(['claude', 'plugin', 'list', '--json'])
132 if (exitCode !== 0) {
133 return
134 }
135 listed = JSON.parse(stdout) as ListedPlugin[]
136 } catch {
137 return
138 }
139
140 const mods: ModToolsMod[] = []
141 for (const plugin of listed) {
142 if (plugin.id !== SELF_ID && (await isMod($, plugin.installPath))) {
143 mods.push({ id: plugin.id, name: modName(plugin.id), enabled: plugin.enabled })
144 }
145 }
146 mods.sort((a, b) => a.name.localeCompare(b.name))
147 await update($, modsAtom, () => mods)
148}
149
150/** Enables or disables each plugin, then reloads once. Returns false if a switch failed (its error is toasted). */
151async function switchPlugins($: Engine, ids: readonly string[], isOn: boolean): Promise<boolean> {
152 let isOk = true
153 for (const id of ids) {
154 const { exitCode, stderr, stdout } = await $.process.run(['claude', 'plugin', isOn ? 'enable' : 'disable', id, '--scope', 'user'])
155 if (exitCode !== 0) {
156 isOk = false
157 $.ui.toast(`${modName(id)}: ${(stderr || stdout).trim().split('\n')[0] ?? 'could not switch'}`)
158 }
159 }
160 if (ids.length > 0) {
161 await refreshMods($)
162 await $.command.run({ command: 'reload-plugins', args: '' })
163 }
164
165 return isOk
166}
167
168async function setMod($: Engine, mod: ModToolsMod, isOn: boolean) {
169 if (mod.id === CLEAN_VIEW_ID && mod.enabled) {
170 await setCleanView($, isOn)
171 return
172 }
173 if (await switchPlugins($, [mod.id], isOn)) {
174 $.ui.toast(`${mod.name}: ${isOn ? 'On' : 'Off'}`)
175 }
176}
177
178async function setAllMods($: Engine, isOn: boolean) {
179 const mods = await read($, modsAtom)
180 const cleanView = mods.find(mod => mod.id === CLEAN_VIEW_ID)
181 // Clean View switches by /simple; it is only enabled as a plugin when All on finds it disabled.
182 const ids = mods
183 .filter(mod => mod.enabled !== isOn && (mod.id !== CLEAN_VIEW_ID || isOn))
184 .map(mod => mod.id)
185 if (cleanView?.enabled && !isOn) {
186 await setCleanView($, false)
187 }
188 await switchPlugins($, ids, isOn)
189 if (cleanView !== undefined && isOn) {
190 await setCleanView($, true)
191 }
192 $.ui.toast(isOn ? 'All mods on' : 'All mods off')
193}
194
195/** Finds which offered model a model name (an id, an alias, a /config value) is. */
196export function modelFor(name: string | null): ModelChoice | null {
197 if (name === null) {
198 return null
199 }
200 const lower = name.toLowerCase()
201
202 return MODELS.find(model => lower.includes(model.family)) ?? null
203}
204
205export function effortFor(name: string | null): EffortChoice | null {
206 if (name === null) {
207 return null
208 }
209 const lower = name.toLowerCase()
210
211 return EFFORTS.find(effort => effort.value === lower) ?? null
212}
213
214function findRow(rows: readonly ConfigRow[], name: 'model' | 'effort'): ConfigRow | undefined {
215 const matches = (text: string) => (name === 'model' ? /^model$/i.test(text) : /effort/i.test(text))
216
217 return rows.find(row => row.provider.plugin === 'engine' && (matches(row.key) || matches(row.label)))
218}
219
220function asText(value: unknown): string | null {
221 return typeof value === 'string' && value !== '' ? value : null
222}
223
224function asRecord(value: unknown): Record<string, unknown> {
225 return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {}
226}
227
228/** The effort settings hold for a model: its own `modelSettings` entry, else the global `effortLevel`. */
229function savedEffort(settings: Record<string, unknown>, model: string | null): string | null {
230 const choice = modelFor(model)
231 if (choice !== null) {
232 const perModel = asRecord(settings.modelSettings)
233 const bareId = choice.id.replace('[1m]', '')
234 const key = bareId in perModel ? bareId : Object.keys(perModel).find(name => modelFor(name)?.family === choice.family)
235 const effort = key === undefined ? null : asText(asRecord(perModel[key]).effortLevel)
236 if (effort !== null) {
237 return effort
238 }
239 }
240
241 return asText(settings.effortLevel)
242}
243
244async function readSettings($: Engine): Promise<Record<string, unknown>> {
245 try {
246 return asRecord(await $.settings.read())
247 } catch {
248 return {}
249 }
250}
251
252/** Reads the session's model from `/config` (or settings) and its effort from `/config` or settings. */
253async function refresh($: Engine) {
254 let model: string | null = null
255 let effort: string | null = null
256 try {
257 const rows = await $.config.list()
258 model = asText(findRow(rows, 'model')?.value)
259 effort = asText(findRow(rows, 'effort')?.value)
260 } catch {
261 // The settings below are the fallback.
262 }
263
264 const settings = await readSettings($)
265 model ??= asText(settings.model)
266 effort ??= savedEffort(settings, model)
267
268 if (model !== null) {
269 await update($, modelAtom, () => model)
270 }
271 if (effort !== null) {
272 await update($, effortAtom, () => effort)
273 }
274}
275
276async function chooseModel($: Engine, model: ModelChoice) {
277 await update($, modelAtom, () => model.id)
278 await $.command.run({ command: 'model', args: model.id })
279 const effort = savedEffort(await readSettings($), model.id)
280 if (effort !== null) {
281 await update($, effortAtom, () => effort)
282 }
283 $.ui.toast(`Model: ${model.label}`)
284}
285
286/** Sets effort through its `/config` row where this build has one, else `/effort`. */
287async function chooseEffort($: Engine, effort: EffortChoice) {
288 await update($, effortAtom, () => effort.value)
289 const row = findRow(await $.config.list(), 'effort')
290 const fits = row !== undefined && !row.isLocked && (row.kind === 'text' || (row.options ?? []).includes(effort.value))
291 const answer = fits ? await $.config.set({ key: row.key, value: effort.value }).catch(() => undefined) : undefined
292 if (answer === undefined || answer.deny !== undefined) {
293 await $.command.run({ command: 'effort', args: effort.value })
294 }
295 $.ui.toast(`Effort: ${effort.label}`)
296}
297
298async function setCleanView($: Engine, isOn: boolean) {
299 await $.command.run({ command: 'simple', args: isOn ? 'on' : 'off' })
300}
301
302async function setOpen($: Engine, isOpen: boolean) {
303 await update($, isOpenAtom, () => isOpen)
304 if (isOpen) {
305 await refresh($)
306 await refreshMods($)
307 }
308}
309
310async function toggleFromCommand($: Engine) {
311 const isOpen = !(await read($, isOpenAtom))
312 await setOpen($, isOpen)
313
314 return isOpen ? 'Mod Tools is open above the prompt.' : 'Mod Tools is closed.'
315}
316
317export function registerModTools(on: On) {
318 on('session.start', async ($, e, next) => {
319 try {
320 await $.command.register({ name: COMMAND, description: 'Open or close Mod Tools' })
321 } catch {
322 await $.command.register({ name: FALLBACK_COMMAND, description: 'Open or close Mod Tools' })
323 }
324
325 await refresh($)
326 await refreshMods($)
327
328 return next(e)
329 })
330
331 on('command.run', { command: 'tools' }, async $ => ({ text: await toggleFromCommand($) }))
332 on('command.run', { command: 'mod-tools' }, async $ => ({ text: await toggleFromCommand($) }))
333
334 on('turn.start', async ($, e, next) => {
335 if (await read($, isOpenAtom)) {
336 await refresh($)
337 }
338
339 return next(e)
340 })
341
342 on('ui.render', { component: 'SessionMode' }, async ($, e) => {
343 const { Box, Text, Button } = $.ui.resolve(e)
344 const isOpen = await read($, isOpenAtom)
345
346 return (
347 <Box flexDirection="row" gap={2}>
348 {e.props.modes.length > 0 && <Text dimColor>{e.props.modes.join(' & ')}</Text>}
349 <Button
350 key="open"
351 plain
352 label={`◆ ${FOOTER_LABEL} ${isOpen ? '▴' : '▾'}`}
353 onPress={() => setOpen($, !isOpen)}
354 />
355 </Box>
356 )
357 })
358
359 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
360 if (e.props.hasSurvey || !(await read($, isOpenAtom))) {
361 return next(e)
362 }
363
364 const { Box, Text, Button } = $.ui.resolve(e)
365 const below = await next(e)
366 const width = Math.max(32, e.props.bodyColumns)
367 const model = modelFor(await read($, modelAtom))
368 const effort = effortFor(await read($, effortAtom))
369 const hasEffort = model === null || model.hasEffort
370 const isCleanViewOn = await read($, cleanViewAtom)
371 const mods = await read($, modsAtom)
372 const paneOpen = await readPaneOpen($)
373 // A mod reads On when its plugin is enabled; Clean View also needs /simple on.
374 const isOn = (mod: ModToolsMod) => mod.enabled && (mod.id !== CLEAN_VIEW_ID || isCleanViewOn)
375 const summary = [model?.label ?? 'Default model', hasEffort ? (effort?.label ?? 'Default effort') : null]
376 .filter(part => part !== null)
377 .join(' · ')
378
379 const modelRow = MODELS.map(choice =>
380 choice.family === model?.family ? (
381 <Text backgroundColor="claude" color="inverseText" bold>
382 {` ${choice.label} `}
383 </Text>
384 ) : (
385 <Button key={`model:${choice.family}`} plain label={` ${choice.label} `} onPress={() => chooseModel($, choice)} />
386 ),
387 )
388
389 const effortRow = hasEffort ? (
390 EFFORTS.map(choice =>
391 choice.value === effort?.value ? (
392 <Text backgroundColor="permission" color="inverseText" bold>
393 {` ${choice.label} `}
394 </Text>
395 ) : (
396 <Button key={`effort:${choice.value}`} plain label={` ${choice.label} `} onPress={() => chooseEffort($, choice)} />
397 ),
398 )
399 ) : (
400 <Text dimColor> {model?.label} doesn't use an effort setting</Text>
401 )
402
403 const modRow = (mod: ModToolsMod) => {
404 const isModOn = isOn(mod)
405 const caption = CAPTIONS[baseName(mod.id)]
406 const hasPane = mod.enabled && PANES[baseName(mod.id)] !== undefined
407 const isShown = paneOpen[baseName(mod.id)] === true
408
409 return (
410 <Box key={`row:${mod.id}`} flexDirection="row" justifyContent="space-between" gap={2}>
411 <Box flexDirection="row" gap={1} flexShrink={1}>
412 <Text color={isModOn ? 'success' : 'inactive'}>{isModOn ? '●' : '○'}</Text>
413 <Text bold>{mod.name}</Text>
414 {caption !== undefined && (
415 <Text dimColor wrap="truncate-end">
416 {caption}
417 </Text>
418 )}
419 </Box>
420 <Box flexDirection="row" flexShrink={0}>
421 <Box width={SHOW_CELLS} flexShrink={0}>
422 {hasPane &&
423 (isShown ? (
424 <Box backgroundColor="success">
425 <Button key={`show:${mod.id}`} plain label=" ● Shown " onPress={() => setShown($, mod, false)} />
426 </Box>
427 ) : (
428 <Button key={`show:${mod.id}`} plain label=" ○ Hidden " onPress={() => setShown($, mod, true)} />
429 ))}
430 </Box>
431 <Box width={ENABLED_CELLS} flexShrink={0}>
432 {isModOn ? (
433 <Box backgroundColor="success">
434 <Button key={`mod:${mod.id}`} plain label=" ● On " onPress={() => setMod($, mod, false)} />
435 </Box>
436 ) : (
437 <Button key={`mod:${mod.id}`} plain label=" ○ Off " onPress={() => setMod($, mod, true)} />
438 )}
439 </Box>
440 </Box>
441 </Box>
442 )
443 }
444
445 const panel = (
446 <Box flexDirection="column" width={width}>
447 <Box flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={1}>
448 <Box flexDirection="row" justifyContent="space-between" gap={2}>
449 <Text color="claude" bold wrap="truncate-end">
450 {TITLE}
451 </Text>
452 <Text wrap="truncate-end">{summary}</Text>
453 </Box>
454
455 <Box flexDirection="row" marginTop={1}>
456 <Box width={LABEL_CELLS} flexShrink={0}>
457 <Text dimColor>MODEL</Text>
458 </Box>
459 <Box flexDirection="row" flexWrap="wrap" flexShrink={1}>
460 {modelRow}
461 </Box>
462 </Box>
463 <Box flexDirection="row">
464 <Box width={LABEL_CELLS} flexShrink={0}>
465 <Text dimColor>EFFORT</Text>
466 </Box>
467 <Box flexDirection="row" flexWrap="wrap" flexShrink={1}>
468 {effortRow}
469 </Box>
470 </Box>
471
472 <Box key="heading:mods" flexDirection="row" justifyContent="space-between" gap={2} marginTop={1} marginBottom={1}>
473 <Box flexDirection="row" gap={2} paddingLeft={2} flexShrink={1}>
474 <Text dimColor>{MODS_HEADING}</Text>
475 {mods.length > 0 && (
476 <Box flexDirection="row" gap={1}>
477 <Button key="mods:allOn" plain label=" All on " onPress={() => setAllMods($, true)} />
478 <Button key="mods:allOff" plain label=" All off " onPress={() => setAllMods($, false)} />
479 </Box>
480 )}
481 </Box>
482 {mods.length > 0 && (
483 <Box flexDirection="row" flexShrink={0}>
484 <Box width={SHOW_CELLS} flexShrink={0}>
485 <Text dimColor> Show</Text>
486 </Box>
487 <Box width={ENABLED_CELLS} flexShrink={0}>
488 <Text dimColor> Enabled</Text>
489 </Box>
490 </Box>
491 )}
492 </Box>
493 {mods.length > 0 ? mods.map(modRow) : <Text dimColor>No other mods installed</Text>}
494 </Box>
495 <Text dimColor wrap="truncate-end">
496 {'─'.repeat(Math.max(0, width - RULE_LABEL.length))}
497 {RULE_LABEL}
498 </Text>
499 </Box>
500 )
501
502 // No size props on this wrapper: the engine refuses its own band node under a sized Box.
503 return (
504 <Box flexDirection="column">
505 {panel}
506 {below}
507 </Box>
508 )
509 })
510}
511types/index.d.ts 18 lines1export type ModToolsModel = string | null
2
3export type ModToolsEffort = string | null
4
5/** An installed mod the panel can switch: its plugin id, display name and whether the plugin is enabled. */
6export type ModToolsMod = { id: string; name: string; enabled: boolean }
7
8declare module 'claude-code' {
9 interface PluginState {
10 'mod-tools': {
11 isOpen: boolean
12 model: ModToolsModel
13 effort: ModToolsEffort
14 mods: ModToolsMod[]
15 }
16 }
17}
18