Skip to content

Commit 2480825

Browse files
committed
Remove the Ctrl+O command palette overlay
Ctrl+O opened a separate command palette that mixed real registry slash commands with a handful of demo-only shell actions (fake permissions/ operator/model-picker overlays with hardcoded data, chrome-zone toggles with no other trigger). That leaked internal vocabulary into the product surface and duplicated the `/` command list, which already narrowed the same catalog to registry commands. Delete palette.ts and consolidate its pure catalog/filter/format helpers into command-catalog.ts, which now backs `/` on its own. Drop the Ctrl+O binding, its SHELL_SHORTCUTS row, and the dead residual-action switch in shell.ts. Point the landing screen's command hint at `/` instead of Ctrl+O. permissions/operator/model_picker were demo stubs with no real callers outside the deleted palette and stay gone. copy_active, toggle_mouse, and mentions already have their own bindings (Alt+C, Alt+M, @) and are unaffected. toggle_task/toggle_agents and the subagent-observe action have no trigger left anywhere in the product; their underlying functions (setChromeZones, enterSubagentObserve) remain reachable through the shell API for a host to wire up, just not from any key or command today.
1 parent 19d9710 commit 2480825

18 files changed

Lines changed: 214 additions & 889 deletions

docs/TUI.md

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -193,28 +193,29 @@ every picker open — independent of the recents list, which only moves on an
193193
explicit pick and can go stale (`ProductHostConfig.activeModelId`'s doc
194194
comment and `annotateCurrent` in `src/tui-opentui/product-host.ts`).
195195

