SLOPSHOPPER

mods-menu

A Mods button beside the Clean View toggle that opens a small menu with a button for each of your mods.

newbandcommandtoast
★ 1v0.1.0no licenseupdated 2026-10-14ataege/claude-skills/plugins/mods-menu
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mods-menu
› 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 › /mods ⎿ mods-menu: Mods menu opened. mods-menu:toggle ╭────────────────────────────────────────────╮ │ Your mods mods-menu:close │ │ ◆ Clean View not loaded │ │ ◆ Agent Dock not loaded │ │ ◇ Prompt Upgradenot loaded │ │ ▣ Status Bar always on │ │ ◎ Model Routing always on │ ╰────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
mods-menu:toggle ╭────────────────────────────────────────────╮ │ Your mods mods-menu:close │ │ ◆ Clean View not loaded │ │ ◆ Agent Dock not loaded │ │ ◇ Prompt Upgradenot loaded │ │ ▣ Status Bar always on │ │ ◎ Model Routing always on │ ╰────────────────────────────────────────────╯
README

claude-skills

A Claude Code plugin marketplace.

PluginWhat it does
clean-viewHides tool calls, diffs and command output, and shows a calm checklist of Claude's plan above the prompt, with a row for each helper Claude starts. /simple on or /simple off switches it. Also holds Agent Dock: pick a Team Size from 1 to 100 and every request is split across that many helpers, each shown live in a pane. /dock opens or folds it, /dock 10 sets the size.
status-barOne colored line under the prompt: plan, model, effort, context, 7-day and 5-hour limits, and running agents by model and effort.
model-routingRoutes every delegated agent to the cheapest model tier that can do the job well, at an explicit effort. Refuses agents with no model, fills in effort by tier, blocks spawns from haiku agents and past two levels, and keeps a routing guide in Claude's instructions.
mods-menuA ☰ Mods button beside the Clean View toggle. It opens a small menu listing your mods with their controls: Clean View on or off, the Agent Dock and its Team Size, and the mods that are always on. /mods opens or closes it.
prompt-upgradeTurns a rough prompt into a great one before it is sent. /better <prompt> opens a pane that asks a few quick multiple-choice questions, rewrites the prompt, and shows your original and the upgrade side by side; nothing runs until you press Run. /better on checks every typed prompt first: one that already has what it needs is sent as typed, one that falls short opens the pane, and the same prompt sent twice goes straight through. Press Enter twice to send a prompt as typed. A Detail setting (Auto, Low, Medium or High; /better level auto) sets the questions and how long and deep the upgrade is: Low, Medium and High ask up to 3, 5 or 7; Auto also turns automatic mode on, asks as many questions as needed (up to 9) and, if you don't answer within 30 seconds or press Do it for me, fills in the rest and sends the upgrade itself. /better history shows the last 20.

Install

At the prompt of a terminal session:

/plugin install clean-view --marketplace ataege/claude-skills
/plugin install status-bar --marketplace ataege/claude-skills
/plugin install model-routing --marketplace ataege/claude-skills
/plugin install mods-menu --marketplace ataege/claude-skills
/plugin install prompt-upgrade --marketplace ataege/claude-skills

Answer y to add the marketplace, then pick a scope with Enter.

From a shell:

claude plugin install clean-view --marketplace ataege/claude-skills
claude plugin install status-bar --marketplace ataege/claude-skills
claude plugin install model-routing --marketplace ataege/claude-skills
claude plugin install mods-menu --marketplace ataege/claude-skills
claude plugin install prompt-upgrade --marketplace ataege/claude-skills

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with ataege/claude-skills, then Add plugin.

Developing

Each plugin lives in plugins/<name>. Check one with:

