A button under the prompt that opens a pane to switch /model and /effort; add a setting by adding one control

Claude Code mods (function-hook plugins). Each folder is one plugin; .claude-plugin/marketplace.json makes this repo a marketplace.
| Mod | What it does |
|---|---|
cheap-worker | /worker spins up Claude Code workers from a picker; /workers lists, messages and closes them. Team mode lets workers find each other (ListAgents) and talk (SendMessage). |
agent-progress | /progress: animated progress bar per agent (main, subagents, workers) from their task lists. |
agent-map | /map: project file tree; red 🔨 = an agent is editing it, green ✓ = done, ready for review. |
context-bar | Context-window breakdown above the prompt. |
usage-meter | 5-hour / weekly usage in the footer. |
quick-settings | ⚙ footer button for model and effort. |
skins | /skins: colour themes (Tokyo Night, Dracula, Gruvbox, …). |
cw-pane, driver-seat-guard, evidence-band | For the driver-seat workflow; skip them elsewhere. |
/plugin marketplace add ianwilson97/claude-mods
/plugin install cheap-worker@ianwilson-mods
/plugin install agent-progress@ianwilson-mods
/plugin install agent-map@ianwilson-mods
On the machine where this folder is the live copy (loaded through CLAUDE_CODE_PLUGIN_DIRS), don't also install from the marketplace: each mod would load twice.
Set per machine in /config (or pluginConfigs.cheap-worker.options in ~/.claude/settings.json):
| Option | Values | |
|---|---|---|
provider | openrouter (default) | Any OpenRouter model; key read from $CW_OPENROUTER_API_KEY (export it in ~/.zshenv). Workers skip user settings, hooks and plugins. |
bedrock | Claude only (Sonnet / Opus / Haiku). Workers keep your user settings, so the Bedrock env there (CLAUDE_CODE_USE_BEDROCK, AWS_REGION, AWS_PROFILE, …) applies. No call to openrouter.ai. | |
launch | panes (default) | All workers tiled in tmux session cw: tmux new -A -s cw. |
tabs | Each worker gets a tmux session of its own and opens in a background tab: a cmux tab when Claude Code runs inside cmux, else a Ghostty tab (focus stays where you were). |
Work laptop: provider = bedrock, launch = tabs.
crewThe mod installs crew into ~/.local/bin (that folder must be on your PATH; it never overwrites a different crew).
crew # list running workers, pick one by number
crew ab12c # attach to cw-ab12c
If Ghostty can't be scripted (macOS asks once to let Claude control Ghostty; a managed Mac may refuse), the worker still runs: press CMD+T and run crew. Closing a tab only detaches; the worker keeps running until you close it from /workers.
Needs: tmux; for auto-opened tabs, cmux or Ghostty 1.3+.
/plugin lists the mods as enabled. Managed settings can block marketplaces or plugins; if so, nothing loads./worker, launch one with task say hi; a Ghostty tab opens (or the toast says CMD+T, then crew …)./workers send one a message: messaging is Claude Code's own (ListAgents / SendMessage) and works under Bedrock unless policy turns it off.hooks/register.tsx 112 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Settings } from '../types'
5import { CONTROLS } from './controls'
6import type { Ctx } from './controls'
7
8const settings = atom({ plugin: 'quick-settings', key: 'settings' } as const, {
9 lastEffort: null,
10 pendingEffort: null,
11 override: {},
12 flash: null,
13} as Settings)
14
15const PANE = 'quick-settings'
16const ACCENT = '#d97757'
17
18const open = ($: EngineInterface) => $.ui.open({ id: PANE, title: 'Quick settings' })
19
20function ctxOf($: EngineInterface): Ctx {
21 return {
22 sessionModel: () => $.session.model(),
23 run: (command, args) => $.command.run({ command, args }),
24 get: () => read($, settings),
25 set: fn => update($, settings, fn),
26 }
27}
28
29export const register: Register = on => {
30 on('session.start', async ($, e, next) => {
31 await $.command.register({ name: 'quick-settings', description: 'Open the model and effort switcher' })
32 return next(e)
33 })
34
35 on('command.run', { command: 'quick-settings' }, async $ => {
36 await open($)
37 return { text: 'Quick settings open.' }
38 })
39
40 // Watches the effort each main-thread request carries; applies an override
41 // only where a slash command didn't take.
42 on('turn.step', async function* ($, e, next) {
43 if (e.agentId !== undefined) return yield* next(e)
44 const s = await read($, settings)
45 const sent = e.effort === undefined ? null : String(e.effort)
46 if (s.pendingEffort !== null && sent !== s.pendingEffort && s.override.effort === undefined) {
47 const wanted = s.pendingEffort
48 await update($, settings, x => ({ ...x, override: { ...x.override, effort: wanted }, pendingEffort: null }))
49 $.ui.toast(`/effort didn't take; ${wanted} applied per request instead.`)
50 } else if (s.pendingEffort !== null) {
51 await update($, settings, x => ({ ...x, pendingEffort: null }))
52 }
53 const o = (await read($, settings)).override
54 const step = o.model || o.effort ? { ...e, ...(o.model && { model: o.model }), ...(o.effort && { effort: o.effort as never }) } : e
55 await update($, settings, x => ({ ...x, lastEffort: step.effort === undefined ? x.lastEffort : String(step.effort) }))
56 return yield* next(step)
57 })
58
59 // The footer button, bottom right, beside the engine's own hint line.
60 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
61 const below = await next(e)
62 const { Box, Button } = $.ui.resolve(e)
63 const ctx = ctxOf($)
64 const parts = await Promise.all(
65 CONTROLS.filter(c => c.short).map(async c => c.short?.(await c.current(ctx)) ?? ''),
66 )
67 return (
68 <Box justifyContent="space-between">
69 <Box flexGrow={1}>{below}</Box>
70 <Button key="qs-open" label={`⚙ ${parts.join(' · ')}`} plain dimColor onPress={() => open($)} />
71 </Box>
72 )
73 })
74
75 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
76 const { Box, Button, Text } = $.ui.resolve(e)
77 const s = await read($, settings)
78 const ctx = ctxOf($)
79 const rows = await Promise.all(CONTROLS.map(async c => ({ c, now: await c.current(ctx) })))
80
81 return (
82 <Box flexDirection="column" gap={1} paddingX={1}>
83 {rows.map(({ c, now }) => (
84 <Box key={`row-${c.id}`} flexDirection="column">
85 <Text bold color={ACCENT}>{c.label}</Text>
86 <Box flexWrap="wrap" columnGap={1}>
87 {c.options.map(o => (
88 <Button
89 key={`${c.id}-${o.value}`}
90 label={o.value === now ? `● ${o.label}` : `○ ${o.label}`}
91 variant={o.value === now ? 'primary' : 'secondary'}
92 onPress={async () => {
93 const line = await c.apply(ctxOf($), o.value)
94 await update($, settings, x => ({ ...x, flash: line }))
95 }}
96 />
97 ))}
98 </Box>
99 <Text dimColor>{c.options.find(o => o.value === now)?.note ?? ' '}</Text>
100 </Box>
101 ))}
102 {s.flash && <Text color="#57ab5a">✓ {s.flash}</Text>}
103 {(s.override.model || s.override.effort) && (
104 <Text color="#e8c46a">
105 per-request override: {[s.override.model, s.override.effort].filter(Boolean).join(' · ')}
106 </Text>
107 )}
108 </Box>
109 )
110 })
111}
112hooks/controls.ts 84 lines1import type { Settings } from '../types'
2
3/**
4 * What a control can do, built from `$` in register.tsx (the engine only lets
5 * `$` reach functions it can trace, so controls get closures, never `$`).
6 * A new control that needs more (a config row, a file) adds a member here.
7 */
8export type Ctx = {
9 sessionModel: () => Promise<string>
10 run: (command: string, args: string) => Promise<unknown>
11 get: () => Promise<Settings>
12 set: (fn: (s: Settings) => Settings) => Promise<unknown>
13}
14
15export type Option = { value: string; label: string; note?: string }
16
17/**
18 * One row of the pane. To add a setting, write one of these and append it to
19 * CONTROLS: the pane, the footer button and the tests pick it up from there.
20 */
21export type Control = {
22 id: string
23 label: string
24 options: readonly Option[]
25 /** The value in effect now, matched against `options[].value`; null when unknown. */
26 current: (ctx: Ctx) => Promise<string | null>
27 /** Puts `value` in effect; resolves to the line the pane shows afterwards. */
28 apply: (ctx: Ctx, value: string) => Promise<string>
29 /** Short form for the footer button; omit to leave the control out of it. */
30 short?: (value: string | null) => string
31}
32
33// The session reports a full id (claude-sonnet-5-5); options are /model's aliases.
34export const modelAlias = (id: string) =>
35 ['fable', 'opus', 'sonnet', 'haiku'].find(alias => id.toLowerCase().includes(alias)) ?? id
36
37export const model: Control = {
38 id: 'model',
39 label: 'Model',
40 options: [
41 { value: 'fable', label: 'fable', note: 'most capable' },
42 { value: 'opus', label: 'opus', note: 'deep work' },
43 { value: 'sonnet', label: 'sonnet', note: 'everyday' },
44 { value: 'haiku', label: 'haiku', note: 'fast, cheap' },
45 ],
46 current: async ctx => (await ctx.get()).override.model ?? modelAlias(await ctx.sessionModel()),
47 apply: async (ctx, value) => {
48 await ctx.run('model', value)
49 if (modelAlias(await ctx.sessionModel()) === value) {
50 await ctx.set(s => ({ ...s, override: { ...s.override, model: undefined } }))
51 return `Model set to ${value}.`
52 }
53 // ponytail: fallback rewrites each main-thread request; the status line keeps the old name
54 await ctx.set(s => ({ ...s, override: { ...s.override, model: value } }))
55 return `/model didn't take; ${value} applied per request instead.`
56 },
57 short: v => v ?? '?',
58}
59
60export const effort: Control = {
61 id: 'effort',
62 label: 'Effort',
63 options: [
64 { value: 'low', label: 'low', note: 'quick answers' },
65 { value: 'medium', label: 'medium' },
66 { value: 'high', label: 'high', note: 'default for most' },
67 { value: 'xhigh', label: 'xhigh' },
68 { value: 'max', label: 'max', note: 'hardest problems' },
69 ],
70 current: async ctx => {
71 const s = await ctx.get()
72 return s.override.effort ?? s.pendingEffort ?? s.lastEffort
73 },
74 apply: async (ctx, value) => {
75 await ctx.run('effort', value)
76 // Confirmed (or overridden) by the next request in turn.step.
77 await ctx.set(s => ({ ...s, pendingEffort: value, override: { ...s.override, effort: undefined } }))
78 return `Effort set to ${value}; confirmed on the next request.`
79 },
80 short: v => v ?? 'effort',
81}
82
83export const CONTROLS: readonly Control[] = [effort, model]
84types/index.d.ts 19 lines1/** A per-request override, used only when the slash command didn't take. */
2export type Override = { model?: string; effort?: string }
3
4export type Settings = {
5 /** The effort the last main-thread request went out with (null before the first). */
6 lastEffort: string | null
7 /** An effort chosen here, waiting for the next request to confirm it. */
8 pendingEffort: string | null
9 override: Override
10 /** One line of feedback in the pane after a choice. */
11 flash: string | null
12}
13
14declare module 'claude-code' {
15 interface PluginState {
16 'quick-settings': { settings: Settings }
17 }
18}
19