SLOPSHOPPER

mode-registry

A shared /mode switch that any plugin can offer a mode to. Shows the active mode in the prompt footer. Makes no model calls.

newbandspinnercommand
★ 536v0.1.0MITupdated 2026-10-02DannyMac180/skills/modsmith/templates/mode-registry
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mode-registry
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /mode ⎿ mode-registry: Pick a mode above the prompt, or /mode list. Mode: off No plugin offers a mode yet. [ Off ][ Close ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Mode: off No plugin offers a mode yet. [ Off ][ Close ] ⟨Claude Code's own drawing⟩
README

mode-registry

One /mode switch that every plugin can offer a mode to. You install the registry once; a model router, an "artifact mode", or the effort modes beside this template each add their own mode to it. You see the active mode as a dim label in the prompt footer (review mode, beside focus and the others) and switch with /mode.

This is the "mods composing" pattern: the registry doesn't know what any mode does. It keeps the list and the current choice. Each plugin reads the choice and changes its own behaviour.

What you can do

You typeWhat happens
/modeOpens a picker above the prompt: one button per mode (hotkeys 1-9, 0 for off) and Close. Click it or press ctrl+x tab to give it the keys.
/mode reviewSwitches to the mode with id review. The footer reads review mode.
/mode offClears the mode (none and clear work too).
/mode listPrints every mode on offer, the plugin that offers it, and which one is on.

The active mode lives in $.state for the session and isn't saved between sessions (to save it, write it to $.store as well). The registry doesn't reset it on /clear. Whether the engine keeps $.state across a /clear has not been checked.

What it costs

No model calls, ever. The footer label and the picker are UI only and never enter the context. Each /mode command adds one short line to the transcript, which the model reads (about 10-20 tokens; /mode list is about 15 tokens per mode). A press in the picker adds nothing: it switches the mode silently, so the model isn't told (use /mode <id> when you want it to know). That line is appended after the cached prefix, so it never breaks the prompt cache. Whatever a mode costs is up to the plugin that offers it. For example, effort-modes' switch costs one cache rebuild (see its README).

Offering a mode from your plugin

The contract is types/index.d.ts. It has three values this plugin owns:

  • mode-registry.catalog: ModeSpec[], every mode on offer
  • mode-registry.active: string | null, the active mode's id
  • mode-registry.isPicking: boolean, whether the picker is open

Only the registry writes them. You offer a mode by hooking the registry's write to catalog and adding yours:

on('state.set', { plugin: 'mode-registry', key: 'catalog' }, ($, e, next) =>
  next({
    ...e,
    value: [
      ...e.value.filter(m => m.id !== 'router'),
      { id: 'router', label: 'Router', description: 'Picks the model per turn', owner: 'my-router' },
    ],
  }),
)

Then read the active mode wherever your behaviour lives:

const { value: active = null } = await $.state.get({ plugin: 'mode-registry', key: 'active' })
if (active !== 'router') return next(e)

Some details that matter:

  • Filter your own id before adding it. The registry rewrites the catalog at session start and on every /mode. Filtering first keeps your offer from appearing twice.
  • Read active in the hook that acts. Don't copy it into a module variable you keep across turns: a hot reload wipes module variables, and a value read inside a ui.render subscribes you to redraws.
  • If the registry isn't installed, nothing breaks. active reads as undefined at version 0, your state.set hook never fires, and your plugin runs its default behaviour.
  • To switch modes from code (an auto-router that flips to review when it sees a diff), call $.command.run({ command: 'mode', args: 'review' }). It goes through the same path and the same vetoes as the person typing it. It queues until the session is idle.
  • To refuse or redirect a switch, hook state.set on { plugin: 'mode-registry', key: 'active' } and pass next another value. /mode reports what actually landed, so the person sees the veto.

For type-checking, list the registry in your plugin.json: "dependencies": ["mode-registry"]. The engine then writes this contract into your plugin's .claude-plugin/types/mode-registry/ when it loads it from your folder, and your tsconfig.json picks it up (effort-modes does this). The catch: a plugin with that dependency doesn't load at all without the registry. If yours should also work alone, leave the dependency out and run /plugin-types in a session where mode-registry is enabled instead. Never copy the file into your plugin by hand.

Composes with

  • effort-modes (beside this template): offers ui, api and review.
  • Any plugin that draws in the SessionMode footer or the AbovePrompt band. The registry adds its label to e.props.modes and passes the rest on, and draws {await next(e)} under its picker.

Install / load

claude plugin validate templates/mode-registry
claude plugin test templates/mode-registry           # 4 tests, incl. a veto from a third plugin
claude --plugin-dir templates/mode-registry --plugin-dir templates/effort-modes

Function hooks are early access. If your build doesn't load hooks modules by default, set CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1. That setting loads the hooks module of every installed plugin that ships one, not only this one.

Source 2 files
hooks/register.tsx 132 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ModeSpec } from '../types'
5
6// The contract, in three values this plugin owns. Other plugins read them and
7// change them only through state.set hooks (see README: "Offering a mode").
8const CATALOG = { plugin: 'mode-registry', key: 'catalog' } as const
9const ACTIVE = { plugin: 'mode-registry', key: 'active' } as const
10const PICKING = { plugin: 'mode-registry', key: 'isPicking' } as const
11
12const catalog = atom(CATALOG, [] as ModeSpec[])
13const active = atom(ACTIVE, null)
14const isPicking = atom(PICKING, false)
15
16const OFF = new Set(['off', 'none', 'clear'])
17
18const log = ($: EngineInterface, what: string) => (err: unknown) => {
19  $.ui.log(`mode-registry: ${what}: ${err}`)
20}
21
22// Writing the empty list is the whole refresh: every plugin that offers a mode
23// hooks this write and adds its own, so what lands is the fold of all offers.
24const refresh = async ($: EngineInterface) => {
25  await $.state.set(CATALOG, []).catch(log($, 'catalog refresh failed'))
26  return read($, catalog).catch(() => [] as ModeSpec[])
27}
28
29const labelOf = (modes: ModeSpec[], id: string) => modes.find(m => m.id === id)?.label ?? id
30
31const listing = (modes: ModeSpec[], current: string | null) => {
32  if (modes.length === 0) return 'No plugin offers a mode yet.'
33
34  const rows = modes.map(m => `${m.id === current ? '*' : ' '} ${m.id}  ${m.label}: ${m.description} (${m.owner})`)
35
36  return [`Modes (current: ${current ?? 'off'}):`, ...rows, '/mode <id> to switch, /mode off to clear.'].join('\n')
37}
38
39export const register: Register = on => {
40  on('session.start', async ($, e, next) => {
41    const result = await next(e)
42
43    await $.command
44      .register({ name: 'mode', description: 'Switch mode (any plugin can offer one)', argumentHint: '[id|off|list]' })
45      .catch(log($, '/mode not registered'))
46    await refresh($)
47
48    return result
49  })
50
51  // Our own command: answered here, never passed on.
52  on('command.run', { command: 'mode' }, async ($, e) => {
53    const arg = e.args.trim().toLowerCase()
54    const modes = await refresh($)
55    const current = await read($, active).catch(() => null)
56
57    if (arg === '') {
58      await $.state.set(PICKING, true).catch(log($, 'picker failed to open'))
59      return { text: 'Pick a mode above the prompt, or /mode list.' }
60    }
61
62    if (arg === 'list') return { text: listing(modes, current) }
63
64    if (OFF.has(arg)) {
65      await $.state.set(ACTIVE, null).catch(log($, 'mode not cleared'))
66      return { text: 'Mode off.' }
67    }
68
69    const spec = modes.find(m => m.id === arg)
70
71    if (!spec) return { text: `No mode "${arg}".\n${listing(modes, current)}` }
72
73    await $.state.set(ACTIVE, spec.id).catch(log($, 'mode not set'))
74
75    // Another plugin may have rewritten the write (a veto), so report what landed.
76    const landed = await read($, active).catch(() => current)
77
78    if (landed !== spec.id) return { text: `Another plugin kept ${spec.label} from switching on; mode is ${landed ?? 'off'}.` }
79
80    return { text: `Mode: ${spec.label}. ${spec.description}` }
81  })
82
83  // The compact display: one dim label in the prompt footer, beside the
84  // engine's own and any other plugin's, never instead of them.
85  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
86    const id = await read($, active)
87
88    if (id === null) return next(e)
89
90    const label = labelOf(await read($, catalog), id).toLowerCase()
91
92    return next({ ...e, props: { ...e.props, modes: [...e.props.modes, `${label} mode`] } })
93  })
94
95  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
96    if (e.props.hasSurvey || !(await read($, isPicking))) return next(e)
97
98    const [modes, current] = await Promise.all([read($, catalog), read($, active)])
99    const { Box, Button, Text } = $.ui.resolve(e)
100
101    // Handlers write; a render never does. Each catches, so a failed write
102    // logs instead of rejecting into the engine.
103    const choose = (id: string | null) => () => {
104      void Promise.all([update($, active, () => id), update($, isPicking, () => false)]).catch(log($, 'pick failed'))
105    }
106    const close = () => {
107      void update($, isPicking, () => false).catch(log($, 'picker failed to close'))
108    }
109    const hotkey = (i: number) => (i < 9 ? { hotkey: String(i + 1) } : {})
110
111    return (
112      <Box flexDirection="column">
113        <Box flexDirection="column">
114          <Text bold>Mode: {current === null ? 'off' : labelOf(modes, current)}</Text>
115          {modes.length === 0 ? <Text dimColor>No plugin offers a mode yet.</Text> : null}
116          {modes.map((m, i) => (
117            <Box key={`row-${m.id}`}>
118              <Button key={`mode-${m.id}`} label={m.label} {...hotkey(i)} variant={m.id === current ? 'primary' : 'secondary'} onPress={choose(m.id)} />
119              <Text dimColor wrap="truncate-end"> {m.description}</Text>
120            </Box>
121          ))}
122          <Box>
123            <Button key="mode-off" label="Off" hotkey="0" onPress={choose(null)} />
124            <Button key="mode-close" label="Close" role="dismiss" onPress={close} />
125          </Box>
126        </Box>
127        {await next(e)}
128      </Box>
129    )
130  })
131}
132
types/index.d.ts 32 lines
1// The mode-registry contract. Self-contained on purpose: /plugin-types copies
2// this file into every dependent's .claude/types, so it imports nothing.
3
4// One mode a plugin offers. Plain JSON: it crosses plugins through $.state,
5// so it cannot carry a function. What a mode *does* stays in the plugin that
6// offered it, which reads `active` and applies its own behaviour.
7export type ModeSpec = {
8  // Stable, lowercase, what `/mode <id>` takes. Prefix it if it might clash.
9  id: string
10  // Short, shown in the prompt footer and the picker.
11  label: string
12  // One line: what changes while it is active.
13  description: string
14  // The offering plugin's manifest name, so the picker can say who owns it.
15  owner: string
16}
17
18declare module 'claude-code' {
19  interface PluginState {
20    'mode-registry': {
21      // Every mode on offer. Owned by mode-registry; other plugins add theirs
22      // by hooking state.set on this key and rewriting e.value.
23      catalog: ModeSpec[]
24      // The active mode's id, or null for none. Read it; never assume it is in
25      // the catalog (its owner may have been unloaded).
26      active: string | null
27      // True while the /mode picker is open above the prompt.
28      isPicking: boolean
29    }
30  }
31}
32