Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 192 additions & 0 deletions src/tui/overlay-empty-state.test.ts
Original file line number Diff line number Diff line change
@@ -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> | void,
size: Size = { width: 100, height: 60 },
): Promise<void> {
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);
});
});
});
2 changes: 1 addition & 1 deletion src/tui/overlay-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions src/tui/overlay-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
}
Expand Down
32 changes: 31 additions & 1 deletion src/tui/palette-paint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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[] = [
Expand Down
2 changes: 1 addition & 1 deletion src/tui/shell/chrome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 3 additions & 22 deletions src/tui/shell/overlay-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ import {
overlayRowWidth,
overlayRowsPerItem,
overlayTitleRows,
overlayChromeRows,
overlayMinHostRows,
OVERLAY_HOST_BORDER_ROWS,
} from "../overlay-view.js";
import {
Expand Down Expand Up @@ -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,
});

Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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);
}
Expand Down
8 changes: 5 additions & 3 deletions src/tui/shell/overlay-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 } : {}),
});
},
Expand Down
Loading