SLOPSHOPPER

mode

Pick the model and the reasoning effort from one small pane or /mode. Does nothing until you pick something.

newpanecommandtoasttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mode
│ ┃ Mode ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ Mode │ ┃ Pick the model and the reasoning effort. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Model ⏺ Update(src/auth.ts) │ ┃ ○ Fable ⎿ Added 2 lines, removed 1 line │ ┃ ● Opus ⏺ Bash(bun test) │ ┃ ○ Sonnet ⎿ 3 pass, 1 fail │ ┃ ○ Haiku │ ┃ ↺ Claude Code's default ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Effort ✻ Worked for 42s · done 4:20 PM │ ┃ ○ low │ ┃ ○ medium › /mode │ ┃ ○ high ⎿ mode: Mode settings are open. │ ┃ ○ xhigh │ ┃ ○ max │ ┃ ↺ default │ ┃ │ ┃ ↑↓ move · enter select · esc close │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Mode
◆ Mode Pick the model and the reasoning effort. Model ○ Fable ● Opus ○ Sonnet ○ Haiku ↺ Claude Code's default Effort ○ low ○ medium ○ high ○ xhigh ○ max ↺ default ↑↓ move · enter select · esc close
README

mode

Pick the model and the reasoning effort from one small pane, or from /mode. It does nothing until you pick something: while no effort is chosen, every request goes out untouched.

  • /mode opens the pane (model: Fable, Opus, Sonnet, Haiku or Claude Code's default; effort: low, medium, high, xhigh, max or default).
  • /mode model <alias> switches the model (fable, opus, sonnet, haiku, default) through Claude Code's own /model.
  • /mode effort <level> sets the effort of every request of the main conversation (subagents are left alone). The level is remembered across sessions; levels a model does not support are handled by Claude Code. It never writes your /config. If you later change the effort yourself with /effort, that newer choice wins and the mod drops its override. /mode effort default removes the override. A level that the optional decision-router mod put on the step is not mistaken for your /effort.
  • It publishes mode.manual (true once you picked a model or an effort in the current session) for other mods to read.

Works on its own, or together with the optional menu mod: then the menu lists "Mode" (for example Opus 5.5 · effort high) and shows this page in its pane. Picking a model also makes it your default for new sessions, because the mod switches through /model, which saves it to settings.json; the effort is never written to settings.

Install

/plugin marketplace add danielmalka/malka-claude-mods
/plugin install mode@malka-claude-mods

Uninstall

/mode effort default
/plugin uninstall mode@malka-claude-mods

/mode effort default clears the remembered level. The model stays whatever you last chose with /model.

Development

Type declarations in .claude-plugin/types/ are generated by Claude Code when the mod loads (not committed); the mod's own state contract is types/index.d.ts, and types/menu.d.ts vendors the menu mod's contract (apiVersion 1). Then claude plugin test plugins/mode.


Português

Escolha o modelo e o esforço de raciocínio num painel pequeno ou com /mode. Não faz nada até você escolher algo: sem esforço escolhido, as requisições seguem intactas.

  • /mode abre o painel (modelo: Fable, Opus, Sonnet, Haiku ou o padrão do Claude Code; esforço: low, medium, high, xhigh, max ou default).
  • /mode model <alias> troca o modelo (fable, opus, sonnet, haiku, default) pelo /model do próprio Claude Code.
  • /mode effort <nível> define o esforço de cada requisição da conversa principal (subagentes ficam de fora), lembrado entre sessões; níveis que o modelo não suporta são tratados pelo Claude Code. Nunca escreve no seu /config; se você mudar o esforço com /effort, a sua escolha vence e o override é descartado. /mode effort default remove o override. Um nível que o mod opcional decision-router colocou no passo não é confundido com o seu /effort.
  • Publica mode.manual (verdadeiro quando você escolheu modelo ou esforço na sessão atual) para outros mods lerem.

Funciona sozinho ou com o mod opcional menu (que lista "Mode" e mostra a página no seu painel). Escolher um modelo também o torna o seu padrão para novas sessões, pois o mod troca pelo /model, que o grava no settings.json; o esforço nunca é gravado nas configurações.

Instalar: /plugin marketplace add danielmalka/malka-claude-mods e depois /plugin install mode@malka-claude-mods. Desinstalar: rode /mode effort default e então /plugin uninstall mode@malka-claude-mods.

Source 4 files
hooks/register.tsx 183 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type {} from '../types/menu'
5import type { Effort, MenuEntry } from '../types'
6import { modePage } from './mode-page'
7import { EFFORTS, isEffort, MODELS, prettyModel, summary } from './modes'
8
9const effort = atom({ plugin: 'mode', key: 'effort' } as const, null)
10const sessionEffort = atom({ plugin: 'mode', key: 'sessionEffort' } as const, null)
11const sessionModel = atom({ plugin: 'mode', key: 'sessionModel' } as const, null)
12const manual = atom({ plugin: 'mode', key: 'manual' } as const, false)
13// Foreign ref: the optional `menu` mod owns it (apiVersion 1); undefined = no menu.
14const menuPanel = { plugin: 'menu', key: 'panel' } as const
15// Foreign ref: decision-router's own routed effort; absent plugin = null.
16const routerEffort = { plugin: 'decision-router', key: 'effort' } as const
17const menuEntryRef = { plugin: 'mode', key: 'menuEntry' } as const
18
19// What the engine itself ran the last main-loop step at, to notice the user's own /effort.
20let seenEffort: Effort | undefined
21let seenModel: string | undefined
22
23const PANE = 'mode-settings'
24const MENU_PANE = 'mods-menu'
25
26// Published for the optional menu; a failure here never blocks the switch itself.
27async function writeEntry($: EngineInterface) {
28  const entry: MenuEntry = {
29    apiVersion: 1,
30    title: 'Mode',
31    summary: summary(await read($, sessionModel), (await read($, effort)) ?? (await read($, sessionEffort))),
32    order: 3,
33  }
34  await $.state.set(menuEntryRef, entry).catch(() => undefined)
35}
36
37const markManual = ($: EngineInterface) => update($, manual, () => true)
38
39// The effort every request of the main loop is sent with; null clears it. Never writes the user's /config.
40async function setEffort($: EngineInterface, level: Effort | 'default') {
41  const next = level === 'default' ? null : level
42  await update($, effort, () => next)
43  if (next === null) await $.store.delete('effort')
44  else await $.store.set('effort', next)
45  await markManual($)
46  await writeEntry($)
47  $.ui.toast(`Effort: ${level}`)
48}
49
50// Switches the session's model through Claude Code's own `/model`, so /model, the status line and the requests agree.
51async function setModel($: EngineInterface, alias: string) {
52  const before = await $.session.model()
53  const ran = await $.command.run({ command: 'model', args: alias })
54  const id = await $.session.model()
55  if (id === before) {
56    $.ui.toast(ran.text?.trim() || 'Model unchanged')
57    return
58  }
59  await update($, sessionModel, () => id)
60  await markManual($)
61  await writeEntry($)
62  $.ui.toast(`Model: ${prettyModel(id)}`)
63}
64
65// The session's own effort level before any request. First match wins: a /config row, the effortLevel setting, CLAUDE_EFFORT.
66async function loadSessionEffort($: EngineInterface) {
67  const found: unknown[] = []
68  try {
69    found.push((await $.config.list()).find(r => /effort/i.test(r.key))?.value)
70  } catch {}
71  try {
72    found.push(((await $.settings.read()) as { effortLevel?: unknown }).effortLevel)
73  } catch {}
74  found.push(await $.env.get('CLAUDE_EFFORT'))
75  const level = found.find(isEffort)
76  if (level !== undefined) await update($, sessionEffort, () => level)
77}
78
79async function page($: EngineInterface, e: any, hasBack: boolean) {
80  const tell = (what: string) => (error: unknown) => $.ui.toast(`${what}: ${String(error)}`)
81  return modePage($.ui.resolve(e), {
82    hasBack,
83    model: await read($, sessionModel),
84    level: (await read($, effort)) ?? (await read($, sessionEffort)),
85    onModel: alias => setModel($, alias).catch(tell('Could not switch the model')),
86    onEffort: level => setEffort($, level).catch(tell('Could not set the effort')),
87    // the menu mod handles this press itself (ui.press on this plugin's key); the closure keeps the Button pressable
88    onBack: () => undefined,
89  })
90}
91
92export const register: Register = on => {
93  on('session.start', async ($, e, next) => {
94    // Nothing is overridden until the user picks: only a stored level is read back.
95    // A new session starts its own baseline: the last one's engine level is not the user's choice here.
96    seenEffort = undefined
97    seenModel = undefined
98    await $.command
99      .register({ name: 'mode', description: 'Pick the model and the reasoning effort', argumentHint: '[model <alias>|effort <level|default>]' })
100      .catch(() => undefined)
101    try {
102      const saved = await $.store.get('effort')
103      if (isEffort(saved)) await update($, effort, () => saved)
104      await loadSessionEffort($).catch(() => undefined)
105      await $.session.model().then(id => update($, sessionModel, () => id)).catch(() => undefined)
106      await update($, manual, () => false)
107      await writeEntry($)
108    } catch {}
109    return next(e)
110  })
111
112  on('command.run', { command: 'mode' }, async ($, e) => {
113    const [what, value] = e.args.trim().toLowerCase().split(/\s+/)
114    try {
115      if (what === 'model') {
116        if (value !== 'default' && !MODELS.some(m => m.alias === value)) return { text: `Model: ${MODELS.map(m => m.alias).join(', ')} or default.` }
117        // /model cannot be run from inside a command.run hook (it would wait on this turn): run it right after.
118        $.clock.after(0, () => void setModel($, value).catch(error => $.ui.toast(`Could not switch the model: ${String(error)}`)))
119        return { text: `Switching the model to ${value}.` }
120      }
121      if (what === 'effort') {
122        if (value !== 'default' && !isEffort(value)) return { text: `Effort: ${EFFORTS.join(', ')} or default.` }
123        await setEffort($, value as Effort | 'default')
124        return { text: `Effort: ${value}.` }
125      }
126    } catch (error) {
127      return { text: `Mode could not be changed: ${String(error)}` }
128    }
129    await $.ui.open({ id: PANE, title: 'Mode', focus: true, closeOnEscape: true, rows: 18, columns: 48 })
130    return { text: 'Mode settings are open.' }
131  })
132
133  on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => page($, e, false))
134
135  // Inside the menu's pane (apiVersion 1): drawn only when the menu is on this panel.
136  on('ui.render', { component: 'Pane', requestId: MENU_PANE }, async ($, e, next) => {
137    const { value } = await $.state.get(menuPanel)
138    if (value !== 'mode') return next(e)
139    return page($, e, true)
140  })
141
142  // The chosen effort rides on every model request of the main loop; unset = the request goes down untouched.
143  on('turn.step', async function* ($, e, next) {
144    if (e.agentId !== undefined) return yield* next(e)
145    let level: Effort | null = null
146    try {
147      level = await read($, effort)
148    } catch {}
149    // A level the router put on this step is not the engine's: do not mistake it for the user's /effort.
150    let routed: unknown = null
151    try {
152      routed = (await $.state.get(routerEffort)).value
153    } catch {}
154    const engineEffort = routed !== null && e.effort === routed ? undefined : e.effort
155    // The session's own level and model, as the engine had them, before ours replaces the level.
156    let changed = false
157    // The engine's level moved on the same model since the last step: the user ran /effort or /config.
158    // That is a newer choice than our override, so ours is dropped.
159    if (e.model !== seenModel) seenEffort = undefined
160    seenModel = e.model
161    if (isEffort(engineEffort)) {
162      if (level !== null && seenEffort !== undefined && engineEffort !== seenEffort) {
163        level = null
164        await update($, effort, () => null).catch(() => undefined)
165        await $.store.delete('effort').catch(() => undefined)
166        $.ui.toast(`Effort: following your /effort ${engineEffort}`)
167        changed = true
168      }
169      seenEffort = engineEffort
170    }
171    if (isEffort(engineEffort) && engineEffort !== (await read($, sessionEffort).catch(() => null))) {
172      await update($, sessionEffort, () => engineEffort).catch(() => undefined)
173      changed = true
174    }
175    if (e.model !== (await read($, sessionModel).catch(() => null))) {
176      await update($, sessionModel, () => e.model).catch(() => undefined)
177      changed = true
178    }
179    if (changed) await writeEntry($)
180    return yield* next(level !== null && e.effort !== undefined ? { ...e, effort: level } : e)
181  })
182}
183
hooks/mode-page.tsx 50 lines
1import type { Effort } from '../types'
2import { EFFORTS, MODELS, modelFamily } from './modes'
3
4type Ui = { Box: any; Text: any; Button: any }
5
6// The Mode page: model and effort, one item per row so the arrow keys walk it top to bottom.
7export function modePage(
8  { Box, Text, Button }: Ui,
9  p: {
10    hasBack: boolean
11    model: string | null
12    level: Effort | null // the level shown as chosen: the user's, else the session's
13    onModel: (alias: string) => unknown
14    onEffort: (level: Effort | 'default') => unknown
15    onBack: () => unknown
16  },
17) {
18  const option = (key: string, label: string, isOn: boolean, onPress: () => unknown) => (
19    <Box key={`row-${key}`} gap={1}>
20      <Button key={key} plain label={`${isOn ? '●' : '○'} ${label}`} dimColor={!isOn} onPress={onPress} />
21    </Box>
22  )
23  const heading = (text: string) => (
24    <Box key={`h-${text}`} marginTop={1}>
25      <Text bold>{text}</Text>
26    </Box>
27  )
28  const family = modelFamily(p.model)
29  return (
30    <Box flexDirection="column" paddingX={1}>
31      <Box justifyContent="space-between">
32        <Text bold color="claude">
33          ◆ Mode
34        </Text>
35        {p.hasBack && <Button key="mods-back" plain dimColor hotkey="b" label="← back" onPress={p.onBack} />}
36      </Box>
37      <Text dimColor>Pick the model and the reasoning effort.</Text>
38      {heading('Model')}
39      {MODELS.map(m => option(`model-${m.alias}`, m.label, m.alias === family, () => p.onModel(m.alias)))}
40      <Button key="model-default" plain dimColor label="↺ Claude Code's default" onPress={() => p.onModel('default')} />
41      {heading('Effort')}
42      {EFFORTS.map(l => option(`effort-${l}`, l, l === p.level, () => p.onEffort(l)))}
43      <Button key="effort-default" plain dimColor label="↺ default" onPress={() => p.onEffort('default')} />
44      <Box marginTop={1}>
45        <Text dimColor>↑↓ move · enter select · esc close</Text>
46      </Box>
47    </Box>
48  )
49}
50
hooks/modes.ts 27 lines
1import type { Effort } from '../types'
2
3export const EFFORTS: readonly Effort[] = ['low', 'medium', 'high', 'xhigh', 'max']
4
5// The models the pane offers, by the alias `/model` takes.
6export const MODELS = [
7  { alias: 'fable', label: 'Fable' },
8  { alias: 'opus', label: 'Opus' },
9  { alias: 'sonnet', label: 'Sonnet' },
10  { alias: 'haiku', label: 'Haiku' },
11] as const
12
13// `claude-opus-5-5` -> `opus`; null for an id that is not a Claude model's.
14export const modelFamily = (id: string | null): string | null => id?.match(/^claude-([a-z]+)-/i)?.[1]?.toLowerCase() ?? null
15
16// `claude-opus-5-5` as `Opus 5.5`; a name that is not a Claude model id stays as it is.
17export function prettyModel(id: string): string {
18  const match = id.match(/^claude-([a-z]+)-(\d+)(?:-(\d+))?/i)
19  if (match === null) return id
20  const [, family = '', major = '', minor] = match
21  return `${family.charAt(0).toUpperCase()}${family.slice(1)} ${major}${minor === undefined ? '' : `.${minor}`}`
22}
23
24export const isEffort = (v: unknown): v is Effort => typeof v === 'string' && EFFORTS.includes(v as Effort)
25
26export const summary = (model: string | null, level: Effort | null) => `${model === null ? '' : `${prettyModel(model)} · `}effort ${level ?? 'default'}`
27
types/index.d.ts 21 lines
1export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
2
3// Menu contract, apiVersion 1: an optional `menu` mod lists this entry.
4export type MenuEntry = { apiVersion: 1; title: string; summary: string; order: number }
5
6declare module 'claude-code' {
7  interface PluginState {
8    mode: {
9      menuEntry: MenuEntry
10      // true once the user picked a model or an effort in this session (read by other mods, e.g. a router)
11      manual: boolean
12      // the effort the user chose; null = none, no override is sent
13      effort: Effort | null
14      // the effort the session itself runs at, as last seen
15      sessionEffort: Effort | null
16      // the session's model id, e.g. claude-opus-5-5
17      sessionModel: string | null
18    }
19  }
20}
21