Skip to content

Commit b6c9afd

Browse files
Give empty overlays zero list rows and an explicit empty state (#975)
* Give empty overlays zero list rows and an explicit empty state * 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.
1 parent f896652 commit b6c9afd

7 files changed

Lines changed: 241 additions & 28 deletions

File tree

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
/**
2+
* CL-6720: an overlay with nothing to choose reserves zero list rows and
3+
* paints an explicit empty state inside the body chrome — including on a
4+
* short terminal, which must not reserve a phantom choice row.
5+
*/
6+
import { describe, expect, test } from "bun:test";
7+
8+
import { withTestRenderer } from "./harness.js";
9+
import { OVERLAY_EMPTY_STATE, overlayChromeRows } from "./overlay-view.js";
10+
import { appendStreamRow } from "./shell/chrome.js";
11+
import { createAppShell } from "./shell/index.js";
12+
import type { AppShell } from "./shell/internals.js";
13+
import {
14+
closeInsetOverlay,
15+
openListOverlay,
16+
setOverlayBody,
17+
setOwnedOverlayItems,
18+
} from "./shell/overlay-host.js";
19+
import { createOverlayList } from "./shell/overlay-list.js";
20+
21+
interface Size {
22+
readonly width: number;
23+
readonly height: number;
24+
}
25+
26+
async function withShell(
27+
fn: (shell: AppShell, frame: () => string) => Promise<void> | void,
28+
size: Size = { width: 100, height: 60 },
29+
): Promise<void> {
30+
await withTestRenderer(async (h) => {
31+
const shell = createAppShell(h.renderer, {
32+
terminal: { columns: size.width, rows: size.height },
33+
wireKeys: false,
34+
});
35+
try {
36+
appendStreamRow(shell, { role: "assistant", text: "session underway" });
37+
await fn(shell, () => h.captureCharFrame());
38+
await h.renderOnce();
39+
} finally {
40+
shell.dispose();
41+
}
42+
}, size);
43+
}
44+
45+
describe("empty overlay layout", () => {
46+
test("zero rows reserve zero height", async () => {
47+
await withShell((shell) => {
48+
openListOverlay(shell, { kind: "demo", items: [] });
49+
try {
50+
expect(shell.overlayList?.height).toBe(0);
51+
// Zero list rows; the host carries chrome plus the one empty-state row.
52+
const chrome = overlayChromeRows(
53+
"demo",
54+
shell.overlayBodyLines.length + 1,
55+
false,
56+
false,
57+
);
58+
expect(shell.layout.heights.overlay_host).toBe(chrome);
59+
} finally {
60+
closeInsetOverlay(shell);
61+
}
62+
});
63+
});
64+
65+
test("an explicit empty state paints inside the body chrome", async () => {
66+
await withTestRenderer(
67+
async (h) => {
68+
const shell = createAppShell(h.renderer, {
69+
terminal: { columns: 80, rows: 24 },
70+
wireKeys: false,
71+
});
72+
try {
73+
appendStreamRow(shell, {
74+
role: "assistant",
75+
text: "session underway",
76+
});
77+
openListOverlay(shell, { kind: "demo", items: [] });
78+
await h.renderOnce();
79+
const frame = h.captureCharFrame();
80+
expect(frame).toContain(OVERLAY_EMPTY_STATE);
81+
} finally {
82+
shell.dispose();
83+
}
84+
},
85+
{ width: 80, height: 24 },
86+
);
87+
});
88+
89+
test("replacing the body on an empty overlay keeps zero rows", async () => {
90+
await withShell((shell) => {
91+
openListOverlay(shell, { kind: "demo", items: [] });
92+
try {
93+
setOverlayBody(shell, "context line");
94+
expect(shell.overlayList?.height).toBe(0);
95+
const chrome = overlayChromeRows(
96+
"demo",
97+
shell.overlayBodyLines.length + 1,
98+
false,
99+
false,
100+
);
101+
expect(shell.layout.heights.overlay_host).toBe(chrome);
102+
} finally {
103+
closeInsetOverlay(shell);
104+
}
105+
});
106+
});
107+
108+
test("replacing all items with none collapses to zero rows", async () => {
109+
await withShell((shell) => {
110+
openListOverlay(shell, { kind: "demo", items: ["a", "b"] });
111+
try {
112+
expect(setOwnedOverlayItems(shell, "demo", [], [])).toBe(true);
113+
expect(shell.overlayList?.height).toBe(0);
114+
} finally {
115+
closeInsetOverlay(shell);
116+
}
117+
});
118+
});
119+
120+
test("a short terminal reserves no phantom choice row for an empty overlay", async () => {
121+
const size = { width: 80, height: 8 };
122+
await withTestRenderer(async (h) => {
123+
const shell = createAppShell(h.renderer, {
124+
terminal: { columns: size.width, rows: size.height },
125+
wireKeys: false,
126+
});
127+
try {
128+
appendStreamRow(shell, { role: "assistant", text: "session underway" });
129+
openListOverlay(shell, { kind: "demo", items: [] });
130+
await h.renderOnce();
131+
await h.renderOnce();
132+
expect(shell.overlayList?.height).toBe(0);
133+
const chrome = overlayChromeRows(
134+
"demo",
135+
shell.overlayBodyLines.length + 1,
136+
false,
137+
false,
138+
);
139+
expect(shell.layout.heights.overlay_host).toBe(chrome);
140+
const frame = h.captureCharFrame();
141+
const lines = frame.replace(/\n$/, "").split("\n");
142+
const top = lines.findIndex((l) => l.trimStart().startsWith("┌"));
143+
const bottom = lines.findIndex(
144+
(l, i) => i > top && l.trimStart().startsWith("└"),
145+
);
146+
expect(top).toBeGreaterThanOrEqual(0);
147+
expect(bottom).toBeGreaterThan(top);
148+
expect(bottom).toBeLessThan(lines.length);
149+
expect(frame).toContain(OVERLAY_EMPTY_STATE);
150+
} finally {
151+
shell.dispose();
152+
}
153+
}, size);
154+
});
155+
156+
test("a non-empty overlay keeps its rows and paints no empty state", async () => {
157+
await withTestRenderer(
158+
async (h) => {
159+
const shell = createAppShell(h.renderer, {
160+
terminal: { columns: 80, rows: 24 },
161+
wireKeys: false,
162+
});
163+
try {
164+
appendStreamRow(shell, {
165+
role: "assistant",
166+
text: "session underway",
167+
});
168+
openListOverlay(shell, { kind: "demo", items: ["alpha", "beta"] });
169+
await h.renderOnce();
170+
expect(shell.overlayList?.height).toBe(2);
171+
const frame = h.captureCharFrame();
172+
expect(frame).toContain("alpha");
173+
expect(frame).not.toContain(OVERLAY_EMPTY_STATE);
174+
} finally {
175+
shell.dispose();
176+
}
177+
},
178+
{ width: 80, height: 24 },
179+
);
180+
});
181+
182+
test("an empty list wrapper starts at zero rows and still grows", async () => {
183+
await withTestRenderer(async (h) => {
184+
const list = createOverlayList(h.renderer, { count: 0, items: 0 });
185+
expect(list.height).toBe(0);
186+
list.setHeight(0);
187+
expect(list.height).toBe(0);
188+
list.setHeight(2);
189+
expect(list.height).toBe(2);
190+
});
191+
});
192+
});

‎src/tui/overlay-view.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ async function paletteFrame(
5757
...presentation,
5858
list: createOverlayList(h.renderer, {
5959
count: presentation.items.length,
60-
items: Math.max(1, presentation.items.length),
60+
items: presentation.items.length,
6161
}),
6262
},
6363
width,

‎src/tui/overlay-view.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@ export interface OverlayListPresentation {
5050
*/
5151
export const OVERLAY_HOST_BORDER_ROWS = 2;
5252

53+
/**
54+
* What an overlay with no choices paints inside the body chrome (CL-6720).
55+
* Distinct from the "(no matches)" filter sentinel, which is a real choice
56+
* row — this paints when the list itself is empty and reserves zero rows.
57+
*/
58+
export const OVERLAY_EMPTY_STATE = "(no choices)";
59+
5360
/** Rule row plus the fixed two content lines — charged whenever `describe` is set. */
5461
const DESCRIPTION_ZONE_ROWS = 1 + DESCRIPTION_ZONE_LINES;
5562

@@ -400,6 +407,7 @@ export function createOverlayView(ctx: RenderContext) {
400407
// for its background, spending layout budget a chooser with no choices did
401408
// not reserve.
402409
if (presentation.items.length > 0) body.add(list.select);
410+
else addOverlayRow(` ${OVERLAY_EMPTY_STATE}`, UI.textDim);
403411
paintAnswerRow(presentation.answer, contentWidth);
404412
paintDescriptionZone(presentation.describe, contentWidth);
405413
}

‎src/tui/palette-paint.test.ts‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { withTestRenderer } from "./harness";
1111
import type { PaletteCommand } from "./command-catalog";
1212
import { createAppShell } from "./shell/index";
1313
import type { AppShell } from "./shell/internals";
14-
import { acceptOverlaySelection } from "./shell/overlay-host";
14+
import { acceptOverlaySelection, openListOverlay } from "./shell/overlay-host";
1515
import { moveOverlaySelection } from "./shell/overlay-list";
1616
import { handlePaletteFilterKey, openPalette } from "./shell/palette";
1717

@@ -192,6 +192,36 @@ describe("palette filters as you type", () => {
192192
expect(shell.overlayItems).toEqual(["(no matches)"]);
193193
});
194194
});
195+
196+
test("accept on an empty overlay is a no-op", async () => {
197+
await withTestRenderer(
198+
async (h) => {
199+
const shell = createAppShell(h.renderer, {
200+
terminal: { columns: 100, rows: 32 },
201+
wireKeys: false,
202+
run: "idle",
203+
});
204+
try {
205+
let accepted = 0;
206+
openListOverlay(shell, {
207+
kind: "demo",
208+
items: [],
209+
onAccept: () => {
210+
accepted += 1;
211+
},
212+
});
213+
expect(shell.overlayItems).toEqual([]);
214+
acceptOverlaySelection(shell);
215+
expect(accepted).toBe(0);
216+
expect(shell.overlayList).not.toBeNull();
217+
expect(shell.overlayItems).toEqual([]);
218+
} finally {
219+
shell.dispose();
220+
}
221+
},
222+
{ width: 100, height: 32 },
223+
);
224+
});
195225
});
196226

