SLOPSHOPPER

quick-settings

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

newpanespinnercommandtoast
v0.1.0no licenseupdated 2026-10-10ianwilson97/claude-mods/quick-settings
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · quick-settings
│ ┃ Quick settings ✕ › fix the failing auth test and add an audit log call │ ┃ Effort │ ┃ [ ○ low ] [ ○ medium ] [ ○ high ] [ ○ xhigh ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Model ⎿ Added 2 lines, removed 1 line │ ┃ [ ○ fable ] [ ● opus ] [ ○ sonnet ] [ ○ hai ⏺ Bash(bun test) │ ┃ deep work ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /quick-settings │ ⎿ quick-settings: Quick settings open. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ⟨Claude Code's own drawing⟩ ⚙ effort · opus

Draws

Pane · Quick settings
Effort [ ○ low ] [ ○ medium ] [ ○ high ] [ ○ xhigh ] [ ○ max ] Model [ ○ fable ] [ ● opus ] [ ○ sonnet ] [ ○ haiku ] deep work
Prompt hint
⟨Claude Code's own drawing⟩ ⚙ effort · opus
README

ianwilson-mods

Claude Code mods (function-hook plugins). Each folder is one plugin; .claude-plugin/marketplace.json makes this repo a marketplace.

ModWhat 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-barContext-window breakdown above the prompt.
usage-meter5-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-bandFor the driver-seat workflow; skip them elsewhere.

Install

/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.

cheap-worker: two switches

Set per machine in /config (or pluginConfigs.cheap-worker.options in ~/.claude/settings.json):

OptionValues
provideropenrouter (default)Any OpenRouter model; key read from $CW_OPENROUTER_API_KEY (export it in ~/.zshenv). Workers skip user settings, hooks and plugins.
bedrockClaude 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.
launchpanes (default)All workers tiled in tmux session cw: tmux new -A -s cw.
tabsEach 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.

Reaching a worker: crew

The 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+.

First run on a new machine: checks

  1. /plugin lists the mods as enabled. Managed settings can block marketplaces or plugins; if so, nothing loads.
  2. /worker, launch one with task say hi; a Ghostty tab opens (or the toast says CMD+T, then crew …).
  3. Turn on team, launch two workers, then from /workers send one a message: messaging is Claude Code's own (ListAgents / SendMessage) and works under Bedrock unless policy turns it off.
Source 3 files
hooks/register.tsx 112 lines
1import { 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}
112
hooks/controls.ts 84 lines
1import 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]
84
types/index.d.ts 19 lines
1/** 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