diff --git a/packages/app/e2e/terminal/terminal-input.spec.ts b/packages/app/e2e/terminal/terminal-input.spec.ts new file mode 100644 index 000000000..b133365fb --- /dev/null +++ b/packages/app/e2e/terminal/terminal-input.spec.ts @@ -0,0 +1,21 @@ +import { runTerminal, waitTerminalFocusIdle } from "../actions" +import { test } from "../fixtures" +import { terminalSelector } from "../selectors" +import { terminalToggleKey } from "../utils" + +// Regression coverage for #696: a returns-value inversion in the Ghostty +// custom key handler swallowed every keystroke before it could reach the +// PTY. The compile-time defense is the typed wrapper in +// `@/utils/terminal-key-handler`; this spec keeps the full keyboard → PTY → +// render path under test so a future break in any layer surfaces here. +test("terminal forwards typed characters to the underlying PTY", async ({ page, gotoSession }) => { + await gotoSession() + + const terminal = page.locator(terminalSelector).first() + const visible = await terminal.isVisible().catch(() => false) + if (!visible) await page.keyboard.press(terminalToggleKey) + await waitTerminalFocusIdle(page, { term: terminal }) + + const token = `E2E_INPUT_${Date.now()}` + await runTerminal(page, { cmd: `echo ${token}`, token }) +}) diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 0cbc9cd17..8654a503f 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -16,6 +16,7 @@ import { isTerminalGoneError } from "@/context/terminal" import type { RuntimePTY, TerminalSnapshot, TerminalTab } from "@/context/terminal-types" import { terminalAttr, terminalProbe } from "@/testing/terminal" import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" +import { attachKeyHandler } from "@/utils/terminal-key-handler" import { terminalWebSocketURL } from "@/utils/terminal-websocket-url" import { terminalWriter } from "@/utils/terminal-writer" @@ -392,12 +393,12 @@ export const Terminal = (props: TerminalProps) => { }), ) - t.attachCustomKeyEventHandler((event) => { + attachKeyHandler(t, (event) => { const key = event.key.toLowerCase() if (event.ctrlKey && event.shiftKey && !event.metaKey && key === "c") { document.execCommand("copy") - return true + return "block" } // Dispatch terminal toggles ourselves because Ghostty focus can swallow @@ -405,12 +406,14 @@ export const Terminal = (props: TerminalProps) => { const config = settings.keybinds.get(TOGGLE_TERMINAL_ID) ?? DEFAULT_TOGGLE_TERMINAL_KEYBIND const keybinds = parseKeybind(config) - if (!matchKeybind(keybinds, event)) return true + if (matchKeybind(keybinds, event)) { + event.preventDefault() + event.stopPropagation() + queueMicrotask(() => command.trigger(TOGGLE_TERMINAL_ID, "keybind")) + return "block" + } - event.preventDefault() - event.stopPropagation() - queueMicrotask(() => command.trigger(TOGGLE_TERMINAL_ID, "keybind")) - return false + return "passthrough" }) const fit = new mod.FitAddon() diff --git a/packages/app/src/utils/terminal-key-handler.ts b/packages/app/src/utils/terminal-key-handler.ts new file mode 100644 index 000000000..1f18357f4 --- /dev/null +++ b/packages/app/src/utils/terminal-key-handler.ts @@ -0,0 +1,11 @@ +import type { Terminal as Term } from "ghostty-web" + +export type KeyHandlerResult = "block" | "passthrough" + +// Ghostty's customKeyEventHandler returns `true` to BLOCK default handling — +// opposite of xterm.js, which uses `true` to mean "continue normal processing". +// This wrapper exposes an enum so callers cannot accidentally invert the +// boolean and silently swallow every keystroke (see issue #696). +export const attachKeyHandler = (term: Term, handler: (event: KeyboardEvent) => KeyHandlerResult) => { + term.attachCustomKeyEventHandler((event) => handler(event) === "block") +}