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

<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">
/config settings get one too..claude-plugin/quick-menu.json; no code, no dependency.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.
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.
/menu opens the pane. /menu refresh discovers plugin menus again.≣ 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.☆ / ★ on any row adds or removes a favourite. Favourites are listed first in the pane.▸ / ▾). 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.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.● (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 · .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.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..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.--plugin-dir whose root is not known (see Limitations): its registered commands, and a dim line saying to set CLAUDE_CODE_PLUGIN_DIRS.userConfig rows.managed) is set by managed policy and cannot be changed here.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.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.
--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.session.start and on /menu refresh (which toasts the counts when it is done), so /menu answers at once./config are not shown here.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.
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
See CONTRIBUTING.md for branches, commits and tests, and the Code of Conduct.
See PRIVACY.md: the plugin collects and sends nothing.
Report vulnerabilities privately, see SECURITY.md.
MIT, see LICENSE.
hooks/quick-menu.tsx 1626 lines1import { 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: stringtypes/index.d.ts 86 lines1/** 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