diff --git a/packages/coding-agent/src/modes/components/plugin-settings.ts b/packages/coding-agent/src/modes/components/plugin-settings.ts index 110ed135f..89f60bba9 100644 --- a/packages/coding-agent/src/modes/components/plugin-settings.ts +++ b/packages/coding-agent/src/modes/components/plugin-settings.ts @@ -11,6 +11,7 @@ import { Container, Input, + matchesKey, type SelectItem, SelectList, type SettingItem, @@ -36,13 +37,18 @@ import { DynamicBorder } from "./dynamic-border"; /** * Forwards a keystroke to `input`, but cancels via `onCancel` when the user presses Escape. + * + * Escape is decoded via `matchesKey` rather than a raw `\x1b` compare: inside the + * fullscreen settings overlay the kitty keyboard protocol is active (ghostty/kitty), + * where the Escape key arrives as the CSI-u sequence `\x1b[27u`, not a bare `\x1b`. + * The literal fallbacks preserve legacy single/double-escape on terminals without it. */ export function handleInputOrEscape( data: string, input: { handleInput(data: string): void }, onCancel: () => void, ): void { - if (data === "\x1b" || data === "\x1b\x1b") { + if (data === "\x1b" || data === "\x1b\x1b" || matchesKey(data, "escape")) { onCancel(); return; } diff --git a/packages/coding-agent/test/modes/components/handle-input-or-escape.test.ts b/packages/coding-agent/test/modes/components/handle-input-or-escape.test.ts new file mode 100644 index 000000000..35e6f2a8b --- /dev/null +++ b/packages/coding-agent/test/modes/components/handle-input-or-escape.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { handleInputOrEscape } from "@oh-my-pi/pi-coding-agent/modes/components/plugin-settings"; +import { setKittyProtocolActive } from "@oh-my-pi/pi-tui"; + +afterEach(() => { + setKittyProtocolActive(false); +}); + +describe("handleInputOrEscape", () => { + it("cancels on a kitty CSI-u escape (the fullscreen settings overlay encoding)", () => { + // Ghostty/kitty report Escape as `\x1b[27u` once the keyboard protocol is + // active (which it is inside the fullscreen settings overlay). A raw `\x1b` + // compare misses it, so Esc looked dead in the text-input submenu. + setKittyProtocolActive(true); + let cancelled = false; + const forwarded: string[] = []; + handleInputOrEscape("\x1b[27u", { handleInput: data => forwarded.push(data) }, () => { + cancelled = true; + }); + expect(cancelled).toBe(true); + expect(forwarded).toEqual([]); + }); + + it("cancels on a legacy bare escape", () => { + let cancelled = false; + const forwarded: string[] = []; + handleInputOrEscape("\x1b", { handleInput: data => forwarded.push(data) }, () => { + cancelled = true; + }); + expect(cancelled).toBe(true); + expect(forwarded).toEqual([]); + }); + + it("forwards a printable keystroke to the input instead of cancelling", () => { + setKittyProtocolActive(true); + let cancelled = false; + const forwarded: string[] = []; + handleInputOrEscape("g", { handleInput: data => forwarded.push(data) }, () => { + cancelled = true; + }); + expect(cancelled).toBe(false); + expect(forwarded).toEqual(["g"]); + }); +});