Toolbox: an orange button that opens a panel to switch model and effort, with rows other mods can add.

Public Claude Code plugins and mods by schmas.
| Plugin | Scope | Contents | ||
|---|---|---|---|---|
clean-view | Calm view for non-technical users (mod) | Hides tool calls, diffs and command output; shows one plain-English checklist with progress meters above the prompt. Three modes: on hides details, both shows details and the checklist, off. Switch with the band button or `/simple on\ | both\ | off`. With Toolbox installed, the switch is a row in the Toolbox panel |
toolbox | Model and effort switcher (mod) | ◆ Toolbox button sits in the prompt footer, next to the mode labels, and opens a panel last above the prompt. A SETTINGS row moves the button above the prompt too. One click sets the session model or effort. Other mods add rows to it through $.toolbox. Toggle with the button or /toolbox |
Add the marketplace once, then install the plugins you want.
/plugin marketplace add schmas/claude-plugins
/plugin install clean-view@schmas
/plugin install toolbox@schmas
/reload-plugins
claude-plugins/
├── .claude-plugin/
│ └── marketplace.json # Lists 2 plugins
├── plugins/
│ ├── clean-view/
│ │ ├── .claude-plugin/plugin.json
│ │ ├── hooks/ # Function-hook mod + tests
│ │ └── types/ # Shared checklist state contract
│ └── toolbox/
│ ├── .claude-plugin/plugin.json
│ ├── hooks/ # Function-hook mod + tests
│ └── types/ # $.toolbox add-on API + state contract
└── README.md
MIT. See LICENSE.
hooks/register.tsx 8 lines1import type { Register } from 'claude-code'
2
3import { registerPanel } from './panel'
4
5export const register: Register = on => {
6 registerPanel(on)
7}
8hooks/panel.tsx 390 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, RenderElement, RenderInput } from 'claude-code'
3
4import type { ToolboxEffort, ToolboxLaunch, ToolboxPosition, ToolboxSetting } from '../types'
5import {
6 DEFAULT_POSITION,
7 EFFORTS,
8 POSITION_SETTING_ID,
9 activeModel,
10 buttonLabel,
11 controlText,
12 isEffort,
13 isOn,
14 isPosition,
15 modelChoices,
16 nextValue,
17 parseEffortArg,
18 positionFromLabel,
19 positionSetting,
20 spaced,
21 summary,
22 upsert,
23} from './panel-logic'
24import type { EffortChoice, ModelChoice } from './panel-logic'
25
26type Engine = EngineInterface
27
28const ORANGE = '#E07B39'
29const PURPLE = '#7C5CD6'
30const GREEN = '#2E9E5B'
31const GRAY = '#555555'
32/** The rule under the title: darker than dim text, like a shadow. */
33const SHADOW = '#3A3A3A'
34const STORE_KEY = 'isOpen'
35const EFFORT_STORE_KEY = 'effort'
36const POSITION_STORE_KEY = 'position'
37const LABEL_WIDTH = 10
38const PANEL_WIDTH = 78
39
40const IS_OPEN = { plugin: 'toolbox', key: 'isOpen' } as const
41const EFFORT = { plugin: 'toolbox', key: 'effort' } as const
42const isOpen = atom(IS_OPEN, false)
43const effort = atom(EFFORT, null as ToolboxEffort | null)
44const POSITION = { plugin: 'toolbox', key: 'position' } as const
45const position = atom(POSITION, DEFAULT_POSITION as ToolboxPosition)
46const sync = atom({ plugin: 'toolbox', key: 'sync' } as const, 0)
47const press = atom({ plugin: 'toolbox', key: 'press' } as const, null)
48
49/**
50 * Rows other mods add through `$.toolbox`. The noun's methods have no `$` to
51 * write state with, so the rows live here; a hot reload of Toolbox drops them
52 * until each add-on sends its row again.
53 */
54let settings: ToolboxSetting[] = []
55let launches: ToolboxLaunch[] = []
56
57async function redraw($: Engine): Promise<void> {
58 await update($, sync, n => (n ?? 0) + 1)
59}
60
61// A /clear starts the session's state over, and no event this mod can count on
62// fires after it. So a value not written yet this session comes from the store.
63
64async function openNow($: Engine): Promise<boolean> {
65 const { value } = await $.state.get(IS_OPEN)
66
67 return value === undefined ? (await $.store.get(STORE_KEY)) === true : value
68}
69
70async function effortNow($: Engine): Promise<ToolboxEffort | null> {
71 const { value } = await $.state.get(EFFORT)
72
73 if (value !== undefined && value !== null) {
74 return value
75 }
76
77 const stored = await $.store.get(EFFORT_STORE_KEY)
78
79 return isEffort(stored) ? stored : null
80}
81
82async function positionNow($: Engine): Promise<ToolboxPosition> {
83 const { value } = await $.state.get(POSITION)
84
85 if (value !== undefined) {
86 return value
87 }
88
89 const stored = await $.store.get(POSITION_STORE_KEY)
90
91 return isPosition(stored) ? stored : DEFAULT_POSITION
92}
93
94async function setPosition($: Engine, value: ToolboxPosition): Promise<void> {
95 await update($, position, () => value)
96 await $.store.set(POSITION_STORE_KEY, value)
97}
98
99async function setOpen($: Engine, value: boolean): Promise<void> {
100 await update($, isOpen, () => value)
101 await $.store.set(STORE_KEY, value)
102}
103
104async function seeEffort($: Engine, level: ToolboxEffort | null): Promise<void> {
105 if (level !== null && (await read($, effort)) !== level) {
106 await update($, effort, () => level)
107 await $.store.set(EFFORT_STORE_KEY, level)
108 }
109}
110
111async function chooseModel($: Engine, choice: ModelChoice): Promise<void> {
112 const result = await $.config.set({ key: 'model', value: choice.alias })
113 await redraw($)
114
115 if ('deny' in result) {
116 $.ui.toast(`Model did not change: ${String(result.deny)}`)
117
118 return
119 }
120
121 $.ui.toast(`Model changed to ${choice.label}`)
122}
123
124async function chooseEffort($: Engine, choice: EffortChoice): Promise<void> {
125 await $.command.run({ command: 'effort', args: choice.level })
126 await seeEffort($, choice.level)
127 $.ui.toast(`Effort changed to ${choice.label}`)
128}
129
130/** Tells the add-on that owns `id`: it hooks `state.set` on `toolbox.press`. */
131async function pressAddon($: Engine, id: string, value: ToolboxSetting['value'] | null): Promise<void> {
132 await update($, press, last => ({ id, value, count: (last?.count ?? 0) + 1 }))
133 await redraw($)
134}
135
136async function drawButton($: Engine, e: RenderInput<'AbovePrompt' | 'SessionMode'>): Promise<RenderElement> {
137 const { Button } = $.ui.resolve(e)
138 const open = await openNow($)
139
140 return <Button key="toolbox" plain label={buttonLabel(open)} onPress={() => setOpen($, !open)} />
141}
142
143async function drawPanel($: Engine, e: RenderInput<'AbovePrompt'>): Promise<RenderElement> {
144 const { Box, Text, Button } = $.ui.resolve(e)
145 await read($, sync)
146 const level = await effortNow($)
147 const rows = await $.config.list()
148 const modelId = await $.session.model()
149 const models = modelChoices(rows.find(row => row.key === 'model'))
150 const model = activeModel(models, modelId)
151 const width = Math.max(30, Math.min(PANEL_WIDTH, e.props.bodyColumns))
152 // border (2) + padding (2)
153 const inner = width - 4
154
155 const chip = (key: string, label: string, isActive: boolean, color: string, onPress: () => Promise<void>) =>
156 isActive ? (
157 <Box key={`${key}-chip`} backgroundColor={color} marginRight={1}>
158 <Button key={key} plain label={` ${label} `} onPress={onPress} />
159 </Box>
160 ) : (
161 <Box key={`${key}-chip`} marginRight={1}>
162 <Button key={key} plain label={` ${label} `} onPress={onPress} />
163 </Box>
164 )
165
166 const line = (key: string, name: string, children: RenderElement[]) => (
167 <Box key={key} flexDirection="row" flexWrap="wrap">
168 <Text dimColor>{name.padEnd(LABEL_WIDTH)}</Text>
169 {children}
170 </Box>
171 )
172
173 // A blank row above and below the title, as between the header and MODEL.
174 const section = (key: string, title: string) => (
175 <Box key={key} flexDirection="row" marginTop={1} marginBottom={1}>
176 <Text dimColor>─ {spaced(title)} ─</Text>
177 </Box>
178 )
179
180 const settingRows: RenderElement[] = []
181
182 for (const setting of [positionSetting(await positionNow($)), ...settings]) {
183 const { value } = setting
184 const onPress = () =>
185 setting.id === POSITION_SETTING_ID
186 ? setPosition($, positionFromLabel(nextValue(setting, value)))
187 : pressAddon($, setting.id, nextValue(setting, value))
188 const control =
189 setting.kind === 'toggle' && value !== true ? (
190 <Button key={`set-${setting.id}`} plain dimColor label={` ${controlText(setting, value)} `} onPress={onPress} />
191 ) : (
192 <Box key={`set-${setting.id}-chip`} backgroundColor={setting.kind === 'toggle' ? GREEN : GRAY}>
193 <Button key={`set-${setting.id}`} plain label={` ${controlText(setting, value)} `} onPress={onPress} />
194 </Box>
195 )
196
197 settingRows.push(
198 <Box key={`row-${setting.id}`} flexDirection="row" justifyContent="space-between">
199 <Text wrap="truncate">
200 {isOn(value) ? <Text color={GREEN}>●</Text> : <Text dimColor>○</Text>} {setting.label}
201 {setting.hint === undefined ? '' : <Text dimColor> {setting.hint}</Text>}
202 </Text>
203 {control}
204 </Box>,
205 )
206 }
207
208 const launchButtons = launches.map(launch =>
209 chip(`launch-${launch.id}`, `${launch.icon ?? '◆'} ${launch.label}`, false, ORANGE, () => pressAddon($, launch.id, null)),
210 )
211
212 return (
213 <Box key="toolbox-panel" flexDirection="column" borderStyle="round" borderColor={ORANGE} paddingLeft={1} paddingRight={1} width={width}>
214 <Box flexDirection="row" justifyContent="space-between">
215 <Text bold color={ORANGE}>
216 ◆ {spaced('Toolbox')}
217 </Text>
218 <Box flexDirection="row">
219 <Text key="summary" dimColor>
220 {summary(model, modelId, level)}{' '}
221 </Text>
222 <Button key="close" label="−" role="dismiss" onPress={() => setOpen($, false)} />
223 </Box>
224 </Box>
225 <Text color={SHADOW}>{'─'.repeat(inner)}</Text>
226 <Text> </Text>
227 {line(
228 'models',
229 'MODEL',
230 models.map(choice =>
231 chip(`model-${choice.alias}`, choice.label, choice === model, ORANGE, () => chooseModel($, choice)),
232 ),
233 )}
234 {line(
235 'efforts',
236 'EFFORT',
237 EFFORTS.map(choice => chip(`effort-${choice.level}`, choice.label, choice.level === level, PURPLE, () => chooseEffort($, choice))),
238 )}
239 {settingRows.length > 0 ? section('settings-title', 'Settings') : null}
240 {settingRows}
241 {launchButtons.length > 0 ? section('launch-title', 'Launch') : null}
242 {launchButtons.length > 0 ? (
243 <Box key="launches" flexDirection="row" flexWrap="wrap">
244 {launchButtons}
245 </Box>
246 ) : null}
247 </Box>
248 )
249}
250
251export function registerPanel(on: On): void {
252 on('engine.create', async ($, e, next) => {
253 const built = await next(e)
254
255 return {
256 ...built,
257 toolbox: {
258 addSetting: (setting: ToolboxSetting) => {
259 settings = upsert(settings, setting)
260 },
261 addLaunch: (launch: ToolboxLaunch) => {
262 launches = upsert(launches, launch)
263 },
264 },
265 }
266 })
267
268 on('session.start', async ($, e, next) => {
269 await $.command.register({ name: 'toolbox', description: 'Open or close the Toolbox panel' })
270 const stored = await $.store.get(STORE_KEY)
271 await update($, isOpen, () => stored === true)
272 const storedPosition = await $.store.get(POSITION_STORE_KEY)
273 await update($, position, () => (isPosition(storedPosition) ? storedPosition : DEFAULT_POSITION))
274 const storedEffort = await $.store.get(EFFORT_STORE_KEY)
275
276 if ((await read($, effort)) === null && isEffort(storedEffort)) {
277 await update($, effort, () => storedEffort)
278 }
279
280 const result = await next(e)
281 // Mods beneath this one add their rows during next(e); draw them.
282 await redraw($)
283
284 return result
285 })
286
287 on('command.run', { command: 'toolbox' }, ($, e) => {
288 try {
289 void (async () => {
290 try {
291 await setOpen($, !(await openNow($)))
292 } catch (error) {
293 $.ui.toast(`Toolbox could not toggle: ${String(error)}`)
294 }
295 })()
296
297 return { text: 'Toolbox panel toggled.' }
298 } catch (error) {
299 return { text: `Toolbox could not toggle: ${String(error)}` }
300 }
301 })
302
303 // /model, /effort and other mods' commands change what the panel shows.
304 on('command.run', async ($, e, next) => {
305 const result = await next(e)
306
307 if (e.command === 'effort') {
308 await seeEffort($, parseEffortArg(e.args))
309 }
310
311 if (e.command !== 'toolbox') {
312 await redraw($)
313 }
314
315 return result
316 })
317
318 on('config.set', async ($, e, next) => {
319 const result = await next(e)
320 await redraw($)
321
322 return result
323 })
324
325 on('turn.step', async function* ($, e, next) {
326 if (e.agentId === undefined && typeof e.effort === 'string') {
327 await seeEffort($, e.effort)
328 }
329
330 return yield* next(e)
331 })
332
333 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
334 if ((await positionNow($)) !== 'SessionMode') {
335 return next(e)
336 }
337
338 const { Box, Text } = $.ui.resolve(e)
339 const beneath = await next(e)
340
341 // A plain label in `modes` cannot be pressed, so the button sits after the
342 // engine's own labels, joined the way the engine joins them.
343 return (
344 <Box flexDirection="row">
345 {beneath}
346 {e.props.modes.length > 0 ? <Text dimColor> & </Text> : null}
347 {await drawButton($, e)}
348 </Box>
349 )
350 })
351
352 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
353 if (e.props.hasSurvey) {
354 return next(e)
355 }
356
357 const open = await openNow($)
358 const hasButton = (await positionNow($)) === 'AbovePrompt'
359
360 if (!open && !hasButton) {
361 return next(e)
362 }
363
364 const { Box } = $.ui.resolve(e)
365 // Mods beneath draw first: an add-on may send its row while it draws.
366 const beneath = await next(e)
367 const panel = open ? await drawPanel($, e) : null
368
369 // The band cannot paint over the transcript, so the panel and the button
370 // take their own rows at the right end, last, next to the prompt.
371 // Only those rows have a set width: the engine refuses its own node
372 // under a Box with one, and `beneath` may hold that node.
373 return (
374 <Box flexDirection="column">
375 {beneath}
376 {panel === null ? null : (
377 <Box key="panel-row" flexDirection="row" justifyContent="flex-end" width={e.props.bodyColumns}>
378 {panel}
379 </Box>
380 )}
381 {hasButton ? (
382 <Box key="button-row" flexDirection="row" justifyContent="flex-end" width={e.props.bodyColumns}>
383 {await drawButton($, e)}
384 </Box>
385 ) : null}
386 </Box>
387 )
388 })
389}
390hooks/panel-logic.ts 162 lines1import type { ToolboxEffort, ToolboxPosition, ToolboxSetting, ToolboxSettingValue } from '../types'
2
3export type ModelChoice = { alias: string; label: string }
4
5export type EffortChoice = { level: ToolboxEffort; label: string }
6
7export type ModelRow = { options?: readonly string[] } | undefined
8
9export const EFFORTS: readonly EffortChoice[] = [
10 { level: 'low', label: 'Low' },
11 { level: 'medium', label: 'Medium' },
12 { level: 'high', label: 'High' },
13 { level: 'xhigh', label: 'XHigh' },
14 { level: 'max', label: 'Max' },
15]
16
17/** Used only when Claude Code lists no model options. */
18export const FALLBACK_MODELS: readonly ModelChoice[] = [
19 { alias: 'haiku', label: 'Haiku 4.5' },
20 { alias: 'sonnet', label: 'Sonnet 5.5' },
21 { alias: 'opus', label: 'Opus 5.5' },
22 { alias: 'fable', label: 'Fable 5.1' },
23]
24
25const KNOWN_LABELS: Record<string, string> = Object.fromEntries(FALLBACK_MODELS.map(one => [one.alias, one.label]))
26
27/** Options of the `/model` row that are not one plain model. */
28const NOT_A_MODEL = new Set(['default', 'best', 'opusplan'])
29
30function capitalize(text: string): string {
31 return text === '' ? text : text[0]!.toUpperCase() + text.slice(1)
32}
33
34/** The model chips: the `/model` row's plain model options, with friendly labels. */
35export function modelChoices(row: ModelRow): ModelChoice[] {
36 const aliases = (row?.options ?? []).filter(option => !NOT_A_MODEL.has(option) && !option.includes('['))
37
38 if (aliases.length === 0) {
39 return [...FALLBACK_MODELS]
40 }
41
42 // Known models from small to large, then any others in Claude Code's order.
43 const rank = (alias: string) => {
44 const at = FALLBACK_MODELS.findIndex(one => one.alias === alias)
45
46 return at === -1 ? FALLBACK_MODELS.length : at
47 }
48
49 return [...aliases]
50 .sort((a, b) => rank(a) - rank(b))
51 .map(alias => ({ alias, label: KNOWN_LABELS[alias] ?? capitalize(alias) }))
52}
53
54/** The chip that matches the session's model id (`claude-opus-5-5` matches `opus`). */
55export function activeModel(choices: readonly ModelChoice[], modelId: string): ModelChoice | null {
56 const id = modelId.toLowerCase()
57
58 return choices.find(choice => id === choice.alias || id.includes(`-${choice.alias}-`) || id.startsWith(`${choice.alias}-`)) ?? null
59}
60
61export function effortLabel(level: ToolboxEffort): string {
62 return EFFORTS.find(one => one.level === level)?.label ?? level
63}
64
65export function isEffort(value: unknown): value is ToolboxEffort {
66 return EFFORTS.some(one => one.level === value)
67}
68
69/** The effort an `/effort <arg>` sets, or null when the argument names no level. */
70export function parseEffortArg(args: string): ToolboxEffort | null {
71 const arg = args.trim().toLowerCase()
72
73 return isEffort(arg) ? arg : null
74}
75
76/** The dim summary at the top right: `Opus 5.5 · Max`. */
77export function summary(model: ModelChoice | null, modelId: string, effort: ToolboxEffort | null): string {
78 const name = model?.label ?? modelId
79
80 return effort === null ? name : `${name} · ${effortLabel(effort)}`
81}
82
83/** `TOOLBOX` → `T O O L B O X`. */
84export function spaced(text: string): string {
85 return text.toUpperCase().split('').join(' ')
86}
87
88/** The value a press moves a setting to. */
89export function nextValue(setting: Pick<ToolboxSetting, 'kind' | 'options'>, current: ToolboxSettingValue): ToolboxSettingValue {
90 if (setting.kind === 'toggle') {
91 return current !== true
92 }
93
94 const options = setting.options ?? []
95
96 if (options.length === 0) {
97 return current
98 }
99
100 const at = options.indexOf(String(current))
101
102 return options[(at + 1) % options.length]!
103}
104
105/** A setting is on unless it is false or reads `off`. */
106export function isOn(value: ToolboxSettingValue): boolean {
107 return typeof value === 'boolean' ? value : value.trim().toLowerCase() !== 'off'
108}
109
110/** What the control at the right end of a setting row reads. */
111export function controlText(setting: Pick<ToolboxSetting, 'kind'>, value: ToolboxSettingValue): string {
112 if (setting.kind === 'toggle') {
113 return value === true ? '● On' : '○ Off'
114 }
115
116 return String(value)
117}
118
119export const DEFAULT_POSITION: ToolboxPosition = 'SessionMode'
120
121export const POSITION_SETTING_ID = 'toolbox.position'
122
123const POSITION_LABELS: Record<ToolboxPosition, string> = {
124 AbovePrompt: 'Above prompt',
125 SessionMode: 'In footer',
126}
127
128export function isPosition(value: unknown): value is ToolboxPosition {
129 return value === 'AbovePrompt' || value === 'SessionMode'
130}
131
132/** Toolbox's own row under SETTINGS: where the button sits. */
133export function positionSetting(position: ToolboxPosition): ToolboxSetting {
134 return {
135 id: POSITION_SETTING_ID,
136 label: 'Toolbox button',
137 hint: 'where it sits',
138 kind: 'choice',
139 options: Object.values(POSITION_LABELS),
140 value: POSITION_LABELS[position],
141 }
142}
143
144/** The position a row label like `Under prompt` names. */
145export function positionFromLabel(label: ToolboxSettingValue): ToolboxPosition {
146 const found = (Object.keys(POSITION_LABELS) as ToolboxPosition[]).find(one => POSITION_LABELS[one] === label)
147
148 return found ?? DEFAULT_POSITION
149}
150
151export function buttonLabel(isOpen: boolean): string {
152 return isOpen ? ' ◆ Toolbox ▲ ' : ' ◆ Toolbox ▼ '
153}
154
155/** Puts a new entry in place of one with the same id, or at the end. */
156export function upsert<T extends { id: string }>(list: readonly T[], entry: T): T[] {
157 const at = list.findIndex(one => one.id === entry.id)
158
159 return at === -1 ? [...list, entry] : list.map((one, index) => (index === at ? entry : one))
160}
161
162types/index.d.ts 78 lines1/** A reasoning effort level, as `/effort` takes it. */
2export type ToolboxEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
3
4/** Where the Toolbox button sits: the band above the prompt, or the mode labels in the prompt footer. */
5export type ToolboxPosition = 'AbovePrompt' | 'SessionMode'
6
7export type ToolboxSettingValue = boolean | string
8
9/**
10 * A row another mod adds under SETTINGS. Plain data: a mod sends it again
11 * with the new `value` each time the value changes.
12 */
13export type ToolboxSetting = {
14 /** Unique across mods, led by the mod's name, like `clean-view.mode`. */
15 id: string
16 label: string
17 /** Short dim text after the label. */
18 hint?: string
19 /** `toggle` flips a boolean; `choice` steps through `options`. */
20 kind: 'toggle' | 'choice'
21 /** The values a `choice` row steps through, in order. */
22 options?: readonly string[]
23 /** The current value. */
24 value: ToolboxSettingValue
25}
26
27/** A button another mod adds under LAUNCH. Plain data. */
28export type ToolboxLaunch = {
29 /** Unique across mods, led by the mod's name. */
30 id: string
31 label: string
32 /** One glyph before the label, like `✦`. */
33 icon?: string
34}
35
36/**
37 * What Toolbox writes to its `press` state when the person clicks an add-on
38 * row or button. The add-on hooks `state.set` on
39 * `{ plugin: 'toolbox', key: 'press' }` and acts on its own `id`.
40 */
41export type ToolboxPress = {
42 id: string
43 /** The value a setting row moves to; null for a LAUNCH button. */
44 value: ToolboxSettingValue | null
45 /** Counts presses, so two presses of one row are two writes. */
46 count: number
47}
48
49/**
50 * What Toolbox adds to `$`. Arguments cross between mods as plain data, so
51 * they hold no functions. A second add with the same id replaces the first.
52 */
53export type Toolbox = {
54 addSetting: (setting: ToolboxSetting) => void
55 addLaunch: (launch: ToolboxLaunch) => void
56}
57
58declare module 'claude-code' {
59 interface EngineInterface {
60 toolbox: Toolbox
61 }
62
63 interface PluginState {
64 toolbox: {
65 /** True while the panel shows above the prompt. */
66 isOpen: boolean
67 /** The main loop's effort as last seen; null until known. */
68 effort: ToolboxEffort | null
69 /** Where the button sits; the person picks it under SETTINGS. */
70 position: ToolboxPosition
71 /** Bumped to redraw after a change Toolbox cannot subscribe to. */
72 sync: number
73 /** The last add-on row or button pressed. */
74 press: ToolboxPress | null
75 }
76 }
77}
78