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
110 changes: 110 additions & 0 deletions src/tui/overlay-key-routing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* 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 { openPermissionsOverlay, openOperatorOverlay } from "./overlays";
import { createAppShell } from "./shell/index";
import type { AppShell } from "./shell/internals";
import { isSlashPopupOpen } from "./shell/internals";
import { openHelpOverlay, openPalette } 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<void>): Promise<void> {
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<typeof h.pressKey>[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("palette symmetry: unclaimed printable with a palette open reaches the prompt via the fallthrough", async () => {
await withShell(async ({ shell, press }) => {
openPalette(shell, { catalog: CATALOG });
expect(shell.overlayKind).toBe("palette");
expect(isSlashPopupOpen(shell)).toBe(false);

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);
const before = shell.overlayList?.activeIndex ?? 0;

press("j");
expect(shell.overlayList?.activeIndex).toBe(before + 1);
expect(shell.prompt.value).toBe("");
});
});
});
16 changes: 16 additions & 0 deletions src/tui/shell/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Loading