Skip to content

Commit 594c777

Browse files
committed
Keep type-to-filter overlays open on no-match Enter
acceptOverlaySelection closed before onAccept could refuse the empty-id sentinel, so the resume picker vanished on a failed filter. Refuse the sentinel at accept time. Slash-popup Enter on zero matches still dismisses.
1 parent 373a1f5 commit 594c777

7 files changed

Lines changed: 76 additions & 14 deletions

File tree

src/tui/list-modal.test.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,14 @@ describe("runListModal", () => {
8989
expect(harness.captureCharFrame()).toContain("(no matches)");
9090
harness.pressKey("Enter");
9191
await harness.renderOnce();
92-
harness.pressKey("Escape");
93-
expect(await choice).toBeNull();
92+
const afterEnter = harness.captureCharFrame();
93+
expect(afterEnter).toContain("(no matches)");
94+
expect(afterEnter).toContain(">");
95+
for (let i = 0; i < 5; i++) {
96+
harness.pressKey("Backspace");
97+
}
98+
await harness.renderOnce();
99+
harness.pressKey("Enter");
100+
expect(await choice).toBe("s-1");
94101
});
95102
});

src/tui/list-modal.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,7 @@ export async function runListModal(config: ListModalConfig): Promise<string | nu
108108
...(config.typeToFilter === true ? { typeToFilter: true } : {}),
109109
onAccept: (selection) => {
110110
const id = residualIdFromSelection(selection, itemIds);
111-
// Type-to-filter plants "(no matches)" with an empty id. Stay open.
112-
if (id === undefined || id.length === 0) return;
111+
if (id === undefined) return;
113112
settle(id);
114113
},
115114
});

src/tui/overlays.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* Wave 5: primary overlays — open / navigate / Esc restore + resize floors.
33
*/
44
import { describe, expect, test } from "bun:test";
5-
import { rgbToHex } from "@opentui/core";
5+
import { rgbToHex, type KeyEvent } from "@opentui/core";
66
import { IDLE_TRANSCRIPT_FLOOR, OVERLAY_TRANSCRIPT_FLOOR } from "./geometry/index";
77
import { focusOwner, scrollLease } from "./focus/index";
88
import { withTestRenderer } from "./harness";
@@ -18,6 +18,7 @@ import {
1818
clearShellOverlayHooks,
1919
closeInsetOverlay,
2020
createAppShell,
21+
handleListFilterKey,
2122
moveOverlaySelection,
2223
openListOverlay,
2324
pageOverlaySelection,
@@ -425,6 +426,44 @@ describe("overlay accept callbacks", () => {
425426
});
426427
});
427428