197227
const DESCRIBED_CATALOG: readonly PaletteCommand[] = [

‎src/tui/shell/chrome.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -750,7 +750,7 @@ function fitOverlayListToHost(shell: AppShell, hostH: number): void {
750750
chrome = chromeOf(bodyCount);
751751
}
752752
const bodyH = Math.max(0, hostH - chrome);
753-
if (bodyH >= perItem) {
753+
if (hasItems && bodyH >= perItem) {
754754
list.setHeight(
755755
Math.max(1, Math.floor(bodyH / perItem)),
756756
isDecisionOverlay(shell.overlayKind) ? DECISION_CHOICE_ROWS : 1,

‎src/tui/shell/overlay-host.ts‎

Lines changed: 3 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@ import {
1313
overlayRowWidth,
1414
overlayRowsPerItem,
1515
overlayTitleRows,
16-
overlayChromeRows,
17-
overlayMinHostRows,
1816
OVERLAY_HOST_BORDER_ROWS,
1917
} from "../overlay-view.js";
2018
import {
@@ -243,7 +241,7 @@ export function openListOverlay(
243241

244242
shell.overlayList = createOverlayList(shell.renderer as CliRenderer, {
245243
count: labels.length,
246-
items: Math.max(1, listItems),
244+
items: listItems,
247245
activeIndex: opts?.activeIndex ?? 0,
248246
});
249247

@@ -646,24 +644,7 @@ export function setOverlayBody(
646644
// Ask for the whole list again, not the height it currently has: a body that
647645
// shrank should hand its rows back to the choices rather than leave the
648646
// viewport stuck at the size an earlier, taller body forced it to.
649-
const perItem = overlayRowsPerItem(shell.overlayKind);
650-
const chrome = overlayChromeRows(
651-
shell.overlayKind,
652-
shell.overlayBodyLines.length,
653-
!!shellInternals(shell)?.primaryBindings.describe,
654-
overlayAnswerState(shell) !== null,
655-
);
656-
const hostRows = chrome + Math.max(1, shell.overlayItems.length) * perItem;
657-
const minHostRows = overlayMinHostRows(
658-
chrome,
659-
perItem,
660-
shell.overlayItems.length > 0,
661-
);
662-
relayout(shell, {
663-
overlayMode: "inset",
664-
overlayBodyRows: hostRows,
665-
overlayMinBodyRows: minHostRows,
666-
});
647+
relayoutOverlayHost(shell, shell.overlayItems.length);
667648
paintOverlayList(shell);
668649
}
669650

@@ -735,7 +716,7 @@ export function setOwnedOverlayItems(
735716
paintOverlayList(shell);
736717
}
737718
if (displayedCount !== previousCount) {
738-
shell.overlayList?.setHeight(Math.max(1, displayedCount));
719+
shell.overlayList?.setHeight(displayedCount);
739720
relayoutOverlayHost(shell, displayedCount);
740721
paintOverlayList(shell);
741722
}

‎src/tui/shell/overlay-list.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,11 @@ export function dispatchOverlayAccept(
104104
*/
105105
export function relayoutOverlayHost(shell: AppShell, itemCount: number): void {
106106
const perItem = overlayRowsPerItem(shell.overlayKind);
107+
// An empty list reserves zero rows but still paints its one-line empty
108+
// state, so the chrome budget carries that row as a body line (CL-6720).
107109
const chrome = overlayChromeRows(
108110
shell.overlayKind,
109-
shell.overlayBodyLines.length,
111+
shell.overlayBodyLines.length + (itemCount === 0 ? 1 : 0),
110112
!!shellInternals(shell)?.primaryBindings.describe,
111113
overlayAnswerState(shell) !== null,
112114
);
@@ -156,7 +158,7 @@ export function createOverlayList(
156158
opts: { count: number; items: number; activeIndex?: number },
157159
): OverlayList {
158160
let shape: OverlayListShape = {
159-
items: Math.max(1, opts.items),
161+
items: Math.max(0, opts.items),
160162
rowsPerItem: 1,
161163
};
162164
let count = Math.max(0, opts.count);
@@ -244,7 +246,7 @@ export function createOverlayList(
244246
},
245247
setHeight(items: number, rowsPerItem?: number) {
246248
reshape({
247-
items: Math.max(1, Math.floor(items)),
249+
items: Math.max(0, Math.floor(items)),
248250
...(rowsPerItem ? { rowsPerItem } : {}),
249251
});
250252
},

0 commit comments

Comments
 (0)