SLOPSHOPPER

agent-quick-menu

A pane and prompt band for plugin commands declared in quick-menu.json, plus plugin and Claude Code settings exposed through /config.

newpanebandspinnercommandtoast
★ 2v0.3.1MITupdated 2026-10-08agentic-workbench/agent-quick-menu
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-quick-menu
│ ┃ Quick menu ✕ › fix the failing auth test and add an audit log call │ ┃ Quick menu [ Expand all ] [ Collapse all ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ Press ☆ on a row to pin it to the band. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ filter… ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Quick menu: nothing here yet. ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /menu │ ⎿ agent-quick-menu: Quick menu opened │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⟨Claude Code's own drawing⟩ ≣ menu ▾

Draws

Pane · Quick menu
Quick menu [ Expand all ] [ Collapse all ] 0 sections · 0 Press ☆ on a row to pin it to the band. filter… Quick menu: nothing here yet.
README

<img src=".claude-plugin/icon.png" alt="agent-quick-menu logo" width="128">

<h1 align="center">agent-quick-menu</h1>

<b>One menu for Claude Code:</b> every plugin's commands and settings, and Claude Code's own,<br> in a pane beside the transcript, a band of favourites above the prompt and a menu button below it.

<a href="LICENSE"><img src="https://img.shields.io/github/license/agentic-workbench/agent-quick-menu" alt="License: MIT"></a> <a href="https://github.com/agentic-workbench/agent-quick-menu/releases"><img src="https://img.shields.io/github/v/release/agentic-workbench/agent-quick-menu" alt="Latest release"></a> <a href="https://github.com/agentic-workbench/agent-quick-menu/actions/workflows/ci.yml"><img src="https://github.com/agentic-workbench/agent-quick-menu/actions/workflows/ci.yml/badge.svg" alt="CI"></a> <a href="https://github.com/karanb192/awesome-claude-code-mods"><img src="https://awesome.re/mentioned-badge.svg" alt="Listed in awesome-claude-code-mods"></a> <a href="https://github.com/karanb192/awesome-claude-code-mods"><img src="https://raw.githubusercontent.com/karanb192/awesome-claude-code-mods/main/badges/agentic-workbench--agent-quick-menu--agent-quick-menu-validates.svg" alt="validates"></a>

<a href="#install">Install</a> · <a href="#usage">Usage</a> · <a href="#for-plugin-authors">For plugin authors</a> · <a href="docs/convention.md">Convention</a> · <a href="#what-this-plugin-does-on-your-machine">What it does on your machine</a>

<img src="docs/screenshot.png" alt="The quick menu pane: foldable sections per plugin with command buttons, their slash commands and descriptions, and Claude Code's own settings with toggles and choices" width="900">

Highlights

  • Every plugin in one place. Each plugin gets a foldable section with its commands as buttons and its settings as editable rows; Claude Code's own /config settings get one too.
  • Commands that ask. A button can prompt for its argument (a branch, a date) before it runs, with save and cancel.
  • Settings you can change in place. Toggles, option buttons and text fields, each with its description; a filter finds a row by name or description.
  • Favourites in the band. Pin any command or setting with ☆ and it sits above the prompt, one press away. The menu button sits at the bottom right of the prompt footer.
  • Safe by default. Every button shows the slash command it runs; another plugin's or a built-in command needs a second press. Menu files are treated as untrusted input.
  • One file to join. A plugin registers with a small .claude-plugin/quick-menu.json; no code, no dependency.

Requirements

Claude Code 2.1.287 or newer, in the terminal or the Desktop Code tab, on macOS or Linux. The VS Code chat panel is not supported. Windows paths (USERPROFILE, ; in CLAUDE_CODE_PLUGIN_DIRS) are read, but the menu is untested there.

Install

In a Claude Code session:

/plugin marketplace add agentic-workbench/agent-quick-menu
/plugin install agent-quick-menu@agent-quick-menu
/reload-plugins

Or from a shell:

claude plugin marketplace add agentic-workbench/agent-quick-menu
claude plugin install agent-quick-menu@agent-quick-menu

Then run /reload-plugins in a running session.

Usage

  • /menu opens the pane. /menu refresh discovers plugin menus again.
  • The menu button ≣ menu ▸ sits at the bottom right of the prompt footer, after the dim mode labels. Click it to toggle: it closes the menu when it is open (▾) and opens it when closed (▸); it has no hotkey, as the footer cannot hold focus. /menu always opens or focuses it. The band above the prompt is one line holding your favourites only, after a dim caption: ★ favourites: Pull Status ….
  • ctrl+x tab focuses the band.
  • Pinned favourites are buttons in the band above the prompt; one click runs them. There is no keyboard shortcut for them.
  • ☆ / ★ on any row adds or removes a favourite. Favourites are listed first in the pane.
  • Every section folds: press its header (▸ / ▾). Favourites start open, every other section folded. Expand all (e) and Collapse all (c) sit on the top line; the state is kept across sessions. The pane closes with Claude Code's own ×, Escape or ctrl+x x. With no favourites yet, the pane says to press ☆ on a row.
  • The filter field at the top (filter…) matches setting labels, keys and descriptions and command names and labels, case-insensitive, across every section. Sections without a match are hidden, matching ones open while a filter is set, and the counts follow.
  • Every setting row is a plain label and a value, and a press edits it: a boolean is a green ● (on) or gray ○ (off) with the plain word on / off; a choice opens an inline row of option buttons (● current, ○ other), one editor open at a time; text and number rows show value ✎ and open an Input with ✓ save and ✕ cancel. Cancel is the button: Escape does not cancel the edit (it closes the pane). Commands and settings show a dim italic description after · .
  • A command with ask in its menu entry opens that same editor when pressed, in the pane or from a band favourite (which opens the pane at its row), with the default typed in and ✓ run / ✕ cancel. Enter or ✓ run runs /command <args> <input>; the row hint reads /command <args> …. The confirm-twice of a built-in or another plugin's command applies to ✓ run. See docs/convention.md.
  • If the terminal is too narrow to place the pane, a toast says so and the band lists the sections (header buttons, at most as many rows as the band may take, and a Close button, since the engine draws no close mark there) instead.
  • [-] at the end of the band (drawn by Claude Code, or ctrl+x ctrl+a) folds the band.

What appears

  • One section per plugin that ships a .claude-plugin/quick-menu.json: its commands one per line (button, then the /command args hint) and its settings (userConfig) as an aligned label/value table. A setting's description (userConfig description) follows its value dimmed, clipped to the pane width and dropped when under 8 cells remain; the filter matches it too.
  • A commands-only section for a plugin loaded with --plugin-dir whose root is not known (see Limitations): its registered commands, and a dim line saying to set CLAUDE_CODE_PLUGIN_DIRS.
  • A settings-only section for each plugin without the file that has userConfig rows.
  • A "Claude Code" section with Claude Code's own settings.
  • A locked row (managed) is set by managed policy and cannot be changed here.
  • A "Problems" list shows plugin files that were skipped and why.

What this plugin does on your machine

Slash commands it runs, and when. Only when you press a button or a band favourite, and only the command that row shows: /<command> <args> from a plugin's quick-menu.json. Built-in commands and other plugins' commands need a second press within 5 seconds. When you change a setting row it runs /config <key>=<value>, then reads the row back through $.config.list() to judge whether the write took. It runs nothing on its own.

What it sets. Only the /config rows you change in the pane. It sets no environment variables. It keeps favourites and folded sections in Claude Code's plugin store.

What it reads. The plugin registry installed_plugins.json under CLAUDE_CONFIG_DIR or ~/.claude, the environment variables CLAUDE_CONFIG_DIR, HOME, USERPROFILE and CLAUDE_CODE_PLUGIN_DIRS, each plugin's .claude-plugin/quick-menu.json, the .claude-plugin/plugin.json of the folders in CLAUDE_CODE_PLUGIN_DIRS, the list of commands Claude Code offers, and the rows /config shows. It sends nothing over the network and reads no credentials.

Its hooks.

  • ui.render on AbovePrompt: adds its own row of favourites and keeps every other mod's band beneath it.
  • ui.render on SessionMode: draws the footer's mode labels as the engine does, then the menu button.
  • ui.render on its Pane: draws the menu.
  • command.run, matched to /menu only: answers /menu and /menu refresh itself. It never sees, changes or blocks any other command, including the ones it runs for you.
  • plugin.register: reads the name, root and provenance of each plugin as it loads, to find the quick-menu.json of plugins loaded with --plugin-dir. It passes every registration on unchanged and never alters or blocks a plugin, its settings, instructions, hooks or tool descriptions.
  • ui.close: observed only, to redraw the footer's menu button when the pane closes. It passes every close on unchanged.
  • session.start: registers /menu, loads favourites and folds, and starts discovery.

For plugin authors

Add .claude-plugin/quick-menu.json to your plugin:

{
  "$schema": "https://raw.githubusercontent.com/agentic-workbench/agent-quick-menu/main/schema/quick-menu.schema.json",
  "version": 1,
  "commands": [
    { "command": "my-plugin:status", "label": "Status", "description": "Show the plugin's state" },
    { "command": "my-plugin:deploy", "label": "Deploy to…", "ask": { "placeholder": "environment", "default": "staging" } }
  ],
  "settings": ["verbose"]
}

That's it: commands become buttons, ask prompts for an argument first, and settings lists which of your userConfig fields to show (omit it to show all). Without agent-quick-menu installed the file is never read. The full format, limits and versioning promise are in docs/convention.md.

Limitations

  • There is no global hotkey and no keyboard shortcut for the band favourites.
  • A --plugin-dir plugin's root is only learned when its hooks module loads after this one (plugin.register; kept across a reload of the menu); a plugin without one, or loaded earlier, is listed from $.command.list() with its commands only. Set CLAUDE_CODE_PLUGIN_DIRS to the same folder to read its quick-menu.json. A --plugin-dir copy shadows an installed copy of the same plugin. The menu reads its own file from its own folder.
  • Discovery runs in the background, after every plugin's session.start and on /menu refresh (which toasts the counts when it is done), so /menu answers at once.
  • Settings that are not shown in /config are not shown here.
  • A favourite setting that is not boolean opens the pane instead of toggling.

Releases

Releases follow semantic versioning: version in plugin.json is the release, tagged as v<version> and listed in CHANGELOG.md. Claude Code updates an install when that version changes, so installs from main get each release.

Development

make check                 # JSON check, claude plugin validate + test, and tsc --noEmit (tsc from PATH, else via npx)
claude --plugin-dir .      # try the plugin from this checkout

Contributing

See CONTRIBUTING.md for branches, commits and tests, and the Code of Conduct.

Privacy

See PRIVACY.md: the plugin collects and sends nothing.

Security

Report vulnerabilities privately, see SECURITY.md.

License

MIT, see LICENSE.

Source 2 files
hooks/quick-menu.tsx 1626 lines
1import { atom, read, update } from 'claude-code'
2import type { CommandInfo, ConfigRow, ConfigValue, Elements, EngineInterface, Register, RenderElement, RenderInput, RenderNode } from 'claude-code'
3
4import type { Favourite, MenuAsk, MenuCommand, MenuFile, MenuProblem, MenuSection, Note, RowState, SectionCommand } from '../types'
5
6const PANE_ID = 'quick-menu'
7const MENU_FILE = '.claude-plugin/quick-menu.json'
8const BUILTIN_PREFIX = 'cc-plugin-'
9const ENGINE = 'engine'
10const MENU_COMMAND = 'menu'
11const REFRESH = 'refresh'
12const DISARMED = { id: '', until: 0 }
13
14// Module state: the atoms live in the engine's `$.state` (they outlive a reload), the `let`s and the map in this instance.
15const sections = atom({ plugin: 'agent-quick-menu', key: 'sections' } as const, [] as MenuSection[])
16const problems = atom({ plugin: 'agent-quick-menu', key: 'problems' } as const, [] as MenuProblem[])
17const favourites = atom({ plugin: 'agent-quick-menu', key: 'favourites' } as const, [] as Favourite[])
18const folded = atom({ plugin: 'agent-quick-menu', key: 'folded' } as const, {} as Record<string, boolean>)
19const unplaced = atom({ plugin: 'agent-quick-menu', key: 'unplaced' } as const, false)
20const filter = atom({ plugin: 'agent-quick-menu', key: 'filter' } as const, '')
21/** The element key of the one choice or text row being edited; '' when none. */
22const openEditor = atom({ plugin: 'agent-quick-menu', key: 'openEditor' } as const, '')
23/** The text in the open text editor, as last typed. */
24const editDraft = atom({ plugin: 'agent-quick-menu', key: 'editDraft' } as const, '')
25const rowState = atom({ plugin: 'agent-quick-menu', key: 'rowState' } as const, {
26  queued: {},
27  notes: {},
28  armed: DISARMED,
29} as RowState)
30const inlineRootState = atom({ plugin: 'agent-quick-menu', key: 'inlineRoots' } as const, {} as Record<string, string>)
31
32let sessionCwd = ''
33let sessionStarted = false
34let discoveryRun = 0
35/**
36 * Roots of `--plugin-dir` plugins, learned from `plugin.register` (the only place the types hand out another plugin's `root`).
37 * `plugin.register` fires once per load of the other plugin, never again when this module reloads, so the roots are kept in
38 * `$.state` (which outlives a reload) as well as here (which outlives a new session in the same process).
39 */
40const inlineRoots = new Map<string, string>()
41
42/** Cells a button takes beyond its label (`[ ` and ` ]`), and the gap that follows it. */
43const BUTTON_CHROME = 4
44const BUTTON_GAP = 2
45/** A star and the space after it; the ` · ` before a description; the least room a description is worth drawing in. */
46const STAR_CELLS = 2
47const SEP_CELLS = 3
48const MIN_HELP_CELLS = 8
49/** The band: the room the engine keeps for its `[-]`, and the dim caption before the favourites. */
50const BAND_RESERVE = 6
51const BAND_CAPTION = '★ favourites:'
52const MAX_RUNS_HINT = 60
53const MAX_TOAST = 200
54const MAX_PROBLEM_PLUGIN = 100
55const MAX_VERSION_TEXT = 20
56const PANE_COLUMNS = 100
57
58/** Limits on a menu file (a plugin's text, so untrusted); the schema mirrors them. */
59const MAX_FILE_BYTES = 64 * 1024
60const MAX_COMMANDS = 50
61const MAX_SETTINGS = 50
62const MAX_FAVOURITES = 50
63const MAX_SHOWN_COMMANDS = 30
64const MAX_PROBLEM = 300
65const CONFIRM_MS = 5000
66const LIMITS = { title: 60, label: 40, command: 64, args: 500, description: 200, setting: 64, askPlaceholder: 60, askDefault: 500, input: 500 } as const
67/** Control, line, bidi and zero-width characters: never shown, never accepted. */
68const BAD_CHARS = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Co}\u{FE00}-\u{FE0F}\u{E0100}-\u{E01EF}]/u
69const BAD_CHARS_ALL = new RegExp(BAD_CHARS.source, 'gu')
70const RESERVED_TITLES = ['claude code', 'favourites', 'built-in']
71
72/** Text from outside, safe to show: bad characters replaced, cut to `max`. */
73const clean = (text: string, max = MAX_PROBLEM): string => text.replace(BAD_CHARS_ALL, '?').slice(0, max)
74
75/** Display width of a label in cells: code points, not UTF-16 units. */
76function width(text: string): number {
77  return [...text].length
78}
79
80/** Cut to `max` characters, ending in an ellipsis. */
81const clip = (text: string, max: number): string => (text.length > max ? `${text.slice(0, max - 1)}…` : text)
82
83const pad = (text: string, to: number): string => text + ' '.repeat(Math.max(0, to - width(text)))
84
85/** A path the engine's registry or the environment names must be absolute. */
86const isAbsolutePath = (p: string): boolean => p.startsWith('/') || /^[A-Za-z]:[\\/]/.test(p)
87
88type Validation = { ok: true; value: MenuFile } | { ok: false; error: string }
89
90const isObject = (v: unknown): v is Record<string, unknown> =>
91  typeof v === 'object' && v !== null && !Array.isArray(v)
92
93/** Why `value` is not an acceptable menu string, or null. A lone `title`, `label` or `command` must hold more than blanks. */
94function textError(where: string, value: unknown, max: number, nonBlank = false): string | null {
95  if (typeof value !== 'string') return `${where} must be a ${nonBlank ? 'non-empty ' : ''}string`
96  if (nonBlank && value.trim() === '') return `${where} must be a non-empty string`
97  if (BAD_CHARS.test(value)) return `${where} holds a control, line-break, bidi or zero-width character`
98  if ([...value].length > max) return `${where} is longer than ${max} characters`
99  return null
100}
101
102function validateAsk(where: string, ask: unknown): { ok: true; value: MenuAsk } | { ok: false; error: string } {
103  if (!isObject(ask)) return { ok: false, error: `${where} must be an object` }
104  const value: MenuAsk = {}
105  for (const [key, max] of [['placeholder', LIMITS.askPlaceholder], ['default', LIMITS.askDefault]] as const) {
106    const field = ask[key]
107    if (field === undefined) continue
108    const error = textError(`${where}.${key}`, field, max)
109    if (error) return { ok: false, error }
110    value[key] = field as string
111  }
112  return { ok: true, value }
113}
114
115function validateCommand(c: unknown, i: number): { ok: true; value: MenuCommand } | { ok: false; error: string } {
116  if (!isObject(c)) return { ok: false, error: `commands[${i}] must be an object` }
117  const bad = textError(`commands[${i}].command`, c.command, LIMITS.command, true)
118  if (bad) return { ok: false, error: bad }
119  const value: MenuCommand = { command: c.command as string }
120  for (const key of ['label', 'args', 'description'] as const) {
121    const field = c[key]
122    if (field === undefined) continue
123    const error = textError(`commands[${i}].${key}`, field, LIMITS[key], key === 'label')
124    if (error) return { ok: false, error }
125    value[key] = field as string
126  }
127  if (c.ask !== undefined) {
128    const asked = validateAsk(`commands[${i}].ask`, c.ask)
129    if (!asked.ok) return asked
130    value.ask = asked.value
131  }
132  return { ok: true, value }
133}
134
135/** Validates a menu file; `reserved` are the lower-cased names a title may not take (other plugins'). */
136export function validateMenuFile(json: unknown, reserved: readonly string[] = []): Validation {
137  if (!isObject(json)) return { ok: false, error: 'menu file must be a JSON object' }
138  if (json.version !== 1) {
139    return { ok: false, error: `unsupported version ${clean(String(JSON.stringify(json.version)), MAX_VERSION_TEXT)} (expected 1)` }
140  }
141  if (json.title !== undefined) {
142    const bad = textError('title', json.title, LIMITS.title, true)
143    if (bad) return { ok: false, error: bad }
144    if ([...RESERVED_TITLES, ...reserved].includes((json.title as string).trim().toLowerCase())) {
145      return { ok: false, error: 'title is reserved (Claude Code, Favourites, built-in or another plugin)' }
146    }
147  }
148  let commands: MenuCommand[] = []
149  if (json.commands !== undefined) {
150    if (!Array.isArray(json.commands)) return { ok: false, error: 'commands must be an array' }
151    if (json.commands.length > MAX_COMMANDS) return { ok: false, error: `commands has more than ${MAX_COMMANDS} entries` }
152    commands = []
153    for (const [i, c] of json.commands.entries()) {
154      const r = validateCommand(c, i)
155      if (!r.ok) return r
156      commands.push(r.value)
157    }
158  }
159  let settings: string[] | null = null
160  if (json.settings !== undefined) {
161    if (!Array.isArray(json.settings)) return { ok: false, error: 'settings must be an array of non-empty strings' }
162    if (json.settings.length > MAX_SETTINGS) return { ok: false, error: `settings has more than ${MAX_SETTINGS} entries` }
163    for (const [i, name] of json.settings.entries()) {
164      const bad = name === '' ? `settings[${i}] must be a non-empty string` : textError(`settings[${i}]`, name, LIMITS.setting)
165      if (bad) return { ok: false, error: bad }
166    }
167    settings = [...(json.settings as string[])]
168  }
169  return { ok: true, value: { version: 1, title: json.title as string | undefined, commands, settings } }
170}
171
172const message = (err: unknown): string => (err instanceof Error ? err.message : String(err))
173
174const decodeText = (raw: string | Uint8Array): string => (typeof raw === 'string' ? raw : new TextDecoder().decode(raw))
175
176async function readJson($: EngineInterface, path: string): Promise<unknown> {
177  return JSON.parse(decodeText(await $.fs.read(path)))
178}
179
180type Target = { name: string; root: string }
181
182/** The plugin name without its `@<marketplace>` suffix. */
183const bareName = (id: string): string => id.split('@')[0] ?? id
184
185const PLUGIN_DIR_NOTE = 'loaded with --plugin-dir: set CLAUDE_CODE_PLUGIN_DIRS to read its quick-menu.json'
186
187/** Plugins whose commands are not claimed by a built-in prefix or the engine. */
188const isForeign = (name: string): boolean => name !== ENGINE && !name.startsWith(BUILTIN_PREFIX)
189
190/** The install entry for this session: a project or local one for the cwd, else the user one (no other scope applies here). */
191function chooseEntry(entries: unknown): Record<string, unknown> | undefined {
192  if (!Array.isArray(entries)) return undefined
193  const objects = entries.filter(isObject)
194  const here = objects.find(
195    x => (x.scope === 'project' || x.scope === 'local') && sessionCwd !== '' && x.projectPath === sessionCwd,
196  )
197  return here ?? objects.find(x => x.scope === 'user')
198}
199
200/** The bare names of the plugins that are loaded: those with a registered command or a `userConfig` row (engine rows excluded). */
201async function loadedPlugins($: EngineInterface, listed: readonly CommandInfo[], issues: MenuProblem[]): Promise<Set<string>> {
202  const names = new Set<string>()
203  for (const c of listed) if (c.source === 'plugin' && c.plugin) names.add(bareName(c.plugin))
204  try {
205    for (const row of await $.config.list()) {
206      if (row.provider.plugin !== ENGINE) names.add(bareName(row.key.split('.')[0] ?? row.key))
207    }
208  } catch (err) {
209    issues.push({ plugin: 'config', message: `cannot list config rows: ${message(err)}` })
210  }
211  return names
212}
213
214async function registryTargets(
215  $: EngineInterface,
216  listed: readonly CommandInfo[],
217  issues: MenuProblem[],
218): Promise<Target[]> {
219  const loaded = await loadedPlugins($, listed, issues)
220  if (loaded.size === 0) return []
221  const configDir = await $.env.get('CLAUDE_CONFIG_DIR')
222  let base: string
223  if (configDir) {
224    if (!isAbsolutePath(configDir)) {
225      issues.push({ plugin: 'CLAUDE_CONFIG_DIR', message: 'must be an absolute path; plugin registry skipped' })
226      return []
227    }
228    base = configDir
229  } else {
230    const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
231    if (!home || !isAbsolutePath(home)) return []
232    base = `${home.replace(/[\\/]+$/, '')}/.claude`
233  }
234  const registryPath = `${base}/plugins/installed_plugins.json`
235  let plugins: Record<string, unknown> = {}
236  try {
237    const json = await readJson($, registryPath)
238    if (isObject(json) && isObject(json.plugins)) plugins = json.plugins
239  } catch (err) {
240    issues.push({ plugin: 'installed_plugins.json', message: `cannot read ${registryPath}: ${message(err)}` })
241    return []
242  }
243  const targets: Target[] = []
244  // Several marketplaces can ship one bare name: the entry installed for this project or local scope wins, else the first user one.
245  const chosen = new Map<string, { id: string; entry: Record<string, unknown>; isHere: boolean }>()
246  for (const id of Object.keys(plugins)) {
247    const name = bareName(id)
248    const entry = chooseEntry(plugins[id])
249    if (!loaded.has(name) || !entry) continue
250    const isHere = entry.scope !== 'user'
251    const held = chosen.get(name)
252    if (!held || (isHere && !held.isHere)) chosen.set(name, { id, entry, isHere })
253  }
254  for (const { id, entry } of chosen.values()) {
255    if (typeof entry.installPath !== 'string') continue
256    if (!isAbsolutePath(entry.installPath)) {
257      issues.push({ plugin: bareName(id), message: 'installPath is not absolute; skipped' })
258      continue
259    }
260    targets.push({ name: bareName(id), root: entry.installPath })
261  }
262  return targets
263}
264
265/** `--plugin-dir` folders: `CLAUDE_CODE_PLUGIN_DIRS`, then the roots `plugin.register` handed out. */
266async function dirTargets($: EngineInterface, issues: MenuProblem[]): Promise<Target[]> {
267  const raw = await $.env.get('CLAUDE_CODE_PLUGIN_DIRS')
268  const targets: Target[] = []
269  // `;` separates the folders where a drive letter holds a colon (Windows), `:` elsewhere.
270  const separator = /^[A-Za-z]:[\\/]/.test(raw ?? '') || (raw ?? '').includes(';') ? ';' : ':'
271  for (const root of (raw ?? '').split(separator).filter(Boolean)) {
272    try {
273      if (!isAbsolutePath(root)) throw new Error('not an absolute path')
274      const manifest = await readJson($, `${root}/.claude-plugin/plugin.json`)
275      const name = isObject(manifest) ? manifest.name : undefined
276      if (typeof name !== 'string' || name === '') throw new Error('plugin.json has no name')
277      targets.push({ name, root })
278    } catch (err) {
279      issues.push({ plugin: root, message: `cannot identify plugin dir: ${message(err)}` })
280    }
281  }
282  let kept: Record<string, string> = {}
283  try {
284    kept = await read($, inlineRootState)
285  } catch {
286    // The kept roots are a convenience: without them only the in-process ones count.
287  }
288  for (const [name, root] of [...inlineRoots, ...Object.entries(kept)]) if (isAbsolutePath(root)) targets.push({ name, root })
289  return targets
290}
291
292async function readMenuFile(
293  $: EngineInterface,
294  target: Target,
295  issues: MenuProblem[],
296  reserved: readonly string[],
297): Promise<MenuFile | null> {
298  const path = `${target.root}/${MENU_FILE}`
299  const problem = (text: string): null => (issues.push({ plugin: target.name, message: `${path}: ${text}` }), null)
300  try {
301    if (!(await $.fs.exists(path))) return null
302    const stat = await $.fs.stat(path)
303    if (stat.kind !== 'file' || stat.isLink) return problem('must be a regular file, not a link')
304    if (stat.size > MAX_FILE_BYTES) return problem(`larger than ${MAX_FILE_BYTES / 1024} KiB`)
305    const text = decodeText(await $.fs.read(path))
306    if (text.length > MAX_FILE_BYTES) return problem(`larger than ${MAX_FILE_BYTES / 1024} KiB`)
307    let json: unknown
308    try {
309      json = JSON.parse(text)
310    } catch {
311      return problem('not valid JSON')
312    }
313    const result = validateMenuFile(json, reserved)
314    return result.ok ? result.value : problem(result.error)
315  } catch (err) {
316    return problem(message(err))
317  }
318}
319
320/** The file's title, with the plugin's name after it unless that is the title already. */
321function sectionTitle(title: string | undefined, name: string): string {
322  if (title === undefined) return name
323  return title.toLowerCase() === name.toLowerCase() ? title : `${title} · ${name}`
324}
325
326/** Plugins the registry and the known roots do not explain (a `--plugin-dir` one): their registered commands, no menu file. */
327function commandSections(listed: readonly CommandInfo[], targets: readonly Target[]): MenuSection[] {
328  const known = new Set(targets.map(t => t.name))
329  const byPlugin = new Map<string, SectionCommand[]>()
330  for (const c of listed) {
331    if (c.source !== 'plugin' || !c.plugin) continue
332    const name = bareName(c.plugin)
333    if (known.has(name) || !isForeign(name)) continue
334    const list = byPlugin.get(name) ?? []
335    list.push({
336      command: c.name,
337      label: c.name,
338      ...(c.description !== '' && { description: c.description }),
339      isAvailable: true,
340      source: c.source,
341      owner: name,
342    })
343    byPlugin.set(name, list)
344  }
345  return [...byPlugin].map(([plugin, commands]) => ({
346    plugin,
347    title: plugin,
348    commands,
349    settings: null,
350    source: 'commands' as const,
351    note: PLUGIN_DIR_NOTE,
352  }))
353}
354
355/** What a listed command (or its absence) says about a menu command. */
356function availability(info: CommandInfo | undefined): Pick<SectionCommand, 'isAvailable' | 'source' | 'owner'> {
357  if (!info) return { isAvailable: false }
358  return { isAvailable: true, source: info.source, ...(info.plugin !== undefined && { owner: bareName(info.plugin) }) }
359}
360
361/** The file sections' commands marked against `listed`; the sections of commands the engine itself lists stay. */
362function applyListing(fileSections: readonly MenuSection[], listed: readonly CommandInfo[]): MenuSection[] {
363  const byName = new Map(listed.map(c => [c.name, c]))
364  return fileSections.map(section =>
365    section.source !== 'file'
366      ? section
367      : {
368          ...section,
369          commands: section.commands.map(({ source: _s, owner: _o, ...c }) => ({ ...c, ...availability(byName.get(c.command)) })),
370        },
371  )
372}
373
374/**
375 * Lists the commands afresh and marks every section against that, so a command registered after discovery (plugins
376 * register one after another in their session.start) shows as available. Null when the engine cannot list them.
377 */
378async function refreshListing($: EngineInterface): Promise<CommandInfo[] | null> {
379  let listed: CommandInfo[]
380  try {
381    listed = await $.command.list()
382  } catch {
383    return null
384  }
385  await update($, sections, s => applyListing(s, listed))
386  return listed
387}
388
389/**
390 * Reads every enabled plugin's menu file and builds the file sections; failures become problems.
391 * The first root per name wins: this plugin's own (`$.plugin.root`, which `plugin.register` never hands it), then the
392 * `--plugin-dir` folders, then the installed copies they shadow.
393 */
394async function discover($: EngineInterface): Promise<{ sections: MenuSection[]; problems: MenuProblem[] }> {
395  const issues: MenuProblem[] = []
396  let listed: CommandInfo[] = []
397  try {
398    listed = await $.command.list()
399  } catch (err) {
400    issues.push({ plugin: 'commands', message: `cannot list commands: ${message(err)}` })
401  }
402  const all = [
403    { name: $.plugin.name, root: $.plugin.root },
404    ...(await dirTargets($, issues)),
405    ...(await registryTargets($, listed, issues)),
406  ]
407  const targets = all.filter((t, i) => all.findIndex(o => o.name === t.name) === i)
408
409  const byName = new Map(listed.map(c => [c.name, c]))
410  const result: MenuSection[] = []
411  for (const target of targets) {
412    const reserved = targets.filter(t => t.name !== target.name).map(t => bareName(t.name).toLowerCase())
413    const file = await readMenuFile($, target, issues, reserved)
414    if (file) {
415      result.push({
416        plugin: target.name,
417        title: sectionTitle(file.title, target.name),
418        commands: file.commands.map(c => ({
419          command: c.command,
420          label: c.label ?? c.command,
421          ...(c.args !== undefined && { args: c.args }),
422          ...(c.description !== undefined && { description: c.description }),
423          ...(c.ask !== undefined && { ask: c.ask }),
424          ...availability(byName.get(c.command)),
425        })),
426        settings: file.settings,
427        source: 'file',
428      })
429    }
430  }
431  result.push(...commandSections(listed, targets))
432  result.sort((a, b) => a.title.localeCompare(b.title))
433  return { sections: result, problems: issues.map(p => ({ plugin: clean(p.plugin, MAX_PROBLEM_PLUGIN), message: clean(p.message) })) }
434}
435
436/** Discovers and stores the sections; a run overtaken by a later one leaves the later one's answer standing. */
437async function runDiscovery($: EngineInterface): Promise<{ sections: number; problems: number } | null> {
438  const run = ++discoveryRun
439  const discovered = await discover($)
440  if (run !== discoveryRun) return null
441  await update($, sections, () => discovered.sections)
442  await update($, problems, () => discovered.problems)
443  // The listing discovery took may predate a registration still under way; mark against a fresh one.
444  await refreshListing($)
445  $.ui.invalidate('ui.render')
446  return { sections: discovered.sections.length, problems: discovered.problems.length }
447}
448
449/** Starts a discovery without holding the dispatch that asked for it; `announce` toasts its counts, failures always toast. */
450function startDiscovery($: EngineInterface, announce = false): void {
451  void runDiscovery($).then(
452    counts => {
453      if (counts && announce) $.ui.toast(`Quick menu refreshed: ${counts.sections} sections, ${counts.problems} problems`)
454    },
455    err => $.ui.toast(toastLine(`Quick menu: discovery failed: ${message(err)}`)),
456  )
457}
458
459/** `/menu`: opens the pane, or starts a refresh, and answers at once; discovery runs on its own and redraws when it lands. */
460async function openMenu($: EngineInterface, args: string): Promise<{ text: string }> {
461  if (args.trim() === REFRESH) {
462    startDiscovery($, true)
463    return { text: 'Quick menu: discovering plugin menus again' }
464  }
465  // A load without a session.start (an update or enable mid-session) has not discovered yet: the pane would list no commands.
466  if (discoveryRun === 0) startDiscovery($)
467  await openPane($)
468  return { text: 'Quick menu opened' }
469}
470
471/** Commands this plugin registers; the menu answers them itself (runAny). */
472const OWN_COMMANDS = new Set([MENU_COMMAND])
473
474/**
475 * Runs a command a row or the band names. This plugin's own are answered here: the engine leaves a plugin's own hooks
476 * out of the `command.run` its own `$.command.run` raises (re-entry), so that call would reach the engine's
477 * "registered /menu but no command.run hook answered it".
478 */
479async function runAny($: EngineInterface, c: SectionCommand): Promise<{ text?: string }> {
480  if (OWN_COMMANDS.has(c.command)) {
481    const answer = await openMenu($, c.args ?? '')
482    // A refresh toasts its counts when discovery lands; a toast now would push that one out (toasts are spaced 2 s).
483    return (c.args ?? '').trim() === REFRESH ? {} : answer
484  }
485  return $.command.run({ command: c.command, ...(c.args !== undefined && { args: c.args }) })
486}
487
488const commandKey = (plugin: string, c: SectionCommand): string => `cmd:${plugin}:${c.command}:${c.args ?? ''}`
489const settingKey = (key: string): string => `set:${key}`
490
491const toastLine = (text: string): string => (text.split('\n')[0] ?? '').slice(0, MAX_TOAST)
492
493async function setNote($: EngineInterface, id: string, note: Note | null): Promise<void> {
494  await update($, rowState, s => {
495    const notes = { ...s.notes }
496    if (note) notes[id] = note
497    else delete notes[id]
498    return { ...s, notes }
499  })
500}
501
502async function setQueued($: EngineInterface, id: string, isQueued: boolean): Promise<void> {
503  await update($, rowState, s => {
504    const queued = { ...s.queued }
505    if (isQueued) queued[id] = true
506    else delete queued[id]
507    return { ...s, queued }
508  })
509}
510
511/** Who a command belongs to, when that is not the section's own plugin: the tag shown beside it and the reason to confirm. */
512function runsTag(plugin: string, c: SectionCommand): string | null {
513  if (!c.isAvailable) return null
514  if (c.source === 'plugin' && c.owner !== undefined && c.owner === bareName(plugin)) return null
515  if (c.source === 'builtin') return 'runs a built-in'
516  if (c.source === 'plugin' && c.owner !== undefined) return `runs ${c.owner}`
517  if (c.source === 'user') return 'runs a user command'
518  if (c.source === 'mcp') return 'runs an MCP command'
519  return 'runs a command of unknown origin'
520}
521
522/** Ends an arming that nobody confirmed in time; a newer arming stands. */
523async function disarm($: EngineInterface, id: string, until: number): Promise<void> {
524  await update($, rowState, st => (st.armed.id === id && st.armed.until === until ? { ...st, armed: DISARMED } : st))
525  $.ui.invalidate('ui.render')
526}
527
528/** Waits out the confirm window, then ends that arming. */
529async function expireArming($: EngineInterface, id: string, until: number): Promise<void> {
530  try {
531    await $.clock.sleep(CONFIRM_MS)
532    await disarm($, id, until)
533  } catch {
534    // The module was unloaded or the session ended while waiting: nothing is left to disarm.
535  }
536}
537
538/** Where a command button sits: the key prefix of its rows, whether a band press, and whether the surface can draw an Input. */
539type PressSite = { prefix: string; fromBand: boolean; canAsk: boolean }
540const PANE_SITE: PressSite = { prefix: '', fromBand: false, canAsk: true }
541
542/**
543 * The confirm-twice of a command of another plugin or a built-in: the first call arms (the button reads "press again: /x") and
544 * answers false; a second within 5 s answers true. True at once for a command of the section's own plugin.
545 */
546async function isConfirmed($: EngineInterface, plugin: string, c: SectionCommand): Promise<boolean> {
547  if (runsTag(plugin, c) === null) return true
548  const id = commandKey(plugin, c)
549  const now = await $.clock.now()
550  const armed = (await read($, rowState)).armed
551  if (armed.id !== id || now >= armed.until) {
552    const until = now + CONFIRM_MS
553    await update($, rowState, st => ({ ...st, armed: { id, until } }))
554    void expireArming($, id, until)
555    $.ui.invalidate('ui.render')
556    return false
557  }
558  await update($, rowState, st => ({ ...st, armed: DISARMED }))
559  return true
560}
561
562/**
563 * A press on a button. A command of another plugin or a built-in first arms (the button reads "press again: /x") and runs
564 * on a second press within 5 s, in the pane or the band alike. A command with `ask` opens its row editor instead; the
565 * confirming press is then the one on `✓ run`.
566 */
567async function pressCommand($: EngineInterface, plugin: string, shown: SectionCommand, site: PressSite = PANE_SITE): Promise<void> {
568  let c = shown
569  if (!c.isAvailable) {
570    // Marked unavailable by a listing that may predate its registration: ask again before refusing.
571    const listed = await refreshListing($)
572    const info = listed?.find(x => x.name === c.command)
573    if (!info) {
574      $.ui.toast(toastLine(`Quick menu: /${c.command} is not available`))
575      $.ui.invalidate('ui.render')
576      return
577    }
578    c = { ...c, ...availability(info) }
579    $.ui.invalidate('ui.render')
580  }
581  if (c.ask && site.canAsk) {
582    await openAsk($, plugin, c, site)
583    return
584  }
585  if (await isConfirmed($, plugin, c)) await runCommand($, plugin, c)
586}
587
588/** The fixed args, then what was typed, as one trimmed string; undefined when both are empty. */
589function joinArgs(fixed: string | undefined, input: string): string | undefined {
590  const joined = `${(fixed ?? '').trim()} ${input.trim()}`.trim()
591  return joined === '' ? undefined : joined
592}
593
594/**
595 * Opens the editor of a command with `ask`, its default typed in. From the band the pane opens first, with the pinned list
596 * unfolded, since a band row has no room for an input.
597 */
598async function openAsk($: EngineInterface, plugin: string, c: SectionCommand, site: PressSite): Promise<void> {
599  const id = site.prefix + commandKey(plugin, c)
600  if (site.fromBand) {
601    await openPane($)
602    const stored = await storedValue($, FOLDS)
603    if (isFolded(stored, FAV_ID)) await writeFolds($, { ...stored, [FAV_ID]: false })
604  }
605  await setNote($, commandKey(plugin, c), null)
606  await update($, rowState, st => ({ ...st, armed: DISARMED }))
607  await setOpenEditor($, id, c.ask?.default ?? '', id)
608}
609
610/**
611 * `✓ run` or Enter in an ask editor: invalid input says so and stays open; otherwise the confirm-twice of a foreign command
612 * (the editor stays open for the second press), then the editor closes and `/command <args> <input>` runs.
613 */
614async function runAsked($: EngineInterface, plugin: string, c: SectionCommand, raw: string): Promise<void> {
615  const rid = commandKey(plugin, c)
616  const bad = textError('input', raw, LIMITS.input)
617  if (bad) {
618    await setNote($, rid, { kind: 'error', text: bad, shown: '' })
619    await setDraft($, raw)
620    $.ui.invalidate('ui.render')
621    return
622  }
623  await setNote($, rid, null)
624  if (!(await isConfirmed($, plugin, c))) return
625  await putEditor($, '')
626  const args = joinArgs(c.args, raw)
627  const { args: _fixed, ...bare } = c
628  await runCommand($, plugin, args === undefined ? bare : { ...bare, args }, rid)
629}
630
631/** `✕ cancel` in an ask editor: closes it, runs nothing. */
632async function cancelAsk($: EngineInterface, plugin: string, c: SectionCommand): Promise<void> {
633  await setNote($, commandKey(plugin, c), null)
634  await update($, rowState, st => ({ ...st, armed: DISARMED }))
635  await setOpenEditor($, '')
636}
637
638async function runCommand($: EngineInterface, plugin: string, c: SectionCommand, id = commandKey(plugin, c)): Promise<void> {
639  let claimed = false
640  await update($, rowState, s => {
641    claimed = false
642    if (s.queued[id]) return s
643    claimed = true
644    return { ...s, queued: { ...s.queued, [id]: true as const } }
645  })
646  if (!claimed) return
647  $.ui.invalidate('ui.render')
648  try {
649    const result = await runAny($, c)
650    if (result.text) $.ui.toast(toastLine(result.text))
651  } catch (err) {
652    $.ui.toast(toastLine(message(err)))
653  } finally {
654    await setQueued($, id, false)
655    $.ui.invalidate('ui.render')
656  }
657}
658
659/** A value as `/config key=value` takes it: bare text (quotes would become part of the value), a list comma-joined. */
660const configArg = (value: ConfigValue): string => (typeof value === 'object' ? value.join(',') : String(value))
661
662/** The row as `$.config.list()` has it now; undefined when it is gone or the list cannot be read. */
663async function currentRow($: EngineInterface, key: string): Promise<ConfigRow | undefined> {
664  try {
665    return (await $.config.list()).find(r => r.key === key)
666  } catch {
667    return undefined
668  }
669}
670
671/**
672 * Writes one row through `/config <key>=<value>`, then reads the row back: the value it holds decides, not the reply.
673 * In an interactive session `$.command.run` resolves `{}` for `/config` (its "Set <label> to <value>" goes to the
674 * transcript only), so the reply text cannot tell a write from a refusal; a headless one answers with text.
675 */
676async function writeSetting($: EngineInterface, row: ConfigRow, value: ConfigValue): Promise<void> {
677  const id = settingKey(row.key)
678  const before = configArg(row.value)
679  await setNote($, id, null)
680  try {
681    const result = await $.command.run({ command: 'config', args: `${row.key}=${configArg(value)}` })
682    const text = (result.text ?? '').trim()
683    const now = await currentRow($, row.key)
684    if (now) {
685      const after = configArg(now.value)
686      // Written (as asked, or as a config.set hook clamped it), or asked for the value it already held: no refusal.
687      if (after !== configArg(value) && after === before) await setNote($, id, { kind: 'deny', text: text || 'not changed', shown: after })
688    } else if (text !== '' && !text.startsWith('Set ') && !/not changed|already/i.test(text)) {
689      // The row cannot be read back: only a reply that is no write and no "unchanged" is a refusal.
690      await setNote($, id, { kind: 'deny', text, shown: before })
691    }
692  } catch (err) {
693    await setNote($, id, { kind: 'error', text: message(err), shown: before })
694  }
695  $.ui.invalidate('ui.render')
696}
697
698/** Sets the open editor and its draft without drawing: the caller draws once, after what it does next. */
699async function putEditor($: EngineInterface, id: string, draft = ''): Promise<void> {
700  await update($, openEditor, () => id)
701  await update($, editDraft, () => draft)
702}
703
704/**
705 * Opens the editor of row `id` (with `draft` in a text one), or closes every one with ''; draws. `focusKey`: the element that
706 * takes the focus ring once drawn (the pressed button keeps it otherwise, and `autoFocus` does not move a ring the press set).
707 */
708async function setOpenEditor($: EngineInterface, id: string, draft = '', focusKey = ''): Promise<void> {
709  await putEditor($, id, draft)
710  $.ui.invalidate('ui.render')
711  // Best effort: a test engine with no site holding the keys has nothing behind `ui.focus` and rejects.
712  if (id && focusKey) await $.ui.focus({ requestId: PANE_ID, key: focusKey }).catch(() => ({}))
713}
714
715/** Keeps what the person typed, so the save button knows it; the field already shows it, so no redraw. */
716async function setDraft($: EngineInterface, text: string): Promise<void> {
717  await update($, editDraft, () => text)
718}
719
720/** A pick of another option in an open choice row: folds the row back to `value ▾`, then writes the value (and draws). */
721async function pickChoice($: EngineInterface, row: ConfigRow, value: string): Promise<void> {
722  await putEditor($, '')
723  await writeSetting($, row, value)
724}
725
726/**
727 * Saves a text or number editor: an unchanged value just closes it, an invalid number says so and stays open, anything else
728 * closes it and writes once.
729 */
730async function saveEdit($: EngineInterface, row: ConfigRow, raw: string): Promise<void> {
731  let value: ConfigValue = raw
732  if (row.kind === 'number') {
733    const n = Number(raw)
734    if (raw.trim() === '' || !Number.isFinite(n)) {
735      await setNote($, settingKey(row.key), { kind: 'error', text: `"${raw}" is not a number`, shown: configArg(row.value) })
736      await setDraft($, raw)
737      $.ui.invalidate('ui.render')
738      return
739    }
740    value = n
741  }
742  if (configArg(value) === configArg(row.value)) {
743    await setOpenEditor($, '')
744    return
745  }
746  await putEditor($, '')
747  await writeSetting($, row, value)
748}
749
750/** The save button: saves what was last typed. */
751async function saveDraft($: EngineInterface, row: ConfigRow): Promise<void> {
752  await saveEdit($, row, await read($, editDraft))
753}
754
755/** The `✓ run` button: runs what was last typed. */
756async function runAskedDraft($: EngineInterface, plugin: string, c: SectionCommand): Promise<void> {
757  await runAsked($, plugin, c, await read($, editDraft))
758}
759
760/** The cancel button: closes the editor without a write, and drops the note an invalid number left. */
761async function cancelEdit($: EngineInterface, row: ConfigRow): Promise<void> {
762  await setNote($, settingKey(row.key), null)
763  await setOpenEditor($, '')
764}
765
766type Ui = ReturnType<EngineInterface['ui']['resolve']>
767
768const favCommandKey = (c: SectionCommand): string => (c.args ? `${c.command} ${c.args}` : c.command)
769const sameFav = (a: Favourite, b: Favourite): boolean =>
770  a.kind === b.kind && a.plugin === b.plugin && a.key === b.key
771
772function isFavourite(list: readonly Favourite[], f: Favourite): boolean {
773  return list.some(x => sameFav(x, f))
774}
775
776function isFavourites(v: unknown): v is Favourite[] {
777  return (
778    Array.isArray(v) &&
779    v.every(
780      x =>
781        isObject(x) &&
782        (x.kind === 'command' || x.kind === 'setting') &&
783        typeof x.plugin === 'string' &&
784        typeof x.key === 'string',
785    )
786  )
787}
788
789function isFolds(v: unknown): v is Record<string, boolean> {
790  return isObject(v) && Object.values(v).every(x => typeof x === 'boolean')
791}
792
793/** A value kept in `$.store` under `key`; a stored value `guard` refuses reads as `fallback`. */
794type Stored<T> = { key: string; guard: (v: unknown) => v is T; fallback: T; clamp: (v: T) => T }
795
796const FAVOURITES: Stored<Favourite[]> = {
797  key: 'favourites',
798  guard: isFavourites,
799  fallback: [],
800  clamp: list => list.slice(0, MAX_FAVOURITES),
801}
802const FOLDS: Stored<Record<string, boolean>> = { key: 'folded', guard: isFolds, fallback: {}, clamp: v => v }
803
804async function storedValue<T>($: EngineInterface, s: Stored<T>): Promise<T> {
805  try {
806    const stored: unknown = await $.store.get(s.key)
807    return s.guard(stored) ? s.clamp(stored) : s.fallback
808  } catch {
809    return s.fallback
810  }
811}
812
813async function loadFavourites($: EngineInterface): Promise<void> {
814  const stored = await storedValue($, FAVOURITES)
815  await update($, favourites, () => stored)
816}
817
818async function loadFolds($: EngineInterface): Promise<void> {
819  const stored = await storedValue($, FOLDS)
820  await update($, folded, () => stored)
821}
822
823/** Writes `$.store`; a failure toasts and answers false. */
824async function saveStore($: EngineInterface, key: string, value: unknown): Promise<boolean> {
825  try {
826    await $.store.set(key, value)
827    return true
828  } catch (err) {
829    $.ui.toast(toastLine(`Quick menu: cannot save ${key}: ${message(err)}`))
830    return false
831  }
832}
833
834/** The stores are written and mirrored in their atoms alike: save, then the atom, then a draw; a failed save changes neither. */
835async function writeFavourites($: EngineInterface, next: Favourite[]): Promise<void> {
836  if (!(await saveStore($, FAVOURITES.key, next))) return
837  await update($, favourites, () => next)
838  $.ui.invalidate('ui.render')
839}
840
841async function writeFolds($: EngineInterface, next: Record<string, boolean>): Promise<void> {
842  if (!(await saveStore($, FOLDS.key, next))) return
843  await update($, folded, () => next)
844  $.ui.invalidate('ui.render')
845}
846
847async function toggleFavourite($: EngineInterface, f: Favourite): Promise<void> {
848  const list = await storedValue($, FAVOURITES)
849  const pinned = isFavourite(list, f)
850  if (!pinned && list.length >= MAX_FAVOURITES) {
851    $.ui.toast(`Quick menu: at most ${MAX_FAVOURITES} favourites`)
852    return
853  }
854  await writeFavourites($, pinned ? list.filter(x => !sameFav(x, f)) : [...list, f])
855}
856
857/** What a row is drawn from: the context every row of a block shares. `hasFields`: the surface draws Input (the mobile table has none; its stand-ins draw nothing). `pad`: the block's longest setting label; `cmdPad`: its longest command label; `open`: the key of the row whose editor is open; `draft`: the text in a text editor. */
858type RowCtx = {
859  list: readonly Favourite[]
860  prefix: string
861  hasFields: boolean
862  pad: number
863  cmdPad: number
864  open: string
865  draft: string
866  columns: number
867}
868
869function renderStar($: EngineInterface, ui: Ui, f: Favourite, id: string, ctx: RowCtx) {
870  const { Button } = ui
871  const pinned = isFavourite(ctx.list, f)
872  return (
873    <Button
874      key={`${ctx.prefix}star:${id}`}
875      label={pinned ? '★' : '☆'}
876      plain
877      {...(pinned ? {} : { dimColor: true })}
878      onPress={() => void toggleFavourite($, f)}
879    />
880  )
881}
882
883/** The dim ` · description` that takes what is left of the line and truncates; nothing when there is none. */
884function renderHelp(ui: Ui, help: string | null) {
885  if (help === null) return null
886  const { Box, Text } = ui
887  return (
888    <Box flexDirection="row" flexShrink={1}>
889      <Text dimColor>{' · '}</Text>
890      <Box flexShrink={1}>
891        <Text dimColor italic wrap="truncate-end">{help}</Text>
892      </Box>
893    </Box>
894  )
895}
896
897/** `text` cut to `room` cells, or null when it is missing or `room` is under MIN_HELP_CELLS. */
898const helpFor = (text: string | undefined, room: number): string | null =>
899  text && room >= MIN_HELP_CELLS ? clip(text, room) : null
900
901/** The Input element of a surface that has one (the mobile table has none). */
902const inputOf = (ui: Ui) => (ui as Partial<Pick<Elements['terminal'], 'Input'>>).Input
903
904/** A row's control and the cells it takes (label included), so the description gets what is left. An open editor takes the line. */
905type Drawn = { control: RenderNode; cells: number }
906const WHOLE_LINE = Number.POSITIVE_INFINITY
907
908/** What one setting row shows: its key in the tree, the padded label and the value as text. */
909type SettingView = { row: ConfigRow; id: string; label: string; shown: string }
910
911/** Folded to `value ▾`; pressed, `value ▴` and a row of option Buttons (`● current`, `○ other`), or under the value when the line is too short. */
912function renderChoice($: EngineInterface, ui: Ui, v: SettingView, options: readonly string[], ctx: RowCtx): Drawn {
913  const { Box, Text, Button } = ui
914  const { row, id, label, shown } = v
915  const open = ctx.open === id
916  const toggle = (
917    <Button key={id} label={`${shown} ${open ? '▴' : '▾'}`} plain onPress={() => void setOpenEditor($, open ? '' : id, '', `${id}:${shown}`)} />
918  )
919  const head = (
920    <Box key={`choice:${id}`} flexDirection="row">
921      <Text>{`${label}  `}</Text>
922      {toggle}
923    </Box>
924  )
925  if (!open) return { control: head, cells: width(label) + 2 + width(shown) + 2 }
926  const buttons = options.map(value => (
927    <Box key={`opt:${id}:${value}`} flexDirection="row">
928      <Text> </Text>
929      <Button
930        key={`${id}:${value}`}
931        label={`${value === shown ? '●' : '○'} ${value}`}
932        plain
933        autoFocus={value === shown ? true : undefined}
934        onPress={() => void (value === shown ? setOpenEditor($, '') : pickChoice($, row, value))}
935      />
936    </Box>
937  ))
938  const optionsWidth = options.reduce((sum, value) => sum + 1 + 2 + width(value), 0)
939  const indent = STAR_CELLS + width(label) + 2
940  const fits = indent + width(shown) + 2 + optionsWidth <= ctx.columns
941  const control = fits ? (
942    <Box key={`choice:${id}`} flexDirection="row">
943      <Text>{`${label}  `}</Text>
944      {toggle}
945      {buttons}
946    </Box>
947  ) : (
948    <Box key={`choice:${id}`} flexDirection="column">
949      {head}
950      <Box flexDirection="row">
951        <Text>{' '.repeat(indent - STAR_CELLS)}</Text>
952        {buttons}
953      </Box>
954    </Box>
955  )
956  return { control, cells: WHOLE_LINE }
957}
958
959/** A text or number value: `value ✎` until pressed, then an Input with save and cancel; Enter saves. */
960function renderTextEdit($: EngineInterface, ui: Ui, v: SettingView, Input: NonNullable<ReturnType<typeof inputOf>>, ctx: RowCtx): Drawn {
961  const { Box, Text, Button } = ui
962  const { row, id, label, shown } = v
963  if (ctx.open !== id) {
964    const control = (
965      <Box key={`field:${id}`} flexDirection="row">
966        <Text>{`${label}  `}</Text>
967        <Button key={id} label={`${shown} ✎`} plain onPress={() => void setOpenEditor($, id, shown, id)} />
968      </Box>
969    )
970    return { control, cells: width(label) + 2 + width(shown) + 2 }
971  }
972  // The label is its own Text: an Input's own label draws a `: ` before the value.
973  const control = (
974    <Box key={`field:${id}`} flexDirection="row" columnGap={1}>
975      <Text>{`${label} `}</Text>
976      <Input
977        key={id}
978        value={ctx.draft}
979        autoFocus
980        onInput={(value: string) => void setDraft($, value)}
981        onSubmit={(value: string) => void saveEdit($, row, value)}
982      />
983      <Button key={`${id}:save`} label="✓ save" onPress={() => void saveDraft($, row)} />
984      <Button key={`${id}:cancel`} label="✕ cancel" onPress={() => void cancelEdit($, row)} />
985    </Box>
986  )
987  return { control, cells: WHOLE_LINE }
988}
989
990function renderBoolean($: EngineInterface, ui: Ui, v: SettingView): Drawn {
991  const { Box, Text, Button } = ui
992  const { row, id, label } = v
993  const word = row.value ? 'on' : 'off'
994  const control = (
995    <Box key={`bool:${id}`} flexDirection="row">
996      <Text>{`${label}  `}</Text>
997      {row.value ? <Text color="green">●</Text> : <Text color="gray">○</Text>}
998      <Text> </Text>
999      <Button key={id} label={word} plain onPress={() => void writeSetting($, row, !row.value)} />
1000    </Box>
1001  )
1002  // The glyph, a space, the word and one cell the plain button keeps.
1003  return { control, cells: width(label) + 2 + 1 + 1 + width(word) + 1 }
1004}
1005
1006function renderSetting(
1007  $: EngineInterface,
1008  ui: Ui,
1009  row: ConfigRow,
1010  plugin: string,
1011  notes: Record<string, Note>,
1012  ctx: RowCtx,
1013) {
1014  const { Box, Text } = ui
1015  const Input = inputOf(ui)
1016  const id = ctx.prefix + settingKey(row.key)
1017  const made = notes[settingKey(row.key)]
1018  // A note stands for the value it was made against: once the row shows another, it is stale and not drawn.
1019  const note = made && made.shown === configArg(row.value) ? made : undefined
1020  const shown = Array.isArray(row.value) ? row.value.join(', ') : String(row.value)
1021  const label = pad(row.label, ctx.pad)
1022  const view: SettingView = { row, id, label, shown }
1023  let drawn: Drawn
1024  if (row.isLocked) {
1025    drawn = { control: <Text key={id} dimColor>{`${label}  ${shown}  managed`}</Text>, cells: width(label) + 2 + width(`${shown}  managed`) }
1026  } else if (row.kind === 'boolean') {
1027    drawn = renderBoolean($, ui, view)
1028  } else if (row.kind === 'choice' && row.options && row.options.length > 0 && ctx.hasFields) {
1029    drawn = renderChoice($, ui, view, row.options, ctx)
1030  } else if (Input && ctx.hasFields) {
1031    drawn = renderTextEdit($, ui, view, Input, ctx)
1032  } else {
1033    drawn = { control: <Text key={id}>{`${label}  ${shown}`}</Text>, cells: width(label) + 2 + width(shown) }
1034  }
1035  // The description takes what is left of the line after star, label, value or editor and a note.
1036  const used = STAR_CELLS + drawn.cells + (note ? 2 + width(note.text) : 0) + SEP_CELLS
1037  const help = helpFor(row.description?.replace(/\s+/g, ' '), ctx.columns - used)
1038  return (
1039    <Box key={`row:${id}`} flexDirection="row">
1040      {renderStar($, ui, { kind: 'setting', plugin, key: row.key }, settingKey(row.key), ctx)}
1041      <Text> </Text>
1042      {drawn.control}
1043      {renderHelp(ui, help)}
1044      {note && <Text color="red">{`  ${note.text}`}</Text>}
1045    </Box>
1046  )
1047}
1048
1049/** The label a command button shows: `press again: /x` while its confirming press is awaited. */
1050function commandLabel(plugin: string, c: SectionCommand, state: RowState): string {
1051  const isArmed = runsTag(plugin, c) !== null && state.armed.id === commandKey(plugin, c)
1052  return isArmed ? `press again: /${clip(c.command, LIMITS.command)}` : c.label
1053}
1054
1055type AskView = { plugin: string; c: SectionCommand; id: string; rid: string; runs: string; tag: string | null; state: RowState }
1056
1057/** An open ask editor: the label, an Input with the default, `✓ run` and `✕ cancel`; under it the hint, the origin tag and a note. */
1058function renderAsk($: EngineInterface, ui: Ui, v: AskView, Input: NonNullable<ReturnType<typeof inputOf>>, ctx: RowCtx) {
1059  const { Box, Text, Button } = ui
1060  const { plugin, c, id, rid, runs, tag, state } = v
1061  const note = state.notes[rid]
1062  const isArmed = tag !== null && state.armed.id === rid
1063  return (
1064    <Box key={`row:${id}`} flexDirection="column">
1065      <Box flexDirection="row" columnGap={1}>
1066        {renderStar($, ui, { kind: 'command', plugin, key: favCommandKey(c) }, rid, ctx)}
1067        <Text>{c.label}</Text>
1068        <Input
1069          key={id}
1070          value={ctx.draft}
1071          autoFocus
1072          {...(c.ask?.placeholder !== undefined && { placeholder: c.ask.placeholder })}
1073          onInput={(value: string) => void setDraft($, value)}
1074          onSubmit={(value: string) => void runAsked($, plugin, c, value)}
1075        />
1076        <Button key={`${id}:run`} label="✓ run" onPress={() => void runAskedDraft($, plugin, c)} />
1077        <Button key={`${id}:cancel`} label="✕ cancel" onPress={() => void cancelAsk($, plugin, c)} />
1078      </Box>
1079      <Box flexDirection="row">
1080        <Text>{' '.repeat(STAR_CELLS + 1)}</Text>
1081        <Text dimColor>{runs}</Text>
1082        {tag !== null && <Text dimColor>{` ${tag}`}</Text>}
1083        {isArmed && <Text color="yellow">{'  press ✓ run again'}</Text>}
1084        {note && <Text color="red">{`  ${note.text}`}</Text>}
1085      </Box>
1086    </Box>
1087  )
1088}
1089
1090function renderCommand(
1091  $: EngineInterface,
1092  ui: Ui,
1093  plugin: string,
1094  c: SectionCommand,
1095  state: RowState,
1096  ctx: RowCtx,
1097) {
1098  const { Box, Text, Button } = ui
1099  const rid = commandKey(plugin, c)
1100  const id = ctx.prefix + rid
1101  const tag = runsTag(plugin, c)
1102  const runs = clip(`/${c.command}${c.args ? ` ${c.args}` : ''}${c.ask ? ' …' : ''}`, MAX_RUNS_HINT)
1103  const Input = inputOf(ui)
1104  if (c.ask && c.isAvailable && Input && ctx.open === id) return renderAsk($, ui, { plugin, c, id, rid, runs, tag, state }, Input, ctx)
1105  // The help text takes what is left of the line after star, button, hint and tag.
1106  const used = STAR_CELLS + Math.max(ctx.cmdPad, width(c.label)) + BUTTON_CHROME + BUTTON_GAP + width(runs) + (tag === null ? 0 : 1 + width(tag)) + 1
1107  const help = helpFor(c.isAvailable ? c.description : undefined, ctx.columns - used - SEP_CELLS)
1108  return (
1109    <Box key={`row:${id}`} flexDirection="row">
1110      {renderStar($, ui, { kind: 'command', plugin, key: favCommandKey(c) }, rid, ctx)}
1111      <Text> </Text>
1112      {c.isAvailable ? (
1113        <Button key={id} label={commandLabel(plugin, c, state)} onPress={() => void pressCommand($, plugin, c, { prefix: ctx.prefix, fromBand: false, canAsk: ctx.hasFields })} />
1114      ) : (
1115        <Text key={id} dimColor>{`${c.label} (not available)`}</Text>
1116      )}
1117      {c.isAvailable && <Text>{' '.repeat(Math.max(0, ctx.cmdPad - width(c.label)) + BUTTON_GAP)}</Text>}
1118      {c.isAvailable && <Text dimColor>{runs}</Text>}
1119      {tag !== null && <Text dimColor>{` ${tag}`}</Text>}
1120      {renderHelp(ui, help)}
1121      {state.queued[rid] && <Text dimColor> queued</Text>}
1122    </Box>
1123  )
1124}
1125
1126function settingRows(section: MenuSection, rows: readonly ConfigRow[]): ConfigRow[] {
1127  const own = rows.filter(r => bareName(r.provider.plugin) === bareName(section.plugin))
1128  if (section.settings === null) return own
1129  const out: ConfigRow[] = []
1130  for (const field of section.settings) {
1131    const row = own.find(r => r.key === `${section.plugin}.${field}`)
1132    if (row) out.push(row)
1133  }
1134  return out
1135}
1136
1137function findFavCommand(f: Favourite, known: readonly MenuSection[]): SectionCommand | undefined {
1138  return known.find(x => x.plugin === f.plugin)?.commands.find(c => favCommandKey(c) === f.key)
1139}
1140
1141function renderFavourite(
1142  $: EngineInterface,
1143  ui: Ui,
1144  f: Favourite,
1145  known: readonly MenuSection[],
1146  rows: readonly ConfigRow[],
1147  state: RowState,
1148  ctx: RowCtx,
1149) {
1150  if (f.kind === 'command') {
1151    const c = findFavCommand(f, known)
1152    if (c) return renderCommand($, ui, f.plugin, c, state, ctx)
1153  } else {
1154    const r = rows.find(x => x.key === f.key)
1155    if (r) return renderSetting($, ui, r, f.plugin, state.notes, ctx)
1156  }
1157  const { Box, Text } = ui
1158  return (
1159    <Box key={`row:${ctx.prefix}gone:${f.kind}:${f.plugin}:${f.key}`} flexDirection="row">
1160      {renderStar($, ui, f, `${f.kind}:${f.plugin}:${f.key}`, ctx)}
1161      <Text> </Text>
1162      <Text dimColor>{`${f.key} (gone)`}</Text>
1163    </Box>
1164  )
1165}
1166
1167/** Settings-only sections: plugins with rows but no menu file section, built at draw time. */
1168function configSections(rows: readonly ConfigRow[], filed: readonly MenuSection[]): MenuSection[] {
1169  const names = new Set(rows.map(r => r.provider.plugin).filter(n => n !== ENGINE && !filed.some(x => bareName(x.plugin) === bareName(n))))
1170  const builtin = new Set(rows.filter(r => r.provider.tier === 'builtin').map(r => r.provider.plugin))
1171  return [...names]
1172    .map(plugin => {
1173      const isBuiltIn = builtin.has(plugin)
1174      const bare = bareName(plugin)
1175      const title = isBuiltIn && bare.startsWith(BUILTIN_PREFIX) ? bare.slice(BUILTIN_PREFIX.length) : bare
1176      return { plugin, title, commands: [], settings: null, source: 'config' as const, ...(isBuiltIn && { isBuiltIn }) }
1177    })
1178    .sort((a, b) => a.title.localeCompare(b.title))
1179}
1180
1181/** One foldable section of the pane: the pinned list, a plugin, or Claude Code's own settings. */
1182type Block = {
1183  id: string
1184  plugin: string
1185  title: string
1186  commands: SectionCommand[]
1187  rows: ConfigRow[]
1188  favs?: readonly Favourite[]
1189  note?: string
1190  isBuiltIn?: boolean
1191}
1192
1193type MenuData = {
1194  all: MenuSection[]
1195  rows: ConfigRow[]
1196  state: RowState
1197  favs: Favourite[]
1198  folded: Record<string, boolean>
1199  /** The filter as typed, and trimmed and lower-cased as matched. */
1200  filterText: string
types/index.d.ts 86 lines
1/** Schema version of the menu file a plugin ships at `.claude-plugin/quick-menu.json`. */
2export type QuickMenuVersion = 1
3
4/** A command that asks for arguments when pressed: the hint shown in the empty field and the text it starts with. */
5export type MenuAsk = { placeholder?: string; default?: string }
6
7/** One quick-launch command of a menu file, as `/` would take it, without the slash. */
8export type MenuCommand = {
9  command: string
10  label?: string
11  args?: string
12  description?: string
13  ask?: MenuAsk
14}
15
16/** A validated menu file. `settings: null` means all of the plugin's `userConfig` rows. */
17export type MenuFile = {
18  version: QuickMenuVersion
19  title?: string
20  commands: MenuCommand[]
21  settings: string[] | null
22}
23
24/** One command of a discovered section; `isAvailable` is false when `$.command.list()` lacks it. */
25export type SectionCommand = {
26  command: string
27  label: string
28  args?: string
29  description?: string
30  ask?: MenuAsk
31  isAvailable: boolean
32  /** From `$.command.list()`: where the command comes from and, for a plugin's, which plugin (no `@marketplace`). */
33  source?: 'builtin' | 'plugin' | 'user' | 'mcp'
34  owner?: string
35}
36
37/** One plugin's section: `file` when its menu file was read, `config` when only `userConfig` rows exist, `commands` when only its registered commands are known (loaded with `--plugin-dir`). */
38export type MenuSection = {
39  plugin: string
40  title: string
41  commands: SectionCommand[]
42  settings: string[] | null
43  source: 'file' | 'config' | 'commands'
44  /** Dim line shown under the section's header while open. */
45  note?: string
46  /** A plugin bundled in the binary: its title carries a dim `built-in` tag. */
47  isBuiltIn?: boolean
48}
49
50/** A plugin whose menu file or registry entry could not be used. */
51export type MenuProblem = { plugin: string; message: string }
52
53/** A row's note. `shown`: the row's value (as `/config key=value` spells it) when the note was made; the note is drawn only while the row still shows it. */
54export type Note = { kind: 'deny' | 'error'; text: string; shown: string }
55
56/** Transient per-row pane state: commands waiting on `$.command.run`, and the deny or error beside a row. */
57export type RowState = {
58  queued: Record<string, true>
59  notes: Record<string, Note>
60  /** The command whose button was pressed once and waits for the confirming press until `until` (`$.clock.now()` ms). */
61  armed: { id: string; until: number }
62}
63
64/** A pinned command (key: command plus args) or setting (key: config key) of the plugin. */
65export type Favourite = { kind: 'command' | 'setting'; plugin: string; key: string }
66
67declare module 'claude-code' {
68  interface PluginState {
69    'agent-quick-menu': {
70      sections: MenuSection[]
71      problems: MenuProblem[]
72      rowState: RowState
73      favourites: Favourite[]
74      folded: Record<string, boolean>
75      unplaced: boolean
76      filter: string
77      /** `--plugin-dir` plugin name to root, from `plugin.register`, kept across a reload of this module. */
78      inlineRoots: Record<string, string>
79      /** The element key of the one choice or text row being edited; '' when all are folded to `value ▾` / `value ✎`. */
80      openEditor: string
81      /** The text in the open text editor, as last typed. */
82      editDraft: string
83    }
84  }
85}
86