429+
describe("type-to-filter list overlay", () => {
430+
test("no-match Enter leaves overlayList set and does not echo Chose (no matches)", async () => {
431+
await withTestRenderer(
432+
async (h) => {
433+
const shell = createAppShell(h.renderer, {
434+
terminal: { columns: 80, rows: 24 },
435+
wireKeys: false,
436+
});
437+
try {
438+
openListOverlay(shell, {
439+
kind: "resume",
440+
items: ["First session", "Second session"],
441+
itemIds: ["s-1", "s-2"],
442+
typeToFilter: true,
443+
});
444+
const press = (seq: string): boolean =>
445+
handleListFilterKey(shell, {
446+
name: seq,
447+
sequence: seq,
448+
ctrl: false,
449+
meta: false,
450+
option: false,
451+
} as unknown as KeyEvent);
452+
for (const ch of "zzzzz") press(ch);
453+
expect(shell.overlayItems).toEqual(["(no matches)"]);
454+
acceptOverlaySelection(shell);
455+
expect(shell.overlayList).not.toBeNull();
456+
expect(shell.overlayItems).toEqual(["(no matches)"]);
457+
expect(shell.streamLog.some((row) => /Chose \(no matches\)/.test(row.text))).toBe(false);
458+
} finally {
459+
shell.dispose();
460+
}
461+
},
462+
{ width: 80, height: 24 },
463+
);
464+
});
465+
});
466+
428467
describe("resize mid-overlay", () => {
429468
test("80×24 ↔ larger keeps floors; closed restores idle floor", async () => {
430469
await withTestRenderer(

src/tui/palette-paint.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { KeyEvent } from "@opentui/core";
99
import { withTestRenderer } from "./harness";
1010
import type { PaletteCommand } from "./command-catalog";
1111
import {
12+
acceptOverlaySelection,
1213
createAppShell,
1314
handlePaletteFilterKey,
1415
moveOverlaySelection,
@@ -183,6 +184,17 @@ describe("palette filters as you type", () => {
183184
expect(shell.overlayKind).toBe("palette");
184185
});
185186
});
187+
188+
test("type-to-filter no-match Enter leaves the palette open", async () => {
189+
await withPalette((shell) => {
190+
for (const ch of "zzqq") press(shell, ch);
191+
expect(shell.overlayItems).toEqual(["(no matches)"]);
192+
acceptOverlaySelection(shell);
193+
expect(shell.overlayKind).toBe("palette");
194+
expect(shell.overlayList).not.toBeNull();
195+
expect(shell.overlayItems).toEqual(["(no matches)"]);
196+
});
197+
});
186198
});
187199

188200
const DESCRIBED_CATALOG: readonly PaletteCommand[] = [

src/tui/product-host.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,8 @@ describe("flat type-to-filter model picker", () => {
569569
expect(host.shell.overlayItems).toEqual(["(no matches)"]);
570570
acceptOverlaySelection(host.shell);
571571
expect(selected).toEqual([]);
572+
expect(host.shell.overlayList).not.toBeNull();
573+
expect(host.shell.overlayItems).toEqual(["(no matches)"]);
572574
} finally {
573575
host.dispose();
574576
harness.destroy();

src/tui/product-host.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -576,7 +576,7 @@ export async function mountProductHost(config: ProductHostConfig): Promise<Produ
576576
// back to `items[sel.index]` — that index is into the filtered list,
577577
// not the unfiltered catalog, so it would pick the wrong model.
578578
const id = sel.id;
579-
if (id === undefined || id.length === 0) return;
579+
if (id === undefined) return;
580580
onSelect(id);
581581
},
582582
describe: (itemId) => currentDescribeModel?.(itemId) ?? null,

src/tui/shell.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4330,22 +4330,25 @@ export function acceptOverlaySelection(shell: AppShell): void {
43304330
const idx = shell.overlayList.activeIndex;
43314331
const label = shell.overlayItems[idx] ?? `item ${idx}`;
43324332
const kind = shell.overlayKind ?? "demo";
4333+
const bag = internals.get(shell);
43334334

43344335
if (kind === "palette") {
43354336
const cmd = shell.paletteCommands[idx];
4336-
closeInsetOverlay(shell);
4337-
if (cmd) dispatchPaletteSelection(shell, cmd);
4338-
else {
4339-
appendStreamRow(shell, {
4340-
role: "system",
4341-
text: `palette: no action for ${label}`,
4342-
});
4337+
if (!cmd) {
4338+
// Type-to-filter plants a "(no matches)" row with no command. Stay open.
4339+
// Slash popup (`typeToFilter: false`) still closes — intentional dismiss.
4340+
if (bag?.paletteFilter?.typeToFilter === true && !isSlashPopupOpen(shell)) return;
4341+
closeInsetOverlay(shell);
4342+
return;
43434343
}
4344+
closeInsetOverlay(shell);
4345+
dispatchPaletteSelection(shell, cmd);
43444346
return;
43454347
}
43464348

4347-
const bag = internals.get(shell);
43484349
const id = bag?.overlayItemIds[idx];
4350+
// Type-to-filter plants "(no matches)" with an empty-id sentinel. Stay open.
4351+
if (id === "") return;
43494352
const value = bag?.overlayItemValues[idx];
43504353
const selection: OverlaySelection = {
43514354
kind,

0 commit comments

Comments
 (0)