fix(gui-extensions): stop Command chords from typing letters in the terminal (#57650)

This commit is contained in:
Kit Langton
2026-10-20 23:33:48 -07:00
committed by GitHub
Unverified
parent 28dce534b7
commit 899ccee996
4 changed files with 83 additions and 4 deletions
@@ -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 }) => {
@@ -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)
})
})
@@ -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
}
@@ -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()