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/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/palette-paint.test.ts b/src/tui-opentui/palette-paint.test.ts index c87bdfc32..689224421 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") @@ -193,3 +191,68 @@ 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("╮")) + }) +}) + +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/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 04b2701e2..ae57d08fa 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) @@ -1110,8 +1120,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 } @@ -1126,6 +1142,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, @@ -1217,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), @@ -1229,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) } } @@ -1693,6 +1714,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 +1747,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 +1874,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 +1896,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 +3224,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 +3245,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) } @@ -3304,10 +3346,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) } @@ -3428,17 +3470,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. @@ -3540,6 +3571,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 @@ -3560,7 +3592,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 +3714,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 +5543,7 @@ export function createAppShell( promptContentRows, overlayMode: "closed", overlayBodyRows: undefined, + overlayMinBodyRows: undefined, priorOverlay: null, overlayItemIds: [], overlayItemValues: [], 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)