Skip to content

Commit 05f390d

Browse files
Merge pull request #1011 from corbitsdev/cl-6723-route-unclaimed-ephemeral-popup-keys-back-to-the-prompt
Route unclaimed popup keys back to the prompt
2 parents d69c64c + 976871c commit 05f390d

2 files changed

Lines changed: 126 additions & 0 deletions

File tree

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/**
2+
* CL-6723: with a suggestion/picker list open, keystrokes the list doesn't
3+
* use are silently dropped instead of reaching the prompt — typed filter
4+
* text can vanish without feedback. Unclaimed printables must reach the
5+
* prompt buffer, whether the open surface is a plain picker or an ephemeral
6+
* popup.
7+
*/
8+
import { describe, expect, test } from "bun:test";
9+
10+
import { withTestRenderer } from "./harness";
11+
import type { PaletteCommand } from "./command-catalog";
12+
import { openPermissionsOverlay, openOperatorOverlay } from "./overlays";
13+
import { createAppShell } from "./shell/index";
14+
import type { AppShell } from "./shell/internals";
15+
import { isSlashPopupOpen } from "./shell/internals";
16+
import { openHelpOverlay, openPalette } from "./shell/palette";
17+
18+
const CATALOG: readonly PaletteCommand[] = [
19+
{ id: "model", label: "/model" },
20+
{ id: "mcp", label: "/mcp" },
21+
];
22+
23+
interface Ctx {
24+
readonly shell: AppShell;
25+
readonly press: (key: string) => void;
26+
}
27+
28+
function withShell(fn: (ctx: Ctx) => Promise<void>): Promise<void> {
29+
return withTestRenderer(
30+
async (h) => {
31+
const shell = createAppShell(h.renderer, {
32+
terminal: { columns: 80, rows: 24 },
33+
wireKeys: true,
34+
run: "idle",
35+
paletteCatalog: CATALOG,
36+
});
37+
try {
38+
await fn({
39+
shell,
40+
press: (key) => h.pressKey(key as Parameters<typeof h.pressKey>[0]),
41+
});
42+
} finally {
43+
shell.dispose();
44+
}
45+
},
46+
{ width: 80, height: 24 },
47+
);
48+
}
49+
50+
describe("unclaimed overlay keys reach the prompt", () => {
51+
test("printable a non-filter picker ignores lands in the prompt", async () => {
52+
await withShell(async ({ shell, press }) => {
53+
openHelpOverlay(shell);
54+
expect(shell.overlayList).not.toBeNull();
55+
expect(shell.overlayKind).toBe("help");
56+
57+
press("x");
58+
expect(shell.prompt.value).toBe("x");
59+
expect(shell.overlayKind).toBe("help");
60+
});
61+
});
62+
63+
test("palette symmetry: unclaimed printable with a palette open reaches the prompt via the fallthrough", async () => {
64+
await withShell(async ({ shell, press }) => {
65+
openPalette(shell, { catalog: CATALOG });
66+
expect(shell.overlayKind).toBe("palette");
67+
expect(isSlashPopupOpen(shell)).toBe(false);
68+
69+
press("x");
70+
expect(shell.prompt.value).toBe("x");
71+
expect(shell.overlayKind).toBe("palette");
72+
});
73+
});
74+
75+
test("permission gate keeps printable keys: prompt stays empty, gate stays open", async () => {
76+
await withShell(async ({ shell, press }) => {
77+
openPermissionsOverlay(shell, { items: ["Allow once", "Deny"] });
78+
expect(shell.overlayKind).toBe("permissions");
79+
80+
press("x");
81+
expect(shell.prompt.value).toBe("");
82+
expect(shell.overlayKind).toBe("permissions");
83+
});
84+
});
85+
86+
test("operator gate keeps printable keys: prompt stays empty, gate stays open", async () => {
87+
await withShell(async ({ shell, press }) => {
88+
openOperatorOverlay(shell, {
89+
body: "Proceed?",
90+
choices: ["Cancel", "Continue"],
91+
});
92+
expect(shell.overlayKind).toBe("operator");
93+
94+
press("x");
95+
expect(shell.prompt.value).toBe("");
96+
expect(shell.overlayKind).toBe("operator");
97+
});
98+
});
99+
100+
test("claimed overlay keys keep precedence over the prompt", async () => {
101+
await withShell(async ({ shell, press }) => {
102+
openHelpOverlay(shell);
103+
const before = shell.overlayList?.activeIndex ?? 0;
104+
105+
press("j");
106+
expect(shell.overlayList?.activeIndex).toBe(before + 1);
107+
expect(shell.prompt.value).toBe("");
108+
});
109+
});
110+
});

‎src/tui/shell/keys.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,22 @@ export function createShellKeyHandlers(
437437
return;
438438
}
439439
}
440+
// Unclaimed printables fall through to the prompt instead of vanishing:
441+
// the prompt does not hold focus while the overlay is open, so the
442+
// InputRenderable cannot insert them itself. Decision surfaces are the
443+
// modal exception (focus-routing): a permission/operator gate keeps
444+
// every key until it is answered or dismissed. The overlay stays open
445+
// (no dismiss, no idle-notify) so a queued gate cannot drain mid-list.
446+
if (
447+
shell.overlayKind !== "permissions" &&
448+
shell.overlayKind !== "operator" &&
449+
isPrintableInsertKey(key)
450+
) {
451+
key.preventDefault();
452+
shell.prompt.insertText(key.sequence as string);
453+
shell.sentHistory = sentHistoryOnEdit(shell.sentHistory);
454+
return;
455+
}
440456
return;
441457
}
442458

0 commit comments

Comments
 (0)