From 637360cc6840a7c30fd5ab830c90db5169d5713b Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 13 Sep 2026 12:34:29 -0700 Subject: [PATCH 1/3] Add failing key-routing test for unclaimed overlay printables --- src/tui/overlay-key-routing.test.ts | 82 +++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 src/tui/overlay-key-routing.test.ts diff --git a/src/tui/overlay-key-routing.test.ts b/src/tui/overlay-key-routing.test.ts new file mode 100644 index 000000000..e6b07b52a --- /dev/null +++ b/src/tui/overlay-key-routing.test.ts @@ -0,0 +1,82 @@ +/** + * CL-6723: with a suggestion/picker list open, keystrokes the list doesn't + * use are silently dropped instead of reaching the prompt — typed filter + * text can vanish without feedback. Unclaimed printables must reach the + * prompt buffer, whether the open surface is a plain picker or an ephemeral + * popup. + */ +import { describe, expect, test } from "bun:test"; + +import { withTestRenderer } from "./harness"; +import type { PaletteCommand } from "./command-catalog"; +import { createAppShell } from "./shell/index"; +import type { AppShell } from "./shell/internals"; +import { openHelpOverlay } from "./shell/palette"; + +const CATALOG: readonly PaletteCommand[] = [ + { id: "model", label: "/model" }, + { id: "mcp", label: "/mcp" }, +]; + +interface Ctx { + readonly shell: AppShell; + readonly press: (key: string) => void; +} + +function withShell(fn: (ctx: Ctx) => Promise): Promise { + return withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: true, + run: "idle", + paletteCatalog: CATALOG, + }); + try { + await fn({ + shell, + press: (key) => h.pressKey(key as Parameters[0]), + }); + } finally { + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); +} + +describe("unclaimed overlay keys reach the prompt", () => { + test("printable a non-filter picker ignores lands in the prompt", async () => { + await withShell(async ({ shell, press }) => { + openHelpOverlay(shell); + expect(shell.overlayList).not.toBeNull(); + expect(shell.overlayKind).toBe("help"); + + press("x"); + expect(shell.prompt.value).toBe("x"); + expect(shell.overlayKind).toBe("help"); + }); + }); + + test("ephemeral popup symmetry: slash filter typing still reaches the prompt", async () => { + await withShell(async ({ shell, press }) => { + press("/"); + expect(shell.overlayKind).toBe("palette"); + + press("m"); + expect(shell.prompt.value).toBe("/m"); + expect(shell.overlayKind).toBe("palette"); + }); + }); + + test("claimed overlay keys keep precedence over the prompt", async () => { + await withShell(async ({ shell, press }) => { + openHelpOverlay(shell); + const before = shell.overlayList?.activeIndex ?? 0; + + press("j"); + expect(shell.overlayList?.activeIndex).toBe(before + 1); + expect(shell.prompt.value).toBe(""); + }); + }); +}); From 46c4546f7208633251e8840e558b730c1a937d3c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 13 Sep 2026 12:44:55 -0700 Subject: [PATCH 2/3] Route unclaimed overlay printables back to the prompt --- src/tui/shell/keys.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/tui/shell/keys.ts b/src/tui/shell/keys.ts index e6c4a3077..0e93cfd95 100644 --- a/src/tui/shell/keys.ts +++ b/src/tui/shell/keys.ts @@ -437,6 +437,22 @@ export function createShellKeyHandlers( return; } } + // Unclaimed printables fall through to the prompt instead of vanishing: + // the prompt does not hold focus while the overlay is open, so the + // InputRenderable cannot insert them itself. Decision surfaces are the + // modal exception (focus-routing): a permission/operator gate keeps + // every key until it is answered or dismissed. The overlay stays open + // (no dismiss, no idle-notify) so a queued gate cannot drain mid-list. + if ( + shell.overlayKind !== "permissions" && + shell.overlayKind !== "operator" && + isPrintableInsertKey(key) + ) { + key.preventDefault(); + shell.prompt.insertText(key.sequence as string); + shell.sentHistory = sentHistoryOnEdit(shell.sentHistory); + return; + } return; } From 976871cbef852e2bdcc1b2cdeac0c7f3ea3247ca Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 13 Sep 2026 15:40:58 -0700 Subject: [PATCH 3/3] Cover modal gate keepers and palette fallthrough in overlay key routing tests --- src/tui/overlay-key-routing.test.ts | 38 +++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/src/tui/overlay-key-routing.test.ts b/src/tui/overlay-key-routing.test.ts index e6b07b52a..d8c4b50f8 100644 --- a/src/tui/overlay-key-routing.test.ts +++ b/src/tui/overlay-key-routing.test.ts @@ -9,9 +9,11 @@ import { describe, expect, test } from "bun:test"; import { withTestRenderer } from "./harness"; import type { PaletteCommand } from "./command-catalog"; +import { openPermissionsOverlay, openOperatorOverlay } from "./overlays"; import { createAppShell } from "./shell/index"; import type { AppShell } from "./shell/internals"; -import { openHelpOverlay } from "./shell/palette"; +import { isSlashPopupOpen } from "./shell/internals"; +import { openHelpOverlay, openPalette } from "./shell/palette"; const CATALOG: readonly PaletteCommand[] = [ { id: "model", label: "/model" }, @@ -58,17 +60,43 @@ describe("unclaimed overlay keys reach the prompt", () => { }); }); - test("ephemeral popup symmetry: slash filter typing still reaches the prompt", async () => { + test("palette symmetry: unclaimed printable with a palette open reaches the prompt via the fallthrough", async () => { await withShell(async ({ shell, press }) => { - press("/"); + openPalette(shell, { catalog: CATALOG }); expect(shell.overlayKind).toBe("palette"); + expect(isSlashPopupOpen(shell)).toBe(false); - press("m"); - expect(shell.prompt.value).toBe("/m"); + press("x"); + expect(shell.prompt.value).toBe("x"); expect(shell.overlayKind).toBe("palette"); }); }); + test("permission gate keeps printable keys: prompt stays empty, gate stays open", async () => { + await withShell(async ({ shell, press }) => { + openPermissionsOverlay(shell, { items: ["Allow once", "Deny"] }); + expect(shell.overlayKind).toBe("permissions"); + + press("x"); + expect(shell.prompt.value).toBe(""); + expect(shell.overlayKind).toBe("permissions"); + }); + }); + + test("operator gate keeps printable keys: prompt stays empty, gate stays open", async () => { + await withShell(async ({ shell, press }) => { + openOperatorOverlay(shell, { + body: "Proceed?", + choices: ["Cancel", "Continue"], + }); + expect(shell.overlayKind).toBe("operator"); + + press("x"); + expect(shell.prompt.value).toBe(""); + expect(shell.overlayKind).toBe("operator"); + }); + }); + test("claimed overlay keys keep precedence over the prompt", async () => { await withShell(async ({ shell, press }) => { openHelpOverlay(shell);