claude plugin validate plugins/<name>
claude plugin test plugins/<name>
Source 4 files
hooks/register.tsx 9 lines
1import type { Register } from 'claude-code'
2
3import { registerModsMenu } from './mods-menu'
4
5// Each mod registers itself here.
6export const register: Register = on => {
7  registerModsMenu(on)
8}
9
hooks/mods-menu.tsx 196 lines
1// Mods Menu: a Mods button beside the Clean View toggle that opens a small rectangle of mod buttons.
2import { atom, read, update } from 'claude-code'
3import type { EngineInterface, On, RenderElement, RenderInput } from 'claude-code'
4
5import type { AvailableCommand } from '../types'
6import {
7  COLORS,
8  KEY_PREFIX,
9  MENU_WIDTH,
10  NAME_COLUMN_WIDTH,
11  appendToKeyed,
12  commandText,
13  findKeyed,
14  hasVisibleContent,
15  resolveRows,
16} from './mods-menu-logic'
17import type { ResolvedButton, ResolvedRow } from './mods-menu-logic'
18
19type Engine = EngineInterface
20type BandInput = RenderInput<'AbovePrompt'>
21
22const isOpenAtom = atom({ plugin: 'mods-menu', key: 'isOpen' } as const, false)
23const availableAtom = atom({ plugin: 'mods-menu', key: 'availableCommands' } as const, [])
24
25function showToast($: Engine, text: string): void {
26  try {
27    $.ui.toast(text)
28  } catch {
29    // A toast that cannot show is no reason to fail.
30  }
31}
32
33/** Asks the engine which slash commands exist now and keeps the answer in state. */
34async function refreshCommands($: Engine): Promise<void> {
35  try {
36    const commands = await $.command.list()
37    const available: AvailableCommand[] = commands.map(command => ({ name: command.name, plugin: command.plugin ?? null }))
38    await update($, availableAtom, () => available)
39  } catch {
40    // Keep the last known list.
41  }
42}
43
44/** Opens or closes the menu; opening refreshes the command list. */
45async function setMenuOpen($: Engine, mode: 'flip' | boolean): Promise<boolean> {
46  const wasOpen = await read($, isOpenAtom)
47  const willOpen = mode === 'flip' ? !wasOpen : mode
48  await update($, isOpenAtom, () => willOpen)
49  if (willOpen) await refreshCommands($)
50  return willOpen
51}
52
53/** Runs a button's slash command without blocking the press; a failure becomes a toast. */
54function runButton($: Engine, button: ResolvedButton, isSessionBusy: boolean): void {
55  void (async () => {
56    try {
57      if (isSessionBusy) showToast($, 'Runs when Claude finishes')
58      await $.command.run(button.args === '' ? { command: button.command } : { command: button.command, args: button.args })
59    } catch {
60      showToast($, `Couldn't run ${commandText(button)}`)
61    }
62  })()
63}
64
65function drawRow($: Engine, e: BandInput, row: ResolvedRow): RenderElement {
66  const { Box, Text, Button } = $.ui.resolve(e)
67  const { entry } = row
68
69  const buttons = row.buttons.map(button => (
70    <Box key={`${button.key}:box`} marginRight={1}>
71      <Button
72        key={button.key}
73        plain
74        onPress={() => {
75          runButton($, button, e.props.isWorking)
76          if (button.keepsMenuOpen !== true) void setMenuOpen($, false).catch(() => undefined)
77        }}
78      >
79        <Text color={COLORS.coral}>{button.label}</Text>
80      </Button>
81    </Box>
82  ))
83
84  return (
85    <Box key={`${KEY_PREFIX}row:${entry.id}`} flexDirection="row">
86      <Box width={NAME_COLUMN_WIDTH} flexShrink={0}>
87        <Text wrap="truncate-end">
88          <Text color={COLORS.coral}>{entry.icon}</Text> <Text>{entry.name}</Text>
89        </Text>
90      </Box>
91      <Box flexDirection="row" flexWrap="wrap" flexGrow={1} flexShrink={1}>
92        {row.status === 'not-loaded' ? <Text dimColor>not loaded</Text> : null}
93        {row.status === 'always-on' ? <Text dimColor>always on</Text> : null}
94        {buttons}
95      </Box>
96    </Box>
97  )
98}
99
100/** The small rectangle drawn under the Mods button. */
101async function drawMenu($: Engine, e: BandInput): Promise<RenderElement> {
102  const { Box, Text, Button } = $.ui.resolve(e)
103  const available = await read($, availableAtom)
104  const rows = resolveRows(available)
105  const width = Math.max(24, Math.min(MENU_WIDTH, e.props.bodyColumns))
106
107  return (
108    <Box
109      key={`${KEY_PREFIX}menu`}
110      flexDirection="column"
111      borderStyle="round"
112      borderColor={COLORS.hairline}
113      paddingX={1}
114      width={width}
115    >
116      <Box flexDirection="row">
117        <Box flexGrow={1}>
118          <Text color={COLORS.muted}>Your mods</Text>
119        </Box>
120        <Button key={`${KEY_PREFIX}close`} plain onPress={() => void setMenuOpen($, false).catch(() => undefined)}>
121          <Text color={COLORS.muted}>×</Text>
122        </Button>
123      </Box>
124      {rows.map(row => drawRow($, e, row))}
125    </Box>
126  )
127}
128
129/** The Mods button: a muted label and an arrow that shows whether the menu is open. */
130function drawToggle($: Engine, e: BandInput, isOpen: boolean): RenderElement {
131  const { Box, Text, Button } = $.ui.resolve(e)
132  return (
133    <Box key={`${KEY_PREFIX}corner-item`} marginLeft={1} flexShrink={0}>
134      <Button key={`${KEY_PREFIX}toggle`} plain onPress={() => void setMenuOpen($, 'flip').catch(() => undefined)}>
135        <Text color={COLORS.muted}>{`☰ Mods ${isOpen ? '▴' : '▾'}`}</Text>
136      </Button>
137    </Box>
138  )
139}
140
141async function drawBand($: Engine, e: BandInput, next: (e: BandInput) => Promise<RenderElement>): Promise<RenderElement> {
142  const { Box } = $.ui.resolve(e)
143  const beneath = await next(e)
144  const isOpen = await read($, isOpenAtom)
145  const cornerItem = drawToggle($, e, isOpen)
146  const bodyItem = isOpen ? await drawMenu($, e) : null
147
148  // Case a: Clean View is above us in the chain and left its two slots in the tree.
149  if (findKeyed(beneath, 'band-corner') !== undefined && findKeyed(beneath, 'band-body') !== undefined) {
150    const withCorner = appendToKeyed(beneath, 'band-corner', cornerItem)
151    return (bodyItem === null ? withCorner : appendToKeyed(withCorner, 'band-body', bodyItem)) as RenderElement
152  }
153
154  const slots = (
155    <Box key="band-slots" flexDirection="column">
156      <Box key="corner">{cornerItem}</Box>
157      <Box key="body" alignItems="flex-end">{bodyItem}</Box>
158    </Box>
159  )
160
161  // Case b: nothing visible beneath, so the slots stand alone.
162  if (!hasVisibleContent(beneath)) return slots
163
164  // Case c: something visible beneath, so the slots go under it.
165  return (
166    <Box flexDirection="column">
167      {beneath}
168      {slots}
169    </Box>
170  )
171}
172
173export function registerModsMenu(on: On): void {
174  on('session.start', async ($, e, next) => {
175    try {
176      await $.command.register({ name: 'mods', description: 'Open or close the Mods menu', immediate: true })
177    } catch {
178      // The Mods button still works without the slash command.
179    }
180    await refreshCommands($)
181    return next(e)
182  })
183
184  // /mods toggles the menu.
185  on('command.run', { command: 'mods' }, async $ => {
186    const isOpen = await setMenuOpen($, 'flip')
187    return { text: isOpen ? 'Mods menu opened.' : 'Mods menu closed.' }
188  })
189
190  // The Mods button and menu join the band above the prompt.
191  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
192    if (e.props.hasSurvey) return next(e)
193    return drawBand($, e, next)
194  })
195}
196
hooks/mods-menu-logic.ts 181 lines
1// Mods Menu's pure logic: the catalog, the command mapping and the tree helpers.
2import type { RenderNode } from 'claude-code'
3
4import type { AvailableCommand } from '../types'
5
6export const COLORS = {
7  coral: '#F0806A',
8  muted: '#8B949E',
9  hairline: '#3A3F44',
10} as const
11
12export const MENU_WIDTH = 46
13/** Width of the icon and name column, so every row's buttons line up. */
14export const NAME_COLUMN_WIDTH = 16
15export const KEY_PREFIX = 'mods-menu:'
16
17/** One button in a mod's row: what it says and the slash command it runs. */
18export type ModButton = {
19  label: string
20  command: string
21  args: string
22  /** True for buttons that leave the menu open (the dock sizes). */
23  keepsMenuOpen?: boolean
24}
25
26/** One mod in the menu. */
27export type ModEntry = {
28  id: string
29  icon: string
30  name: string
31  /** The plugin that owns this mod's commands. */
32  plugin: string
33  buttons: ModButton[]
34}
35
36// Adding a mod is one line here.
37export const CATALOG: ModEntry[] = [
38  {
39    id: 'clean-view',
40    icon: '◆',
41    name: 'Clean View',
42    plugin: 'clean-view',
43    buttons: [
44      { label: 'On', command: 'simple', args: 'on' },
45      { label: 'Off', command: 'simple', args: 'off' },
46    ],
47  },
48  {
49    id: 'agent-dock',
50    icon: '◆',
51    name: 'Agent Dock',
52    plugin: 'clean-view',
53    buttons: [
54      { label: 'Open', command: 'dock', args: '' },
55      ...['1', '3', '5', '10'].map(size => ({ label: size, command: 'dock', args: size, keepsMenuOpen: true })),
56    ],
57  },
58  {
59    id: 'prompt-upgrade',
60    icon: '◇',
61    name: 'Prompt Upgrade',
62    plugin: 'prompt-upgrade',
63    buttons: [
64      { label: 'Upgrade', command: 'better', args: '' },
65      { label: 'Auto on', command: 'better', args: 'on' },
66      { label: 'Auto off', command: 'better', args: 'off' },
67      { label: 'History', command: 'better', args: 'history' },
68      // The detail level; the menu stays open so the pick can be changed again.
69      ...(['Auto', 'Low', 'Medium', 'High'] as const).map(level => ({
70        label: level,
71        command: 'better',
72        args: `level ${level.toLowerCase()}`,
73        keepsMenuOpen: true,
74      })),
75    ],
76  },
77  { id: 'status-bar', icon: '▣', name: 'Status Bar', plugin: 'status-bar', buttons: [] },
78  { id: 'model-routing', icon: '◎', name: 'Model Routing', plugin: 'model-routing', buttons: [] },
79]
80
81/** A button ready to draw: the catalog's, or one made for an extra command. */
82export type ResolvedButton = ModButton & { key: string }
83
84/** One menu row after matching the catalog against the commands that exist. */
85export type ResolvedRow = {
86  entry: ModEntry
87  buttons: ResolvedButton[]
88  /** 'ready' draws buttons, 'always-on' has none by design, 'not-loaded' lost its commands. */
89  status: 'ready' | 'always-on' | 'not-loaded'
90}
91
92/** The plugin name without any `@marketplace` suffix. */
93export function pluginBaseName(plugin: string | null | undefined): string {
94  return (plugin ?? '').split('@')[0] ?? ''
95}
96
97/** Matches the catalog against the commands that exist right now. */
98export function resolveRows(available: readonly AvailableCommand[], catalog: readonly ModEntry[] = CATALOG): ResolvedRow[] {
99  const availableNames = new Set(available.map(command => command.name))
100  const catalogCommands = new Set(catalog.flatMap(entry => entry.buttons.map(button => button.command)))
101  const catalogPlugins = new Set(catalog.map(entry => entry.plugin))
102
103  // Commands of a catalog plugin that no button covers yet get a /name button on that plugin's first row.
104  const extraByPlugin = new Map<string, ResolvedButton[]>()
105  for (const command of available) {
106    const owner = pluginBaseName(command.plugin)
107    if (!catalogPlugins.has(owner) || catalogCommands.has(command.name)) continue
108    const extras = extraByPlugin.get(owner) ?? []
109    if (!extras.some(extra => extra.command === command.name)) {
110      extras.push({ key: `${KEY_PREFIX}run:${command.name}`, label: `/${command.name}`, command: command.name, args: '' })
111    }
112    extraByPlugin.set(owner, extras)
113  }
114
115  const claimedPlugins = new Set<string>()
116  return catalog.map(entry => {
117    const catalogButtons: ResolvedButton[] = entry.buttons
118      .filter(button => availableNames.has(button.command))
119      .map(button => ({ ...button, key: `${KEY_PREFIX}run:${entry.id}:${button.label}` }))
120    const isFirstOfPlugin = !claimedPlugins.has(entry.plugin)
121    claimedPlugins.add(entry.plugin)
122    const extras = isFirstOfPlugin ? (extraByPlugin.get(entry.plugin) ?? []) : []
123    const buttons = [...catalogButtons, ...extras]
124
125    if (entry.buttons.length === 0) return { entry, buttons, status: buttons.length > 0 ? 'ready' : 'always-on' }
126    return { entry, buttons, status: buttons.length > 0 ? 'ready' : 'not-loaded' }
127  })
128}
129
130/** The slash command text a button runs, for toasts: `/dock 3`. */
131export function commandText(button: Pick<ModButton, 'command' | 'args'>): string {
132  return button.args === '' ? `/${button.command}` : `/${button.command} ${button.args}`
133}
134
135// ---------------------------------------------------------------------------
136// Tree helpers. A RenderNode is a string or an element whose children sit in `children`.
137// ---------------------------------------------------------------------------
138
139type ElementLike = Exclude<RenderNode, string>
140
141function childrenOf(node: ElementLike): RenderNode[] {
142  const children = (node as { children?: RenderNode[] }).children
143  return Array.isArray(children) ? children : []
144}
145
146function keyOf(node: ElementLike): unknown {
147  return (node as { props?: { key?: unknown } }).props?.key
148}
149
150/** True when the tree holds a non-blank string or a Button anywhere. */
151export function hasVisibleContent(tree: RenderNode | null | undefined): boolean {
152  if (tree === null || tree === undefined) return false
153  if (typeof tree === 'string') return tree.trim() !== ''
154  if (tree.type === 'Button') return true
155  return childrenOf(tree).some(hasVisibleContent)
156}
157
158/** The first element in the tree whose `props.key` matches, or undefined. */
159export function findKeyed(tree: RenderNode | null | undefined, key: string): ElementLike | undefined {
160  if (tree === null || tree === undefined || typeof tree === 'string') return undefined
161  if (keyOf(tree) === key) return tree
162  for (const child of childrenOf(tree)) {
163    const found = findKeyed(child, key)
164    if (found !== undefined) return found
165  }
166  return undefined
167}
168
169/** A copy of the tree with `item` appended to the first keyed element's children. The input is never changed. */
170export function appendToKeyed(tree: RenderNode, key: string, item: RenderNode): RenderNode {
171  if (typeof tree === 'string') return tree
172  if (keyOf(tree) === key) {
173    return { ...tree, children: [...childrenOf(tree), item] } as ElementLike
174  }
175  const children = childrenOf(tree)
176  const holderIndex = children.findIndex(child => findKeyed(child, key) !== undefined)
177  if (holderIndex === -1) return tree
178  const copiedChildren = children.map((child, index) => (index === holderIndex ? appendToKeyed(child, key, item) : child))
179  return { ...tree, children: copiedChildren } as ElementLike
180}
181
types/index.d.ts 16 lines
1// Mods Menu's shared state: other mods can read whether the menu is open.
2
3declare module 'claude-code' {
4  interface PluginState {
5    'mods-menu': {
6      /** True while the menu rectangle is drawn under the Mods button. */
7      isOpen: boolean
8      /** Names of the slash commands that exist now, from `$.command.list()`. */
9      availableCommands: AvailableCommand[]
10    }
11  }
12}
13
14/** One slash command as the menu needs it: its name and the plugin that owns it. */
15export type AvailableCommand = { name: string; plugin?: string | null }
16