Skip to content

Commit d853603

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 cb62977 commit d853603

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,
@@ -450,6 +451,44 @@ describe("overlay accept callbacks", () => {
450451
});
451452
});
452453

454+
describe("type-to-filter list overlay", () => {
455+
test("no-match Enter leaves overlayList set and does not echo Chose (no matches)", async () => {
456+
await withTestRenderer(
457+
async (h) => {
458+
const shell = createAppShell(h.renderer, {
459+
terminal: { columns: 80, rows: 24 },
460+
wireKeys: false,
461+
});
462+
try {
463+
openListOverlay(shell, {
464+
kind: "resume",
465+
items: ["First session", "Second session"],
466+
itemIds: ["s-1", "s-2"],
467+
typeToFilter: true,
468+
});
469+
const press = (seq: string): boolean =>
470+
handleListFilterKey(shell, {
471+
name: seq,
472+
sequence: seq,
473+
ctrl: false,
474+
meta: false,
475+
option: false,
476+
} as unknown as KeyEvent);
477+
for (const ch of "zzzzz") press(ch);
478+
expect(shell.overlayItems).toEqual(["(no matches)"]);
479+
acceptOverlaySelection(shell);
480+
expect(shell.overlayList).not.toBeNull();
481+
expect(shell.overlayItems).toEqual(["(no matches)"]);
482+
expect(shell.streamLog.some((row) => /Chose \(no matches\)/.test(row.text))).toBe(false);
483+
} finally {
484+
shell.dispose();
485+
}
486+
},
487+
{ width: 80, height: 24 },
488+
);
489+
});
490+
});
491+
453492
describe("resize mid-overlay", () => {
454493
test("80×24 ↔ larger keeps floors; closed restores idle floor", async () => {
455494
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
@@ -4338,22 +4338,25 @@ export function acceptOverlaySelection(shell: AppShell): void {
43384338
const idx = shell.overlayList.activeIndex;
43394339
const label = shell.overlayItems[idx] ?? `item ${idx}`;
43404340
const kind = shell.overlayKind ?? "demo";
4341+
const bag = internals.get(shell);
43414342

43424343
if (kind === "palette") {
43434344
const cmd = shell.paletteCommands[idx];
4344-
closeInsetOverlay(shell);
4345-
if (cmd) dispatchPaletteSelection(shell, cmd);
4346-
else {
4347-
appendStreamRow(shell, {
4348-
role: "system",
4349-
text: `palette: no action for ${label}`,
4350-
});
4345+
if (!cmd) {
4346+
// Type-to-filter plants a "(no matches)" row with no command. Stay open.
4347+
// Slash popup (`typeToFilter: false`) still closes — intentional dismiss.
4348+
if (bag?.paletteFilter?.typeToFilter === true && !isSlashPopupOpen(shell)) return;
4349+
closeInsetOverlay(shell);
4350+
return;
43514351
}
4352+
closeInsetOverlay(shell);
4353+
dispatchPaletteSelection(shell, cmd);
43524354
return;
43534355
}
43544356

4355-
const bag = internals.get(shell);
43564357
const id = bag?.overlayItemIds[idx];
4358+
// Type-to-filter plants "(no matches)" with an empty-id sentinel. Stay open.
4359+
if (id === "") return;
43574360
const value = bag?.overlayItemValues[idx];
43584361
const selection: OverlaySelection = {
43594362
kind,

0 commit comments

Comments
 (0)