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

A Claude Code plugin marketplace.
| Plugin | What it does |
|---|---|
clean-view | Hides 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-bar | One colored line under the prompt: plan, model, effort, context, 7-day and 5-hour limits, and running agents by model and effort. |
model-routing | Routes 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-menu | A ☰ 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-upgrade | Turns 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. |
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.
Each plugin lives in plugins/<name>. Check one with:
claude plugin validate plugins/<name>
claude plugin test plugins/<name>hooks/register.tsx 9 lines1import 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}
9hooks/mods-menu.tsx 196 lines1// 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}
196hooks/mods-menu-logic.ts 181 lines1// 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}
181types/index.d.ts 16 lines1// 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