diff --git a/packages/app/e2e/regression/terminal-input.spec.ts b/packages/app/e2e/regression/terminal-input.spec.ts index df24d2e3064..1497bcfd852 100644 --- a/packages/app/e2e/regression/terminal-input.spec.ts +++ b/packages/app/e2e/regression/terminal-input.spec.ts @@ -8,15 +8,24 @@ const workspace = { name: "TerminalInput", pty: {} } satisfies WorkspaceInput test.use({ viewport: { width: 1440, height: 900 } }) -test("clears the terminal line with Command+Delete", async ({ page }) => { +test("clears the terminal line with Command+Delete and suppresses printable Command chords", async ({ page }) => { const { pty } = await openSession(page, workspace) const terminal = page.locator('[data-component="terminal"]') await page.keyboard.press("Control+Backquote") await expect(terminal.locator("textarea")).toHaveCount(1) await expect.poll(() => pty.sockets.length).toBe(1) + await page.keyboard.press("Meta+k") + await page.keyboard.press("Meta+j") + await terminal.locator("textarea").evaluate((textarea) => { + const clipboardData = new DataTransfer() + clipboardData.setData("text/plain", "echo pasted") + textarea.dispatchEvent(new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true })) + }) + await expect.poll(() => pty.sockets[0]!.input.join("")).toBe("echo pasted") + await page.keyboard.press("Meta+Backspace") - await expect.poll(() => pty.sockets[0]!.input.join("")).toBe("\x15") + await expect.poll(() => pty.sockets[0]!.input.join("")).toBe("echo pasted\x15") }) test("reveals the terminal after its first output and hides the native caret", async ({ page }) => { diff --git a/packages/gui-extensions/src/terminal/terminal-key-event.test.ts b/packages/gui-extensions/src/terminal/terminal-key-event.test.ts new file mode 100644 index 00000000000..abbdfacb231 --- /dev/null +++ b/packages/gui-extensions/src/terminal/terminal-key-event.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test" +import { consumeTerminalMetaKey, terminalKeyInput } from "./terminal-key-event" + +function keyEvent(init: KeyboardEventInit) { + return new KeyboardEvent("keydown", { cancelable: true, ...init }) +} + +describe("terminalKeyEvent", () => { + test("maps unmodified Command+Backspace to line kill", () => { + expect(terminalKeyInput(keyEvent({ key: "Backspace", code: "Backspace", metaKey: true }))).toBe("\x15") + expect(terminalKeyInput(keyEvent({ key: "Backspace", code: "Backspace", metaKey: true, shiftKey: true }))).toBeUndefined() + }) + + test("consumes printable Command chords but leaves physical paste and copy to ghostty-web", () => { + expect(consumeTerminalMetaKey(keyEvent({ key: "k", code: "KeyK", metaKey: true }))).toBe(true) + expect(consumeTerminalMetaKey(keyEvent({ key: "a", code: "KeyA", metaKey: true }))).toBe(true) + expect(consumeTerminalMetaKey(keyEvent({ key: "v", code: "KeyV", metaKey: true }))).toBe(false) + expect(consumeTerminalMetaKey(keyEvent({ key: "c", code: "KeyC", metaKey: true }))).toBe(false) + expect(consumeTerminalMetaKey(keyEvent({ key: "ArrowLeft", code: "ArrowLeft", metaKey: true }))).toBe(false) + }) + + test("keeps paste and copy working on RTL and non-QWERTY layouts", () => { + expect(consumeTerminalMetaKey(keyEvent({ key: "ر", code: "KeyV", metaKey: true }))).toBe(false) + expect(consumeTerminalMetaKey(keyEvent({ key: "ؤ", code: "KeyC", metaKey: true }))).toBe(false) + + let pasted = 0 + expect(consumeTerminalMetaKey(keyEvent({ key: "v", code: "Period", metaKey: true }), { paste: () => void (pasted += 1) })).toBe(true) + expect(pasted).toBe(1) + + expect(consumeTerminalMetaKey(keyEvent({ key: "c", code: "KeyI", metaKey: true }), { copy: () => true })).toBe(true) + }) + + test("never consumes Ctrl chords so they reach the shell", () => { + expect(consumeTerminalMetaKey(keyEvent({ key: "c", code: "KeyC", ctrlKey: true }))).toBe(false) + expect(consumeTerminalMetaKey(keyEvent({ key: "a", code: "KeyA", ctrlKey: true }))).toBe(false) + expect(consumeTerminalMetaKey(keyEvent({ key: "k", code: "KeyK", ctrlKey: true, metaKey: true }))).toBe(false) + }) +}) diff --git a/packages/gui-extensions/src/terminal/terminal-key-event.ts b/packages/gui-extensions/src/terminal/terminal-key-event.ts index 7ec6d1e6dfe..0eeaec60285 100644 --- a/packages/gui-extensions/src/terminal/terminal-key-event.ts +++ b/packages/gui-extensions/src/terminal/terminal-key-event.ts @@ -5,3 +5,30 @@ export function terminalKeyInput(event: KeyboardEvent) { return "\x15" } + +// ghostty-web sends the raw character to the PTY for Command chords, so consume them here. +// Returning true makes ghostty-web preventDefault, so physical KeyV/KeyC are left alone +// (whatever character the layout gives them, e.g. RTL) so native paste and copy still run. +export function consumeTerminalMetaKey( + event: KeyboardEvent, + options?: { + copy?: () => boolean + paste?: () => void + }, +) { + if (!event.metaKey || event.ctrlKey || event.altKey) return false + + if (event.code === "KeyV" || (event.code === "KeyC" && !event.shiftKey)) return false + + const key = event.key.toLowerCase() + + if (key === "v") { + options?.paste?.() + + return true + } + + if (key === "c" && !event.shiftKey && options?.copy?.()) return true + + return event.key.length === 1 +} diff --git a/packages/gui-extensions/src/terminal/terminal.tsx b/packages/gui-extensions/src/terminal/terminal.tsx index 3fddfb0defd..95b4251a090 100644 --- a/packages/gui-extensions/src/terminal/terminal.tsx +++ b/packages/gui-extensions/src/terminal/terminal.tsx @@ -13,7 +13,7 @@ import type { TerminalModel } from "./model" import type { LocalPTY } from "./state" import { SerializeAddon } from "./serialize" import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "./runtime-adapters" -import { terminalKeyInput } from "./terminal-key-event" +import { consumeTerminalMetaKey, terminalKeyInput } from "./terminal-key-event" import { terminalWriter } from "./writer" // Serialization on unmount is a synchronous O(rows x cols) walk on the main thread and the @@ -481,7 +481,12 @@ export const Terminal = (props: TerminalProps) => { } // allow for toggle terminal keybinds in parent - return keybinds.matches("terminal.toggle", event) + if (keybinds.matches("terminal.toggle", event)) return true + + return consumeTerminalMetaKey(event, { + paste: () => void document.execCommand("paste"), + copy: () => t.hasSelection() && (document.execCommand("copy") || t.copySelection()), + }) }) const fit = new mod.FitAddon()