196-
The command palette specifically (`src/tui-opentui/palette.ts`,
196+
The `/` command list specifically (`src/tui-opentui/command-catalog.ts`,
197197
`shell.ts:openPalette`/`repaintPalette`): width matches the prompt box — both
198198
are painted at the geometry resolver's shared `contentWidth`
199199
(`geometry/resolve.ts:assignRects`, `shell.ts:overlayRowWidth`). There is no
200200
leading marker column and no per-row kind column; the selected row is marked
201201
by text color only (`paintPaletteList` in `shell.ts`: "the highlighted row
202202
already stands out by sitting under the cursor, so a leading `>` and a grey
203-
block would both be saying the same thing twice"). The palette also paints
204-
with no title rule — the filter row (`> query`) directly under the box
205-
already shows what was typed, so a second header line would say nothing new
203+
block would both be saying the same thing twice"). The list also paints with
204+
no title rule — the filter row (`> query`) directly under the box already
205+
shows what was typed, so a second header line would say nothing new
206206
(`repaintPalette`).
207207

208208
## Slash commands and pickers
209209

210-
`Ctrl+O` opens the command palette from anywhere in the shell (reclaimed from
211-
the Ink-era tool-expand chord); `/` at an empty prompt opens the same
212-
palette narrowed to registry slash commands. Every user-facing slash command
213-
has a palette twin. Palette entries are either "residual" product actions
214-
owned by the shell (open permissions, switch model, toggle a chrome zone,
215-
copy, toggle mouse capture, help, insert a mention, observe a subagent) or
216-
"command" entries backed by the live command registry
217-
(`src/tui-opentui/palette.ts`).
210+
`/` at an empty prompt opens the command list, narrowed by name prefix as
211+
more is typed; Tab completes the name so arguments can be typed, Enter runs
212+
it. Every entry is backed by the live command registry
213+
(`src/tui-opentui/command-catalog.ts:commandItemsFromRegistry`) — there is no
214+
separate palette overlay and no shell-owned action outside the registry. The
215+
overlay this reuses is still internally called `"palette"` (`shell.ts`'s
216+
`PrimaryOverlayKind`), a naming leftover from when a Ctrl+O command palette
217+
also opened it; that chord is gone (see keybindings.ts), and the identifier
218+
stayed because renaming an internal overlay tag has no user-facing effect.
218219

219220
The model/provider picker is provider-first
220221
(`src/tui-opentui/product-host.ts:groupModelsForPicker`/`openLevel`): recent
Lines changed: 36 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,12 @@
11
import { describe, expect, test } from "bun:test"
22
import {
3-
buildCommandCatalog,
43
commandItemsFromRegistry,
4+
filterPaletteCommands,
5+
paletteLabels,
56
} from "./command-catalog"
6-
import { DEFAULT_PALETTE_COMMANDS, paletteDispatchOf } from "./palette"
7-
8-
describe("buildCommandCatalog", () => {
9-
test("maps listCommands-shaped entries to dispatch command names", () => {
10-
const catalog = buildCommandCatalog([
11-
{ name: "compact", description: "Compact history" },
12-
{ name: "model", description: "Open model picker" },
13-
])
14-
15-
const compact = catalog.find((c) => c.id === "compact")
16-
expect(compact).toBeDefined()
17-
expect(compact!.dispatch).toBe("command")
18-
expect(compact!.label).toContain("/compact")
19-
expect(compact!.label).toContain("Compact history")
20-
expect(paletteDispatchOf(compact!)).toBe("command")
21-
22-
const model = catalog.find((c) => c.id === "model")
23-
expect(model?.dispatch).toBe("command")
24-
})
25-
26-
test("includes residual openers alongside registry commands", () => {
27-
const catalog = buildCommandCatalog([
28-
{ name: "compact", description: "Compact history" },
29-
])
30-
expect(catalog.some((c) => c.id === "permissions")).toBe(true)
31-
expect(catalog.some((c) => c.id === "compact")).toBe(true)
32-
expect(catalog.length).toBeGreaterThan(DEFAULT_PALETTE_COMMANDS.length)
33-
})
34-
35-
test("preferRegistry drops residual when registry reuses id", () => {
36-
const catalog = buildCommandCatalog([
37-
{ name: "help", description: "Slash help" },
38-
])
39-
const helps = catalog.filter((c) => c.id === "help")
40-
expect(helps.length).toBe(1)
41-
expect(helps[0]!.dispatch).toBe("command")
42-
})
43-
44-
test("empty registry still yields residual catalog", () => {
45-
const catalog = buildCommandCatalog([])
46-
expect(catalog.length).toBe(DEFAULT_PALETTE_COMMANDS.length)
47-
expect(catalog.every((c) => c.dispatch === "residual")).toBe(true)
48-
})
49-
})
507

518
describe("commandItemsFromRegistry", () => {
52-
test("registry-only items all dispatch as command", () => {
9+
test("maps listCommands-shaped entries to `/`-prefixed labels", () => {
5310
const items = commandItemsFromRegistry([
5411
{ name: "tasks", description: "Show work list" },
5512
{ name: "clear", description: "Clear screen" },
@@ -59,14 +16,45 @@ describe("commandItemsFromRegistry", () => {
5916
id: "tasks",
6017
label: "/tasks — Show work list",
6118
keywords: ["tasks", "slash", "command"],
62-
dispatch: "command",
6319
},
6420
{
6521
id: "clear",
6622
label: "/clear — Clear screen",
6723
keywords: ["clear", "slash", "command"],
68-
dispatch: "command",
6924
},
7025
])
7126
})
7227
})
28+
29+
describe("filterPaletteCommands", () => {
30+
const catalog = commandItemsFromRegistry([
31+
{ name: "compact", description: "Compact history" },
32+
{ name: "model", description: "Open model picker" },
33+
])
34+
35+
test("empty query returns the full catalog", () => {
36+
expect(filterPaletteCommands("", catalog)).toEqual(catalog)
37+
})
38+
39+
test("matches by id, label, or keyword substring", () => {
40+
expect(filterPaletteCommands("compact", catalog).map((c) => c.id)).toEqual([
41+
"compact",
42+
])
43+
expect(filterPaletteCommands("picker", catalog).map((c) => c.id)).toEqual([
44+
"model",
45+
])
46+
})
47+
48+
test("no match returns an empty list", () => {
49+
expect(filterPaletteCommands("zzzz", catalog)).toEqual([])
50+
})
51+
})
52+
53+
describe("paletteLabels", () => {
54+
test("returns just the display labels", () => {
55+
const catalog = commandItemsFromRegistry([
56+
{ name: "tasks", description: "Show work list" },
57+
])
58+
expect(paletteLabels(catalog)).toEqual(["/tasks — Show work list"])
59+
})
60+
})

src/tui-opentui/command-catalog.ts

Lines changed: 61 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,78 @@
11
/**
2-
* Registry → palette catalog bridge for OpenTUI production host.
2+
* Registry → `/` command list catalog (pure).
33
*
44
* Pure: host injects `listCommands()` results (or fixtures). No registry import
55
* here — avoids circular / heavy deps from `src/tui/commands`.
66
*
7-
* setPaletteCatalog(shell, buildCommandCatalog(listCommands()))
7+
* setPaletteCatalog(shell, commandItemsFromRegistry(listCommands()))
88
*/
99

10-
import {
11-
buildPaletteCatalog,
12-
commandsToPaletteItems,
13-
type BuildPaletteCatalogOpts,
14-
type PaletteCommand,
15-
type RegistryCommandSource,
16-
} from "./palette.js"
10+
import { sliceToWidth, stringWidth } from "../tui/view/height.js"
1711

18-
export type { PaletteCommand, RegistryCommandSource }
12+
/** Minimal registry shape — matches `listCommands()` entries without importing them. */
13+
export type RegistryCommandSource = {
14+
readonly name: string
15+
readonly description: string
16+
}
1917

20-
export type BuildCommandCatalogOpts = Omit<BuildPaletteCatalogOpts, "commands">
18+
/** One entry in the `/` command list: registry command name + display label. */
19+
export type PaletteCommand = {
20+
readonly id: string
21+
readonly label: string
22+
/** Optional keywords for name-prefix / substring filter. */
23+
readonly keywords?: readonly string[]
24+
}
25+
26+
/** Map registry command definitions to `/` list items. */
27+
export function commandItemsFromRegistry(
28+
commands: readonly RegistryCommandSource[],
29+
): PaletteCommand[] {
30+
return commands.map((c) => ({
31+
id: c.name,
32+
label: `/${c.name}${c.description}`,
33+
keywords: [c.name, "slash", "command"],
34+
}))
35+
}
2136

2237
/**
23-
* Map `listCommands()`-shaped entries into a palette catalog for setPaletteCatalog.
24-
*
25-
* Includes residual product openers (permissions, model picker, …) plus registry
26-
* slash commands with `dispatch: "command"` and `id` = command name. Registry
27-
* names win over residual openers with the same id (preferRegistry default).
38+
* Case-insensitive substring filter over label + keywords.
39+
* Empty query returns the full catalog (stable order).
2840
*/
29-
export function buildCommandCatalog(
30-
commands: readonly RegistryCommandSource[],
31-
opts?: BuildCommandCatalogOpts,
41+
export function filterPaletteCommands(
42+
query: string,
43+
catalog: readonly PaletteCommand[],
3244
): readonly PaletteCommand[] {
33-
return buildPaletteCatalog({
34-
...opts,
35-
commands,
45+
const q = query.trim().toLowerCase()
46+
if (q.length === 0) return catalog
47+
return catalog.filter((cmd) => {
48+
if (cmd.label.toLowerCase().includes(q)) return true
49+
if (cmd.id.toLowerCase().includes(q)) return true
50+
return (cmd.keywords ?? []).some((k) => k.toLowerCase().includes(q))
3651
})
3752
}
3853

39-
/**
40-
* Registry slash entries only (no residual openers). Each item has
41-
* `dispatch: "command"` and `id` equal to the command name.
42-
*/
43-
export function commandItemsFromRegistry(
44-
commands: readonly RegistryCommandSource[],
45-
): PaletteCommand[] {
46-
return commandsToPaletteItems(commands)
54+
/** Labels for the shared list viewport. */
55+
export function paletteLabels(
56+
commands: readonly PaletteCommand[],
57+
): readonly string[] {
58+
return commands.map((c) => c.label)
59+
}
60+
61+
function fitLabel(label: string, width: number): string {
62+
if (width <= 0) return ""
63+
const columns = stringWidth(label)
64+
// padEnd counts code units, so a label carrying a wide glyph has to be padded
65+
// by the column shortfall rather than to a code-unit length.
66+
if (columns <= width) return label + " ".repeat(width - columns)
67+
if (width === 1) return "…"
68+
const cut = `${sliceToWidth(label, width - 1)}…`
69+
return cut + " ".repeat(Math.max(0, width - stringWidth(cut)))
70+
}
71+
72+
/** Render labels to exactly `width` columns each, ellipsizing long ones. */
73+
export function formatPaletteRows(
74+
labels: readonly string[],
75+
width: number,
76+
): readonly string[] {
77+
return labels.map((label) => fitLabel(label, width))
4778
}

src/tui-opentui/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ export {
2020
export * from "./live-session-port"
2121
export * from "./overlays"
2222
export * from "./long-log"
23-
export * from "./palette"
2423
export * from "./command-catalog"
2524
export * from "./model-catalog"
2625
export * from "./copy-path"

src/tui-opentui/keybindings.test.ts

Lines changed: 8 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,7 @@ import { EventEmitter } from "node:events"
1717
import { describe, expect, test } from "bun:test"
1818

1919
import { PROMPT_KEY_BINDINGS } from "./prompt-input.js"
20-
import { SHELL_SHORTCUTS, shortcutForPaletteId } from "./keybindings.js"
21-
import { isResidualActionId } from "./palette.js"
22-
import { listCommands } from "../tui/commands/registry.js"
23-
import { registerBuiltInCommands } from "../tui/commands/built-in.js"
20+
import { SHELL_SHORTCUTS } from "./keybindings.js"
2421
import { createHarness, withTestRenderer, type Harness } from "./harness.js"
2522
import { mountRunnerHost } from "./runner-host.js"
2623
import { focusOwner } from "./focus/focus-state.js"
@@ -271,15 +268,6 @@ const PROBES: Readonly<Record<string, { readonly group: Group; readonly probe: P
271268
},
272269
},
273270

274-
"Ctrl+O": {
275-
group: "surfaces",
276-
probe: ({ h, shell, chords }) => {
277-
press(h, chords[0])
278-
expect(shell.overlayKind).toBe("palette")
279-
press(h, chords[0])
280-
expect(shell.overlayKind).toBeNull()
281-
},
282-
},
283271
"Alt+C": {
284272
group: "surfaces",
285273
probe: ({ h, shell, chords }) => {
@@ -336,11 +324,16 @@ const PROBES: Readonly<Record<string, { readonly group: Group; readonly probe: P
336324
Esc: {
337325
group: "surfaces",
338326
probe: async ({ h, shell, chords }) => {
339-
press(h, "\x0f") // Ctrl+O opens something to close
327+
setPaletteCatalog(shell, [{ id: "cost", label: "cost" }])
328+
shellFocusPrompt(shell)
329+
shell.prompt.value = ""
330+
shell.prompt.cursorOffset = 0
331+
press(h, "/") // opens the / command popup, something Esc must close
340332
expect(shell.overlayKind).toBe("palette")
341333
press(h, chords[0])
342334
await escapeSettles()
343335
expect(shell.overlayKind).toBeNull()
336+
shell.prompt.value = ""
344337
},
345338
},
346339
"?": {
@@ -386,7 +379,7 @@ const PROBES: Readonly<Record<string, { readonly group: Group; readonly probe: P
386379
"/": {
387380
group: "surfaces",
388381
probe: async ({ h, shell, chords }) => {
389-
setPaletteCatalog(shell, [{ id: "cost", label: "cost", dispatch: "command" }])
382+
setPaletteCatalog(shell, [{ id: "cost", label: "cost" }])
390383
shellFocusPrompt(shell)
391384

392385
shell.prompt.value = "note"
@@ -685,25 +678,3 @@ describe("the runner host does not shadow the prompt bindings the catalog claims
685678
})
686679
})
687680

688-
describe("palette chords", () => {
689-
test("every mapped entry id is a real palette action or registered command", () => {
690-
registerBuiltInCommands()
691-
const commands = new Set(listCommands().map((c) => c.name))
692-
for (const id of PALETTE_IDS) {
693-
expect(isResidualActionId(id) || commands.has(id)).toBe(true)
694-
}
695-
})
696-
697-
test("every mapped chord is a chord the catalog proved", () => {
698-
for (const id of PALETTE_IDS) {
699-
expect(shortcutForPaletteId(id)).toBeString()
700-
}
701-
})
702-
})
703-
704-
/**
705-
* PALETTE_CHORDS is private, so the ids it maps are listed here. A new mapping
706-
* without an entry here is caught by the palette test in wave7, which walks the
707-
* painted rows.
708-
*/
709-
const PALETTE_IDS = ["help", "mentions", "copy_active", "toggle_mouse", "paste-image"] as const

src/tui-opentui/keybindings.ts

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ export const SHELL_SHORTCUTS: readonly ShellShortcut[] = [
2424
{ keys: "Alt+Enter", description: "steer at the next tool boundary; does nothing unless a run is busy" },
2525
{ keys: "Ctrl+C", description: "interrupt the run, or clear the prompt when idle; press twice to exit" },
2626
{ keys: "Ctrl+G", description: "cancel the most recently queued or steered message before it dispatches" },
27-
{ keys: "Ctrl+O", description: "open the command palette; press again to close it" },
2827
{ keys: "Alt+C", description: "copy mode: pick a message, tool output, or diff; press again to close it" },
2928
{ keys: "Alt+M", description: "release the mouse to the terminal for native drag-select and copy; on by default for wheel scroll and click-to-expand" },
3029
{ keys: "Alt+E", description: "expand or collapse every collapsible row (tool call, diff, skill, reasoning)" },
@@ -51,26 +50,3 @@ export const SHELL_SHORTCUTS: readonly ShellShortcut[] = [
5150
"insert a newline instead of sending (Shift+Enter also works on terminals that report the modifier)",
5251
},
5352
] as const
54-
55-
/**
56-
* Palette entry id (residual action id or registry command name) → the chord
57-
* that reaches the same surface without the palette.
58-
*/
59-
const PALETTE_CHORDS: Readonly<Record<string, string>> = {
60-
help: "?",
61-
mentions: "@",
62-
copy_active: "Alt+C",
63-
toggle_mouse: "Alt+M",
64-
"paste-image": "Ctrl+V / Ctrl+P",
65-
}
66-
67-
/**
68-
* Chord to advertise for a palette row, or undefined when the entry has none.
69-
* Resolved against SHELL_SHORTCUTS so the palette can never print a binding the
70-
* shell does not actually implement.
71-
*/
72-
export function shortcutForPaletteId(id: string): string | undefined {
73-
const keys = PALETTE_CHORDS[id]
74-
if (keys === undefined) return undefined
75-
return SHELL_SHORTCUTS.some((s) => s.keys === keys) ? keys : undefined
76-
}

src/tui-opentui/landing.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export const LANDING_HINTS: readonly {
6161
readonly key: string
6262
readonly rest: string
6363
}[] = [
64-
{ key: "ctrl+o", rest: "for commands" },
64+
{ key: "/", rest: "for commands" },
6565
{ key: "?", rest: "for shortcuts" },
6666
]
6767

0 commit comments

Comments
 (0)