From 27c5f80d651ef8a3661277dd9a155a5ff4d3f224 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 7 Aug 2026 00:36:55 -0700 Subject: [PATCH 1/4] Stop the overlay host from painting past its own box A list overlay's border, title, and filter rows are unavoidable, but the geometry resolver could still hand it fewer rows than that when the transcript floor and prompt claimed the rest first. With ~30 palette commands on a short terminal, this squeezed the overlay host below its own render minimum: the box painted more rows than it was assigned, spilling into and past the prompt box below it. The resolver now treats an open overlay's chrome as a second floor alongside the transcript's, collapsing other chrome further (down to relaxing the transcript floor) before it will starve the overlay below what its own border and first content row need. Callers report that minimum explicitly rather than the resolver guessing it from an opaque row count. --- src/tui-opentui/geometry.test.ts | 21 ++++++++++ src/tui-opentui/geometry/index.ts | 1 + src/tui-opentui/geometry/resolve.ts | 21 +++++++++- src/tui-opentui/geometry/zones.ts | 8 ++++ src/tui-opentui/shell.ts | 61 +++++++++++++++++++++++++++-- 5 files changed, 107 insertions(+), 5 deletions(-) diff --git a/src/tui-opentui/geometry.test.ts b/src/tui-opentui/geometry.test.ts index 1f7075784..587e6dbc0 100644 --- a/src/tui-opentui/geometry.test.ts +++ b/src/tui-opentui/geometry.test.ts @@ -244,6 +244,27 @@ describe("resolveGeometry — overlay modes", () => { expect(layout.transcriptHeight).toBeGreaterThanOrEqual(OVERLAY_TRANSCRIPT_FLOOR); }); + test("a large list overlay on a short terminal never exceeds terminal rows", () => { + // A ~30-command palette asks for far more body rows than a short terminal + // has; the resolver must still sum to exactly terminal.rows rather than + // let the overlay's own border/title chrome overflow past the screen. + for (let rows = 4; rows <= 12; rows++) { + const layout = resolveGeometry({ + terminal: { columns: 80, rows }, + overlay: { mode: "inset", bodyRows: 48 }, + }); + const total = layout.chromeHeight + layout.overlayHeight + layout.transcriptHeight; + expect(total).toBe(rows); + } + }); + + test("overlay gets at least its border/title minimum before the transcript floor", () => { + const layout = idle80x24({ + overlay: { mode: "inset", bodyRows: 48, minBodyRows: 5 }, + }); + expect(layout.overlayHeight).toBeGreaterThanOrEqual(5); + }); + test("full_shell hides transcript and gives residual to overlay_host", () => { const layout = idle80x24({ overlay: { mode: "full_shell", bodyRows: 20 }, diff --git a/src/tui-opentui/geometry/index.ts b/src/tui-opentui/geometry/index.ts index 2b714e8ef..d0e75000f 100644 --- a/src/tui-opentui/geometry/index.ts +++ b/src/tui-opentui/geometry/index.ts @@ -2,6 +2,7 @@ export { COLLAPSE_ORDER, IDLE_TRANSCRIPT_FLOOR, OVERLAY_MAX_FRACTION, + OVERLAY_MIN_ROWS, OVERLAY_TRANSCRIPT_FLOOR, PAINT_ORDER, PROMPT_BASE_ROWS, diff --git a/src/tui-opentui/geometry/resolve.ts b/src/tui-opentui/geometry/resolve.ts index 3a597544a..b0bcb0233 100644 --- a/src/tui-opentui/geometry/resolve.ts +++ b/src/tui-opentui/geometry/resolve.ts @@ -6,6 +6,7 @@ import { COLLAPSE_ORDER, IDLE_TRANSCRIPT_FLOOR, OVERLAY_MAX_FRACTION, + OVERLAY_MIN_ROWS, OVERLAY_TRANSCRIPT_FLOOR, PAINT_ORDER, PROMPT_BASE_ROWS, @@ -26,6 +27,12 @@ export type OverlayInput = { readonly mode: OverlayMode; /** Requested overlay body rows (measured by host). Capped by fraction + floor. */ readonly bodyRows?: number; + /** + * Rows the overlay's own chrome cannot render without (border + title + + * at least one content row). Falls back to `OVERLAY_MIN_ROWS` when the + * caller has not measured its actual chrome. + */ + readonly minBodyRows?: number; }; /** @@ -315,6 +322,15 @@ export function resolveGeometry(input: GeometryInput): GeometryLayout { ); if (heights.prompt > promptCap) heights.prompt = promptCap; + // An open overlay with real content needs its own border/title rows or it + // renders past whatever height it was actually assigned. The transcript + // floor below cannot be satisfied at that overlay's expense. + const requestedOverlayRows = input.overlay?.bodyRows ?? 0; + const minOverlay = + mode !== "closed" && requestedOverlayRows > 0 + ? Math.min(input.overlay?.minBodyRows ?? OVERLAY_MIN_ROWS, requestedOverlayRows) + : 0; + // Iteratively collapse optional chrome until transcript meets floor with overlay. // Enough steps to walk a grown prompt back to base one row at a time on top // of dropping every optional zone. @@ -328,7 +344,7 @@ export function resolveGeometry(input: GeometryInput): GeometryLayout { floor, ); const transcript = terminal.rows - chrome - overlay; - if (transcript >= floor) { + if (transcript >= floor && overlay >= minOverlay) { heights.transcript = Math.max(0, transcript); heights.overlay_host = overlay; break; @@ -336,7 +352,8 @@ export function resolveGeometry(input: GeometryInput): GeometryLayout { // Need more space: collapse one zone, then retry. const cut = collapseOnce(heights, collapsed); if (cut === null) { - // Nothing left — accept best effort (may be below floor on tiny terminals). + // Nothing left — relax the transcript floor rather than leave the + // overlay under its own render minimum; accept best effort past that. heights.overlay_host = desiredOverlayHeight( { ...input, terminal }, mode, diff --git a/src/tui-opentui/geometry/zones.ts b/src/tui-opentui/geometry/zones.ts index d11b48b07..21ab1c1e5 100644 --- a/src/tui-opentui/geometry/zones.ts +++ b/src/tui-opentui/geometry/zones.ts @@ -114,6 +114,14 @@ export const PROMPT_CAP_FRACTION = 0.4; /** Overlay host body may not exceed this fraction of terminal rows (proposed). */ export const OVERLAY_MAX_FRACTION = 0.7; +/** + * Smallest overlay_host an open overlay can render into: two border rows plus + * one content row. The transcript floor exists to keep conversation visible, + * but it must not starve an overlay the operator just opened below the rows + * its own border costs — that renders past its box instead of shrinking. + */ +export const OVERLAY_MIN_ROWS = 3; + /** * Prompt floor: labelled borders + one content line. Only a terminal too short * to seat the transcript floor alongside a composing area gets squeezed here. diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index 04b2701e2..213a08b32 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -1126,6 +1126,21 @@ function overlayHostRows( return overlayChromeRows(shell, bodyLineCount) + listRows } +/** + * Smallest host rows the open overlay can render into without spilling past + * its own box: fixed chrome (border, title, body lines) plus one row of the + * list when it has anything to show. Below this the resolver must give ground + * elsewhere (transcript floor) rather than starve the overlay itself. + */ +function overlayMinHostRows( + shell: AppShell, + bodyLineCount: number, + hasItems: boolean, +): number { + const perItem = overlayRowsPerItem(shell.overlayKind) + return overlayChromeRows(shell, bodyLineCount) + (hasItems ? perItem : 0) +} + function addOverlayRow( shell: AppShell, content: string, @@ -1693,6 +1708,12 @@ export type RelayoutOpts = { readonly promptContentRows?: number readonly overlayMode?: OverlayMode readonly overlayBodyRows?: number + /** + * Rows the open overlay cannot render without: border + title + at least + * one content row. Below this, the box paints past whatever height it was + * assigned instead of shrinking, so the resolver must never starve it here. + */ + readonly overlayMinBodyRows?: number } type PriorOverlaySnapshot = { @@ -1720,6 +1741,7 @@ type ShellInternals = { promptContentRows: number | undefined overlayMode: OverlayMode overlayBodyRows: number | undefined + overlayMinBodyRows: number | undefined /** Snapshot when palette stacks over another primary overlay. */ priorOverlay: PriorOverlaySnapshot | null /** Optional stable ids aligned with overlayItems for the open primary. */ @@ -1846,11 +1868,13 @@ export function relayout(shell: AppShell, opts?: RelayoutOpts): GeometryLayout { const promptContentRows = opts?.promptContentRows ?? bag?.promptContentRows const overlayMode = opts?.overlayMode ?? bag?.overlayMode ?? "closed" const overlayBodyRows = opts?.overlayBodyRows ?? bag?.overlayBodyRows + const overlayMinBodyRows = opts?.overlayMinBodyRows ?? bag?.overlayMinBodyRows if (bag) { bag.visibility = visibility bag.promptContentRows = promptContentRows bag.overlayMode = overlayMode bag.overlayBodyRows = overlayBodyRows + bag.overlayMinBodyRows = overlayMinBodyRows } const columns = opts?.columns ?? shell.renderer.width @@ -1866,6 +1890,9 @@ export function relayout(shell: AppShell, opts?: RelayoutOpts): GeometryLayout { ...(overlayBodyRows !== undefined ? { bodyRows: overlayBodyRows } : {}), + ...(overlayMinBodyRows !== undefined + ? { minBodyRows: overlayMinBodyRows } + : {}), }, ...(promptContentRows !== undefined ? { promptContentRows } : {}), // The landing owns the screen until the first transcript row lands, so @@ -3191,6 +3218,11 @@ export function openListOverlay( shell.overlayBodyLines.length, listItems * perItem, ) + const minHostRows = overlayMinHostRows( + shell, + shell.overlayBodyLines.length, + listItems > 0, + ) shell.overlayList = createListViewport({ count: labels.length, @@ -3207,7 +3239,11 @@ export function openListOverlay( target: focusTarget, scrollOwner: isPalette ? "palette" : "overlay", }) - relayout(shell, { overlayMode: "inset", overlayBodyRows: hostRows }) + relayout(shell, { + overlayMode: "inset", + overlayBodyRows: hostRows, + overlayMinBodyRows: minHostRows, + }) applyFocus(shell) paintOverlayList(shell) } @@ -3560,7 +3596,16 @@ export function closeInsetOverlay(shell: AppShell): void { } const listH = prior.list.height const hostRows = overlayHostRows(shell, prior.bodyLines.length, listH) - relayout(shell, { overlayMode: "inset", overlayBodyRows: hostRows }) + const minHostRows = overlayMinHostRows( + shell, + prior.bodyLines.length, + prior.list.count > 0, + ) + relayout(shell, { + overlayMode: "inset", + overlayBodyRows: hostRows, + overlayMinBodyRows: minHostRows, + }) applyFocus(shell) paintOverlayList(shell) return @@ -3673,7 +3718,16 @@ export function setOverlayBody( Math.max(1, shell.overlayItems.length) * overlayRowsPerItem(shell.overlayKind), ) - relayout(shell, { overlayMode: "inset", overlayBodyRows: hostRows }) + const minHostRows = overlayMinHostRows( + shell, + shell.overlayBodyLines.length, + shell.overlayItems.length > 0, + ) + relayout(shell, { + overlayMode: "inset", + overlayBodyRows: hostRows, + overlayMinBodyRows: minHostRows, + }) paintOverlayList(shell) } @@ -5493,6 +5547,7 @@ export function createAppShell( promptContentRows, overlayMode: "closed", overlayBodyRows: undefined, + overlayMinBodyRows: undefined, priorOverlay: null, overlayItemIds: [], overlayItemValues: [], From e673377a56373cd49b041d0274ca1944fa9dfeb0 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 7 Aug 2026 00:38:04 -0700 Subject: [PATCH 2/4] Give the floating palette back its side margins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The palette host floats with absolute left/right insets over the landing screen, which escape root's padding — so it spanned the full terminal width while the prompt box below it, sized in normal flow, stayed inset by the shared side margin. The two stacked boxes visibly misaligned on both edges. The float now takes its left inset and width from the same layout.sideMargin / layout.contentWidth the prompt box already resolves through, rather than assuming the padding edge sits at column zero. --- src/tui-opentui/palette-paint.test.ts | 27 +++++++++++++++++++++++++++ src/tui-opentui/shell.ts | 10 ++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/tui-opentui/palette-paint.test.ts b/src/tui-opentui/palette-paint.test.ts index c87bdfc32..74a55941f 100644 --- a/src/tui-opentui/palette-paint.test.ts +++ b/src/tui-opentui/palette-paint.test.ts @@ -193,3 +193,30 @@ describe("palette filters as you type", () => { ) }) }) + +describe("command palette width", () => { + // Both boxes are children of the same padded root; a width computed a + // second way for the floating palette drifts from the prompt box's "100%". + test("shares the prompt box's left/right edges while floating over landing", async () => { + const rows = await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }) + openPalette(shell) + await h.renderOnce() + return h.captureCharFrame().split("\n") + }, + { width: 80, height: 24 }, + ) + const overlayTop = rows.find((r) => r.includes("┌")) + const promptTop = rows.find((r) => r.includes("╭")) + expect(overlayTop).toBeDefined() + expect(promptTop).toBeDefined() + expect(overlayTop?.indexOf("┌")).toBe(promptTop?.indexOf("╭")) + expect(overlayTop?.lastIndexOf("┐")).toBe(promptTop?.lastIndexOf("╮")) + }) +}) + diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index 213a08b32..b37331d7c 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -1110,8 +1110,14 @@ function floatOverlayHost( return } host.position = "absolute" - host.left = 0 - host.right = 0 + // Absolute positioning escapes root's padding, so the same sideMargin the + // prompt box gets for free in normal flow has to be given back explicitly. + // width is set to the same contentWidth the prompt box resolves to via + // "100%" of root's padded box — one source, not a second computed here — + // rather than left+right insets, since those combine with the existing + // width:"100%" to overshoot the right edge. + host.left = shell.layout.sideMargin + host.width = shell.layout.contentWidth host.top = top host.zIndex = OVERLAY_FLOAT_Z } From f83f8fb60ba9c0b33a3aa70f2c6cd18c6d57926f Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 7 Aug 2026 00:39:21 -0700 Subject: [PATCH 3/4] Drop the palette's per-row category column and title rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pieces of palette chrome duplicated information already on screen: a per-row category column (command/config/session/...) when the command name already says what it is, and a title rule (- command palette -) repeating what the box border and filter row already establish. Both are gone; rows reflow into the reclaimed width so descriptions truncate later, and the shortcut column still lines up. Every other list overlay keeps its title rule — only the palette, which is unambiguous from context and reads its own filter query as a second header, drops it. --- src/tui-opentui/command-catalog.test.ts | 2 - src/tui-opentui/palette-paint.test.ts | 30 ++++---- src/tui-opentui/palette.test.ts | 60 +++------------- src/tui-opentui/palette.ts | 93 +++---------------------- src/tui-opentui/shell.ts | 33 ++++----- src/tui-opentui/wave6.test.ts | 5 +- src/tui-opentui/width-columns.test.ts | 6 +- 7 files changed, 59 insertions(+), 170 deletions(-) diff --git a/src/tui-opentui/command-catalog.test.ts b/src/tui-opentui/command-catalog.test.ts index 725c20887..31b2596fc 100644 --- a/src/tui-opentui/command-catalog.test.ts +++ b/src/tui-opentui/command-catalog.test.ts @@ -60,14 +60,12 @@ describe("commandItemsFromRegistry", () => { label: "/tasks — Show work list", keywords: ["tasks", "slash", "command"], dispatch: "command", - category: "command", }, { id: "clear", label: "/clear — Clear screen", keywords: ["clear", "slash", "command"], dispatch: "command", - category: "session", }, ]) }) diff --git a/src/tui-opentui/palette-paint.test.ts b/src/tui-opentui/palette-paint.test.ts index 74a55941f..ba9ddbfd3 100644 --- a/src/tui-opentui/palette-paint.test.ts +++ b/src/tui-opentui/palette-paint.test.ts @@ -1,6 +1,6 @@ /** - * Frame-level checks for the command palette's three-column rows: the category - * prefix, the right-aligned chord, and how they degrade at narrow widths. + * Frame-level checks for the command palette's rows: the label, the + * right-aligned chord, and how the chord degrades at narrow widths. */ import { describe, expect, test } from "bun:test" @@ -39,33 +39,31 @@ function rowFor(rows: readonly string[], label: string): string | undefined { } describe("command palette rows", () => { - test("titles the box with a broken rule and shows the filter prompt", async () => { + test("shows the filter prompt with no title rule above it", async () => { const rows = await paletteFrame(100) - expect(rows.some((r) => r.startsWith("─ command palette ─"))).toBe(true) + expect(rows.some((r) => r.startsWith("─ command palette ─"))).toBe(false) expect(rows.some((r) => r.trim() === ">")).toBe(true) }) - test("paints the category prefix and right-aligned chord at 100 columns", async () => { + test("has no leading selection marker or kind column", async () => { const rows = await paletteFrame(100) const help = rowFor(rows, "Show keymap help") expect(help).toBeDefined() - expect(help).toMatch(/^\s+[> ] view\s+Show keymap help\s+\?$/) + expect(help).toMatch(/^\s*Show keymap help\s+\?$/) + expect(help).not.toContain(">") + expect(help).not.toContain("view") + }) + test("keeps the right-aligned chord at 100 columns", async () => { + const rows = await paletteFrame(100) const copy = rowFor(rows, "Copy active message / tool") expect(copy?.endsWith("Alt+C")).toBe(true) }) - test("keeps both side columns at 60 columns", async () => { - const rows = await paletteFrame(60) - const help = rowFor(rows, "Show keymap help") - expect(help).toContain("view") - expect(help?.endsWith("?")).toBe(true) - }) - - test("drops the chord first at 48 columns, keeping the category", async () => { - const rows = await paletteFrame(48) + test("drops the chord at a narrow width, and the label always survives", async () => { + const rows = await paletteFrame(36) const help = rowFor(rows, "Show keymap help") - expect(help).toContain("view") + expect(help).toBeDefined() expect(help?.endsWith("?")).toBe(false) const copy = rowFor(rows, "Copy active") diff --git a/src/tui-opentui/palette.test.ts b/src/tui-opentui/palette.test.ts index b01dbb2b9..75f663d46 100644 --- a/src/tui-opentui/palette.test.ts +++ b/src/tui-opentui/palette.test.ts @@ -84,27 +84,6 @@ describe("buildPaletteCatalog", () => { }) describe("palette row columns", () => { - test("residual openers carry a category", () => { - const cols = DEFAULT_PALETTE_COMMANDS.map((c) => - paletteRowColumns(c, shortcutForPaletteId), - ) - expect(cols.every((c) => c.category.length > 0)).toBe(true) - expect(cols.find((c) => c.label === "Show keymap help")?.category).toBe( - "view", - ) - }) - - test("registry commands get a category from their name", () => { - const catalog = buildPaletteCatalog({ - commands: [ - { name: "rename", description: "Name the session" }, - { name: "wobble", description: "A plugin command" }, - ], - }) - expect(catalog.find((c) => c.id === "rename")?.category).toBe("session") - expect(catalog.find((c) => c.id === "wobble")?.category).toBe("command") - }) - test("shortcuts come from the shell keybinding table", () => { const help = DEFAULT_PALETTE_COMMANDS.find((c) => c.id === "help") expect(paletteRowColumns(help!, shortcutForPaletteId).shortcut).toBe("?") @@ -115,16 +94,15 @@ describe("palette row columns", () => { describe("formatPaletteRows", () => { const ROWS: readonly PaletteRowColumns[] = [ - { category: "view", label: "Show keymap help", shortcut: "?" }, - { category: "edit", label: "Copy active message / tool", shortcut: "Alt+C" }, - { category: "session", label: "Resume prior session", shortcut: "" }, + { label: "Show keymap help", shortcut: "?" }, + { label: "Copy active message / tool", shortcut: "Alt+C" }, + { label: "Resume prior session", shortcut: "" }, ] - test("renders category, label, and right-aligned shortcut at full width", () => { + test("renders the label and right-aligned shortcut at full width", () => { const [help, copy] = formatPaletteRows(ROWS, 55) expect(help).toHaveLength(55) - expect(help?.startsWith("view ")).toBe(true) - expect(help).toContain("Show keymap help") + expect(help?.startsWith("Show keymap help")).toBe(true) expect(help?.trimEnd().endsWith("?")).toBe(true) expect(copy?.trimEnd().endsWith("Alt+C")).toBe(true) }) @@ -137,29 +115,11 @@ describe("formatPaletteRows", () => { } }) - // The palette host spends the box border and the selection marker before the - // row starts, so a terminal N columns wide hands these rows N - 5. - const ROW_WIDTH_AT_60 = 55 - const ROW_WIDTH_AT_48 = 43 - - test("the shortcut column drops first as width narrows", () => { - expect(paletteRowLayout(ROWS, ROW_WIDTH_AT_60)).toMatchObject({ - showCategory: true, - showShortcut: true, - }) - expect(paletteRowLayout(ROWS, ROW_WIDTH_AT_48)).toMatchObject({ - showCategory: true, - showShortcut: false, - }) - const rows = formatPaletteRows(ROWS, ROW_WIDTH_AT_48) + test("the shortcut column drops as width narrows, and the label always survives", () => { + expect(paletteRowLayout(ROWS, 40)).toMatchObject({ showShortcut: true }) + expect(paletteRowLayout(ROWS, 30)).toMatchObject({ showShortcut: false }) + const rows = formatPaletteRows(ROWS, 30) expect(rows[0]?.includes("?")).toBe(false) - expect(rows[0]?.startsWith("view")).toBe(true) - }) - - test("the category drops next, and the label always survives", () => { - const layout = paletteRowLayout(ROWS, 34) - expect(layout.showCategory).toBe(false) - expect(layout.showShortcut).toBe(false) - expect(formatPaletteRows(ROWS, 34)[0]?.trimEnd()).toBe("Show keymap help") + expect(rows[0]?.trimEnd()).toBe("Show keymap help") }) }) diff --git a/src/tui-opentui/palette.ts b/src/tui-opentui/palette.ts index f052cc72f..f585ca280 100644 --- a/src/tui-opentui/palette.ts +++ b/src/tui-opentui/palette.ts @@ -55,48 +55,6 @@ export function isResidualActionId(id: string): id is PaletteActionId { */ export type PaletteDispatch = "residual" | "command" -/** - * Grouping shown in the palette's first column. - * - * The command registry (`src/tui/commands/built-in.ts`) carries no category - * field, and `RegistryCommandSource` only forwards name + description, so there - * is no grouping to read. These are the smallest set that covers what the - * registered commands and residual openers actually are; anything unmapped - * falls back to `command` rather than being guessed into a group. - */ -export type PaletteCategory = - | "session" - | "model" - | "config" - | "view" - | "agent" - | "edit" - | "command" - -/** Registry command name → category. Names come from built-in.ts registrations. */ -const REGISTRY_CATEGORIES: Readonly> = { - clear: "session", - new: "session", - rename: "session", - cost: "session", - goal: "session", - model: "model", - fast: "model", - standard: "model", - clever: "model", - settings: "config", - permissions: "config", - plugins: "config", - mcp: "config", - help: "view", - changelog: "view", - "paste-image": "edit", -} - -export function categoryForCommandName(name: string): PaletteCategory { - return REGISTRY_CATEGORIES[name] ?? "command" -} - export type PaletteCommand = { /** Residual action id or registry command name. */ readonly id: string @@ -108,8 +66,6 @@ export type PaletteCommand = { * action; registry-built items set `"command"` explicitly. */ readonly dispatch?: PaletteDispatch - /** Dim prefix column. Defaults to `command` when omitted. */ - readonly category?: PaletteCategory } /** Minimal registry shape — matches `listCommands()` entries without importing them. */ @@ -125,98 +81,84 @@ export const DEFAULT_PALETTE_COMMANDS: readonly PaletteCommand[] = [ label: "Open permissions", keywords: ["allow", "deny", "tool", "approve"], dispatch: "residual", - category: "config", }, { id: "operator", label: "Ask operator question", keywords: ["confirm", "choice", "prompt"], dispatch: "residual", - category: "session", }, { id: "model_picker", label: "Switch model / provider", keywords: ["model", "provider", "anthropic", "openai"], dispatch: "residual", - category: "model", }, { id: "toggle_goal", label: "Toggle goal chrome", keywords: ["goal", "chrome", "zone"], dispatch: "residual", - category: "view", }, { id: "toggle_task", label: "Toggle task chrome", keywords: ["task", "work", "chrome"], dispatch: "residual", - category: "view", }, { id: "toggle_agents", label: "Toggle agents strip", keywords: ["agents", "strip", "workers"], dispatch: "residual", - category: "view", }, { id: "copy_active", label: "Copy active message / tool", keywords: ["copy", "clipboard", "yank"], dispatch: "residual", - category: "edit", }, { id: "toggle_mouse", label: "Toggle mouse capture (on by default; release it to drag-select)", keywords: ["mouse", "select", "selection", "copy", "drag"], dispatch: "residual", - category: "view", }, { id: "help", label: "Show keymap help", keywords: ["keys", "bindings", "help"], dispatch: "residual", - category: "view", }, { id: "settings", label: "Open settings", keywords: ["config", "preferences", "options"], dispatch: "residual", - category: "config", }, { id: "plugins", label: "Manage plugins", keywords: ["mcp", "extension", "plugin"], dispatch: "residual", - category: "config", }, { id: "resume", label: "Resume prior session", keywords: ["history", "session", "picker"], dispatch: "residual", - category: "session", }, { id: "mentions", label: "Insert file mention", keywords: ["@", "path", "file", "mention"], dispatch: "residual", - category: "edit", }, { id: "observe", label: "Observe subagent session", keywords: ["child", "worker", "observe", "agents"], dispatch: "residual", - category: "agent", }, ] @@ -232,7 +174,6 @@ export function commandsToPaletteItems( label: `/${c.name} — ${c.description}`, keywords: [c.name, "slash", "command"], dispatch: "command" as const, - category: categoryForCommandName(c.name), })) } @@ -310,7 +251,6 @@ export function paletteLabels( /** One palette row before it is fitted to a width. */ export type PaletteRowColumns = { - readonly category: string readonly label: string /** Empty when the entry has no chord. */ readonly shortcut: string @@ -321,41 +261,34 @@ export function paletteRowColumns( shortcutOf: (id: string) => string | undefined, ): PaletteRowColumns { return { - category: cmd.category ?? "command", label: cmd.label, shortcut: shortcutOf(cmd.id) ?? "", } } -/** Columns the label must keep before a side column is dropped. */ +/** Columns the label must keep before the shortcut column is dropped. */ const PALETTE_LABEL_MIN = 28 const PALETTE_COL_GAP = 2 export type PaletteRowLayout = { - readonly showCategory: boolean readonly showShortcut: boolean - readonly categoryWidth: number readonly shortcutWidth: number } /** - * Which columns survive at `width`. The shortcut goes first because it is - * redundant — the row it labels is right there and can be selected instead. - * The category goes second; the label alone is never dropped. + * Whether the shortcut column survives at `width`. It is redundant — the row + * it labels is right there and can be selected instead — so it is the one + * thing dropped when space is tight; the label itself is never truncated + * away entirely. */ export function paletteRowLayout( rows: readonly PaletteRowColumns[], width: number, ): PaletteRowLayout { - const categoryWidth = rows.reduce((n, r) => Math.max(n, stringWidth(r.category)), 0) const shortcutWidth = rows.reduce((n, r) => Math.max(n, stringWidth(r.shortcut)), 0) - const afterCategory = - width - (categoryWidth > 0 ? categoryWidth + PALETTE_COL_GAP : 0) const showShortcut = - shortcutWidth > 0 && - afterCategory - shortcutWidth - PALETTE_COL_GAP >= PALETTE_LABEL_MIN - const showCategory = categoryWidth > 0 && afterCategory >= PALETTE_LABEL_MIN - return { showCategory, showShortcut, categoryWidth, shortcutWidth } + shortcutWidth > 0 && width - shortcutWidth - PALETTE_COL_GAP >= PALETTE_LABEL_MIN + return { showShortcut, shortcutWidth } } function fitLabel(label: string, width: number): string { @@ -377,24 +310,20 @@ function padTo(text: string, width: number): string { } /** - * Render rows to exactly `width` columns: dim category, label, right-aligned - * chord. Column widths are shared across the batch so the three columns line up. + * Render rows to exactly `width` columns: label, right-aligned chord. The + * shortcut column's width is shared across the batch so chords line up. */ export function formatPaletteRows( rows: readonly PaletteRowColumns[], width: number, ): readonly string[] { const layout = paletteRowLayout(rows, width) - const head = layout.showCategory ? layout.categoryWidth + PALETTE_COL_GAP : 0 const tail = layout.showShortcut ? layout.shortcutWidth + PALETTE_COL_GAP : 0 - const labelWidth = Math.max(0, width - head - tail) + const labelWidth = Math.max(0, width - tail) return rows.map((row) => { - const category = layout.showCategory - ? row.category + padTo(row.category, layout.categoryWidth + PALETTE_COL_GAP) - : "" const shortcut = layout.showShortcut ? " ".repeat(PALETTE_COL_GAP) + padTo(row.shortcut, layout.shortcutWidth) + row.shortcut : "" - return `${category}${fitLabel(row.label, labelWidth)}${shortcut}` + return `${fitLabel(row.label, labelWidth)}${shortcut}` }) } diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index b37331d7c..b9a81ec73 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -1071,10 +1071,20 @@ function overlayAnswerRows(shell: AppShell): number { return overlayAnswerState(shell) === null ? 0 : 1 } +/** + * Every other list overlay spends a row on a title rule (`─ permission ─...`); + * the palette drops it — the box already reads as the palette, and the filter + * row underneath says what's typed, so the rule was a second header for the + * same fact. + */ +function overlayTitleRows(kind: PrimaryOverlayKind | null): number { + return kind === "palette" ? 0 : 1 +} + function overlayChromeRows(shell: AppShell, bodyLineCount: number): number { return ( OVERLAY_HOST_BORDER_ROWS + - 1 + + overlayTitleRows(shell.overlayKind) + bodyLineCount + overlayZoneRows(shell) + overlayAnswerRows(shell) @@ -1238,6 +1248,7 @@ function overlayHints(shell: AppShell): readonly string[] { function refreshOverlayTitle(shell: AppShell): void { const bag = internals.get(shell) if (!bag) return + shell.overlayTitle.visible = true shell.overlayTitle.content = overlayTitleLine( bag.overlayTitleText, overlayInteriorWidth(shell), @@ -3346,10 +3357,10 @@ function repaintPalette(shell: AppShell): void { body: `> ${state.query}`, frameId: "command-palette", }) - shell.overlayTitle.content = paletteTitleLine( - state.title, - overlayInteriorWidth(shell), - ) + // No title rule row: the box is only ever the palette, and the filter row + // underneath already shows what's typed — a second header said nothing new. + shell.overlayTitle.visible = false + shell.overlayTitle.content = "" paintOverlayList(shell) } @@ -3470,17 +3481,6 @@ export function handleOverlayAnswerKey( return true } -/** - * Palette title as a rule broken by the title, left-ish. The overlay host's own - * border is asserted elsewhere to be unbroken box-drawing, so the titled rule is - * a row inside the box rather than text written into the border itself. - */ -function paletteTitleLine(title: string, interior: number): string { - const head = `─ ${title} ` - if (head.length >= interior) return head.slice(0, Math.max(0, interior)) - return head + "─".repeat(interior - head.length) -} - /** * Which open surface a chord toggles shut, or null when the chord is not a * toggling opener. @@ -3582,6 +3582,7 @@ export function closeInsetOverlay(shell: AppShell): void { shell.overlayBodyFgs = prior.bodyFgs shell.overlayList = prior.list shell.paletteCommands = prior.paletteCommands + shell.overlayTitle.visible = true shell.overlayTitle.content = prior.title bag.overlayItemIds = prior.itemIds bag.overlayItemValues = prior.itemValues diff --git a/src/tui-opentui/wave6.test.ts b/src/tui-opentui/wave6.test.ts index 06fb12387..c02af88f2 100644 --- a/src/tui-opentui/wave6.test.ts +++ b/src/tui-opentui/wave6.test.ts @@ -47,7 +47,10 @@ describe("Wave 6: command palette", () => { await h.renderOnce() const frame = h.captureCharFrame() - expect(frame).toMatch(/palette/i) + // The palette drops its title rule row, so identify it on screen by + // its filter prompt and first row rather than the word "palette". + expect(frame).toMatch(/│\s*>\s*│/) + expect(frame).toContain("Open permissions") // List labels live in overlayItems (frame may clip first row under tight height). expect(shell.overlayItems[0]).toBe("Open permissions") expect(shell.overlayItems.some((l) => l.includes("permissions"))).toBe( diff --git a/src/tui-opentui/width-columns.test.ts b/src/tui-opentui/width-columns.test.ts index 8aaa951aa..5dbdcffd4 100644 --- a/src/tui-opentui/width-columns.test.ts +++ b/src/tui-opentui/width-columns.test.ts @@ -173,8 +173,8 @@ describe("palette rows", () => { test("rows are exactly `width` columns wide with CJK labels", () => { const rows = formatPaletteRows( [ - { category: "run", label: CJK, shortcut: "ctrl+o" }, - { category: "会話", label: "resume", shortcut: "?" }, + { label: CJK, shortcut: "ctrl+o" }, + { label: "resume", shortcut: "?" }, ], 48, ) @@ -183,7 +183,7 @@ describe("palette rows", () => { test("a too-wide label is cut to columns", () => { const [row] = formatPaletteRows( - [{ category: "", label: `${CJK}${CJK}`, shortcut: "" }], + [{ label: `${CJK}${CJK}`, shortcut: "" }], 12, ) expect(stringWidth(row ?? "")).toBe(12) From bd4c380d126211b37cebf41ca44335614c2c5206 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 7 Aug 2026 00:39:44 -0700 Subject: [PATCH 4/4] Mark the selected palette row by colour, not a grey band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The active row painted a filled grey background across its full width, the one list overlay in the shell that did — every other picker already marks its active row by upgrading the text colour and nothing else. Selection now works the same way here: the active label goes full-emphasis text, the rest stay dimmed, and the cursor reads unmistakably while arrowing through the list without a background fill competing with the accent colours already in the box. --- src/tui-opentui/palette-paint.test.ts | 38 +++++++++++++++++++++++++++ src/tui-opentui/shell.ts | 23 +++++----------- 2 files changed, 44 insertions(+), 17 deletions(-) diff --git a/src/tui-opentui/palette-paint.test.ts b/src/tui-opentui/palette-paint.test.ts index ba9ddbfd3..689224421 100644 --- a/src/tui-opentui/palette-paint.test.ts +++ b/src/tui-opentui/palette-paint.test.ts @@ -218,3 +218,41 @@ describe("command palette width", () => { }) }) +describe("command palette selection colour", () => { + test("marks the active row by text colour, not a filled background", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 100, rows: 32 }, + wireKeys: false, + run: "idle", + }) + openPalette(shell) + await h.renderOnce() + const frame = h.captureSpans() + const activeLine = frame.lines.find((line) => + line.spans.some((s) => s.text.includes("Open permissions")), + ) + const groundLine = frame.lines.find((line) => + line.spans.some((s) => s.text.includes("Ask operator question")), + ) + expect(activeLine).toBeDefined() + expect(groundLine).toBeDefined() + const activeBg = activeLine!.spans[0]!.bg + const groundBg = groundLine!.spans[0]!.bg + // Same background either way — selection reads through text colour + // (fg), not a filled band behind the row. + expect(activeBg).toEqual(groundBg) + const activeFg = activeLine!.spans.find((s) => + s.text.includes("Open permissions"), + )!.fg + const groundFg = groundLine!.spans.find((s) => + s.text.includes("Ask operator question"), + )!.fg + expect(activeFg).not.toEqual(groundFg) + }, + { width: 100, height: 32 }, + ) + }) +}) + diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index b9a81ec73..ae57d08fa 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -1261,34 +1261,23 @@ function overlayInteriorWidth(shell: AppShell): number { return overlayRowWidth(shell) + 2 } -/** Columns before the label column: leading space, selection marker, space. */ -const PALETTE_MARKER_WIDTH = 3 - /** - * Palette rows are three columns wide, and the active one is a full-width band - * rather than a recolored marker. The band is the warm faint tone, not the - * action orange: a palette selection is a cursor position, not a decision the - * shell is waiting on. + * Selection is a text colour, not a marker or a filled band: the highlighted + * row already stands out by sitting under the cursor, so a leading `>` and a + * grey block would both be saying the same thing twice. */ function paintPaletteList(shell: AppShell, list: ListViewportState): void { const interior = overlayInteriorWidth(shell) const columns = shell.paletteCommands.map((cmd) => paletteRowColumns(cmd, shortcutForPaletteId), ) - const lines = formatPaletteRows( - columns, - Math.max(4, interior - PALETTE_MARKER_WIDTH), - ) + const lines = formatPaletteRows(columns, Math.max(4, interior - 1)) const slice = visibleSlice(list) for (let i = slice.start; i < slice.end; i++) { const line = lines[i] ?? "" const active = i === list.activeIndex - const content = ` ${active ? ">" : " "} ${line}`.padEnd(interior) - if (active) { - addOverlayRow(shell, content, UI.text, UI.textFaint) - } else { - addOverlayRow(shell, content, UI.textDim) - } + const content = ` ${line}`.padEnd(interior) + addOverlayRow(shell, content, active ? UI.text : UI.textDim) } }