From e42efc8eb9db12df9d7d54864f5b966e29f0ff87 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 12 Sep 2026 20:33:24 -0700 Subject: [PATCH 1/2] Give empty overlays zero list rows and an explicit empty state --- src/tui/overlay-empty-state.test.ts | 192 ++++++++++++++++++++++++++++ src/tui/overlay-view.test.ts | 2 +- src/tui/overlay-view.ts | 8 ++ src/tui/shell/chrome.ts | 2 +- src/tui/shell/overlay-host.ts | 25 +--- src/tui/shell/overlay-list.ts | 8 +- 6 files changed, 210 insertions(+), 27 deletions(-) create mode 100644 src/tui/overlay-empty-state.test.ts diff --git a/src/tui/overlay-empty-state.test.ts b/src/tui/overlay-empty-state.test.ts new file mode 100644 index 000000000..b787a92e8 --- /dev/null +++ b/src/tui/overlay-empty-state.test.ts @@ -0,0 +1,192 @@ +/** + * CL-6720: an overlay with nothing to choose reserves zero list rows and + * paints an explicit empty state inside the body chrome — including on a + * short terminal, which must not reserve a phantom choice row. + */ +import { describe, expect, test } from "bun:test"; + +import { withTestRenderer } from "./harness.js"; +import { OVERLAY_EMPTY_STATE, overlayChromeRows } from "./overlay-view.js"; +import { appendStreamRow } from "./shell/chrome.js"; +import { createAppShell } from "./shell/index.js"; +import type { AppShell } from "./shell/internals.js"; +import { + closeInsetOverlay, + openListOverlay, + setOverlayBody, + setOwnedOverlayItems, +} from "./shell/overlay-host.js"; +import { createOverlayList } from "./shell/overlay-list.js"; + +interface Size { + readonly width: number; + readonly height: number; +} + +async function withShell( + fn: (shell: AppShell, frame: () => string) => Promise | void, + size: Size = { width: 100, height: 60 }, +): Promise { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: size.width, rows: size.height }, + wireKeys: false, + }); + try { + appendStreamRow(shell, { role: "assistant", text: "session underway" }); + await fn(shell, () => h.captureCharFrame()); + await h.renderOnce(); + } finally { + shell.dispose(); + } + }, size); +} + +describe("empty overlay layout", () => { + test("zero rows reserve zero height", async () => { + await withShell((shell) => { + openListOverlay(shell, { kind: "demo", items: [] }); + try { + expect(shell.overlayList?.height).toBe(0); + // Zero list rows; the host carries chrome plus the one empty-state row. + const chrome = overlayChromeRows( + "demo", + shell.overlayBodyLines.length + 1, + false, + false, + ); + expect(shell.layout.heights.overlay_host).toBe(chrome); + } finally { + closeInsetOverlay(shell); + } + }); + }); + + test("an explicit empty state paints inside the body chrome", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + }); + try { + appendStreamRow(shell, { + role: "assistant", + text: "session underway", + }); + openListOverlay(shell, { kind: "demo", items: [] }); + await h.renderOnce(); + const frame = h.captureCharFrame(); + expect(frame).toContain(OVERLAY_EMPTY_STATE); + } finally { + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + + test("replacing the body on an empty overlay keeps zero rows", async () => { + await withShell((shell) => { + openListOverlay(shell, { kind: "demo", items: [] }); + try { + setOverlayBody(shell, "context line"); + expect(shell.overlayList?.height).toBe(0); + const chrome = overlayChromeRows( + "demo", + shell.overlayBodyLines.length + 1, + false, + false, + ); + expect(shell.layout.heights.overlay_host).toBe(chrome); + } finally { + closeInsetOverlay(shell); + } + }); + }); + + test("replacing all items with none collapses to zero rows", async () => { + await withShell((shell) => { + openListOverlay(shell, { kind: "demo", items: ["a", "b"] }); + try { + expect(setOwnedOverlayItems(shell, "demo", [], [])).toBe(true); + expect(shell.overlayList?.height).toBe(0); + } finally { + closeInsetOverlay(shell); + } + }); + }); + + test("a short terminal reserves no phantom choice row for an empty overlay", async () => { + const size = { width: 80, height: 8 }; + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: size.width, rows: size.height }, + wireKeys: false, + }); + try { + appendStreamRow(shell, { role: "assistant", text: "session underway" }); + openListOverlay(shell, { kind: "demo", items: [] }); + await h.renderOnce(); + await h.renderOnce(); + expect(shell.overlayList?.height).toBe(0); + const chrome = overlayChromeRows( + "demo", + shell.overlayBodyLines.length + 1, + false, + false, + ); + expect(shell.layout.heights.overlay_host).toBe(chrome); + const frame = h.captureCharFrame(); + const lines = frame.replace(/\n$/, "").split("\n"); + const top = lines.findIndex((l) => l.trimStart().startsWith("┌")); + const bottom = lines.findIndex( + (l, i) => i > top && l.trimStart().startsWith("└"), + ); + expect(top).toBeGreaterThanOrEqual(0); + expect(bottom).toBeGreaterThan(top); + expect(bottom).toBeLessThan(lines.length); + expect(frame).toContain(OVERLAY_EMPTY_STATE); + } finally { + shell.dispose(); + } + }, size); + }); + + test("a non-empty overlay keeps its rows and paints no empty state", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + }); + try { + appendStreamRow(shell, { + role: "assistant", + text: "session underway", + }); + openListOverlay(shell, { kind: "demo", items: ["alpha", "beta"] }); + await h.renderOnce(); + expect(shell.overlayList?.height).toBe(2); + const frame = h.captureCharFrame(); + expect(frame).toContain("alpha"); + expect(frame).not.toContain(OVERLAY_EMPTY_STATE); + } finally { + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + + test("an empty list wrapper starts at zero rows and still grows", async () => { + await withTestRenderer(async (h) => { + const list = createOverlayList(h.renderer, { count: 0, items: 0 }); + expect(list.height).toBe(0); + list.setHeight(0); + expect(list.height).toBe(0); + list.setHeight(2); + expect(list.height).toBe(2); + }); + }); +}); diff --git a/src/tui/overlay-view.test.ts b/src/tui/overlay-view.test.ts index c326e5b3a..958dcc9a9 100644 --- a/src/tui/overlay-view.test.ts +++ b/src/tui/overlay-view.test.ts @@ -57,7 +57,7 @@ async function paletteFrame( ...presentation, list: createOverlayList(h.renderer, { count: presentation.items.length, - items: Math.max(1, presentation.items.length), + items: presentation.items.length, }), }, width, diff --git a/src/tui/overlay-view.ts b/src/tui/overlay-view.ts index 3f0fb8bbe..36b5e798b 100644 --- a/src/tui/overlay-view.ts +++ b/src/tui/overlay-view.ts @@ -50,6 +50,13 @@ export interface OverlayListPresentation { */ export const OVERLAY_HOST_BORDER_ROWS = 2; +/** + * What an overlay with no choices paints inside the body chrome (CL-6720). + * Distinct from the "(no matches)" filter sentinel, which is a real choice + * row — this paints when the list itself is empty and reserves zero rows. + */ +export const OVERLAY_EMPTY_STATE = "(no choices)"; + /** Rule row plus the fixed two content lines — charged whenever `describe` is set. */ const DESCRIPTION_ZONE_ROWS = 1 + DESCRIPTION_ZONE_LINES; @@ -400,6 +407,7 @@ export function createOverlayView(ctx: RenderContext) { // for its background, spending layout budget a chooser with no choices did // not reserve. if (presentation.items.length > 0) body.add(list.select); + else addOverlayRow(` ${OVERLAY_EMPTY_STATE}`, UI.textDim); paintAnswerRow(presentation.answer, contentWidth); paintDescriptionZone(presentation.describe, contentWidth); } diff --git a/src/tui/shell/chrome.ts b/src/tui/shell/chrome.ts index acb3ed9b6..186cd88ca 100644 --- a/src/tui/shell/chrome.ts +++ b/src/tui/shell/chrome.ts @@ -750,7 +750,7 @@ function fitOverlayListToHost(shell: AppShell, hostH: number): void { chrome = chromeOf(bodyCount); } const bodyH = Math.max(0, hostH - chrome); - if (bodyH >= perItem) { + if (hasItems && bodyH >= perItem) { list.setHeight( Math.max(1, Math.floor(bodyH / perItem)), isDecisionOverlay(shell.overlayKind) ? DECISION_CHOICE_ROWS : 1, diff --git a/src/tui/shell/overlay-host.ts b/src/tui/shell/overlay-host.ts index 99bd26fe8..3d85873e2 100644 --- a/src/tui/shell/overlay-host.ts +++ b/src/tui/shell/overlay-host.ts @@ -13,8 +13,6 @@ import { overlayRowWidth, overlayRowsPerItem, overlayTitleRows, - overlayChromeRows, - overlayMinHostRows, OVERLAY_HOST_BORDER_ROWS, } from "../overlay-view.js"; import { @@ -243,7 +241,7 @@ export function openListOverlay( shell.overlayList = createOverlayList(shell.renderer as CliRenderer, { count: labels.length, - items: Math.max(1, listItems), + items: listItems, activeIndex: opts?.activeIndex ?? 0, }); @@ -646,24 +644,7 @@ export function setOverlayBody( // Ask for the whole list again, not the height it currently has: a body that // shrank should hand its rows back to the choices rather than leave the // viewport stuck at the size an earlier, taller body forced it to. - const perItem = overlayRowsPerItem(shell.overlayKind); - const chrome = overlayChromeRows( - shell.overlayKind, - shell.overlayBodyLines.length, - !!shellInternals(shell)?.primaryBindings.describe, - overlayAnswerState(shell) !== null, - ); - const hostRows = chrome + Math.max(1, shell.overlayItems.length) * perItem; - const minHostRows = overlayMinHostRows( - chrome, - perItem, - shell.overlayItems.length > 0, - ); - relayout(shell, { - overlayMode: "inset", - overlayBodyRows: hostRows, - overlayMinBodyRows: minHostRows, - }); + relayoutOverlayHost(shell, shell.overlayItems.length); paintOverlayList(shell); } @@ -735,7 +716,7 @@ export function setOwnedOverlayItems( paintOverlayList(shell); } if (displayedCount !== previousCount) { - shell.overlayList?.setHeight(Math.max(1, displayedCount)); + shell.overlayList?.setHeight(displayedCount); relayoutOverlayHost(shell, displayedCount); paintOverlayList(shell); } diff --git a/src/tui/shell/overlay-list.ts b/src/tui/shell/overlay-list.ts index ef2a28c7d..dc29a8b97 100644 --- a/src/tui/shell/overlay-list.ts +++ b/src/tui/shell/overlay-list.ts @@ -104,9 +104,11 @@ export function dispatchOverlayAccept( */ export function relayoutOverlayHost(shell: AppShell, itemCount: number): void { const perItem = overlayRowsPerItem(shell.overlayKind); + // An empty list reserves zero rows but still paints its one-line empty + // state, so the chrome budget carries that row as a body line (CL-6720). const chrome = overlayChromeRows( shell.overlayKind, - shell.overlayBodyLines.length, + shell.overlayBodyLines.length + (itemCount === 0 ? 1 : 0), !!shellInternals(shell)?.primaryBindings.describe, overlayAnswerState(shell) !== null, ); @@ -156,7 +158,7 @@ export function createOverlayList( opts: { count: number; items: number; activeIndex?: number }, ): OverlayList { let shape: OverlayListShape = { - items: Math.max(1, opts.items), + items: Math.max(0, opts.items), rowsPerItem: 1, }; let count = Math.max(0, opts.count); @@ -244,7 +246,7 @@ export function createOverlayList( }, setHeight(items: number, rowsPerItem?: number) { reshape({ - items: Math.max(1, Math.floor(items)), + items: Math.max(0, Math.floor(items)), ...(rowsPerItem ? { rowsPerItem } : {}), }); }, From 8c9ac72daf7043eaa808f04e0a8721b0b5f053f2 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 12 Sep 2026 21:52:46 -0700 Subject: [PATCH 2/2] Pin empty-overlay accept as a no-op openListOverlay passes activeIndex 0 with empty labels; acceptOverlaySelection already stays open without firing accept. This test keeps that guard from regressing. --- src/tui/palette-paint.test.ts | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/tui/palette-paint.test.ts b/src/tui/palette-paint.test.ts index 16aa46101..981a93712 100644 --- a/src/tui/palette-paint.test.ts +++ b/src/tui/palette-paint.test.ts @@ -11,7 +11,7 @@ import { withTestRenderer } from "./harness"; import type { PaletteCommand } from "./command-catalog"; import { createAppShell } from "./shell/index"; import type { AppShell } from "./shell/internals"; -import { acceptOverlaySelection } from "./shell/overlay-host"; +import { acceptOverlaySelection, openListOverlay } from "./shell/overlay-host"; import { moveOverlaySelection } from "./shell/overlay-list"; import { handlePaletteFilterKey, openPalette } from "./shell/palette"; @@ -192,6 +192,36 @@ describe("palette filters as you type", () => { expect(shell.overlayItems).toEqual(["(no matches)"]); }); }); + + test("accept on an empty overlay is a no-op", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 100, rows: 32 }, + wireKeys: false, + run: "idle", + }); + try { + let accepted = 0; + openListOverlay(shell, { + kind: "demo", + items: [], + onAccept: () => { + accepted += 1; + }, + }); + expect(shell.overlayItems).toEqual([]); + acceptOverlaySelection(shell); + expect(accepted).toBe(0); + expect(shell.overlayList).not.toBeNull(); + expect(shell.overlayItems).toEqual([]); + } finally { + shell.dispose(); + } + }, + { width: 100, height: 32 }, + ); + }); }); const DESCRIBED_CATALOG: readonly PaletteCommand[] = [