From 066d7e77b32440a18951f351b13ee14c4f981d06 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Tue, 28 Jul 2026 01:48:35 +0800 Subject: [PATCH] fix: forward Android hardware keyboard keys (#1114) --- src/ui/features/terminal/Terminal.tsx | 19 +++++++ .../terminal/android-hardware-keyboard.ts | 30 +++++++++++ .../android-hardware-keyboard.test.ts | 50 +++++++++++++++++++ 3 files changed, 99 insertions(+) create mode 100644 src/ui/features/terminal/android-hardware-keyboard.ts create mode 100644 src/ui/tests/features/terminal/android-hardware-keyboard.test.ts diff --git a/src/ui/features/terminal/Terminal.tsx b/src/ui/features/terminal/Terminal.tsx index e2f12bea..a3ee2175 100644 --- a/src/ui/features/terminal/Terminal.tsx +++ b/src/ui/features/terminal/Terminal.tsx @@ -47,6 +47,7 @@ import { globalShortcutHandler } from "@/lib/global-shortcut-handler"; import { useCommandTracker } from "@/features/terminal/command-history/useCommandTracker.ts"; import { highlightTerminalOutput } from "@/lib/terminal-syntax-highlighter.ts"; import { useCommandHistory } from "@/features/terminal/command-history/CommandHistoryContext.tsx"; +import { getAndroidHardwareKeySequence } from "@/features/terminal/android-hardware-keyboard.ts"; import { CommandAutocomplete } from "./command-history/CommandAutocomplete.tsx"; import { SimpleLoader } from "@/lib/SimpleLoader.tsx"; import { useConfirmation } from "@/hooks/use-confirmation.ts"; @@ -2479,6 +2480,24 @@ const TerminalInner = forwardRef( } } + if (navigator.userAgent.includes("Android")) { + const sequence = getAndroidHardwareKeySequence( + e, + terminal.modes.applicationCursorKeysMode, + hostConfig.terminalConfig?.backspaceMode, + ); + if (sequence) { + e.preventDefault(); + e.stopPropagation(); + if (webSocketRef.current?.readyState === WebSocket.OPEN) { + webSocketRef.current.send( + JSON.stringify({ type: "input", data: sequence }), + ); + } + return false; + } + } + // Forward global app shortcuts to AppShell directly — xterm swallows // all keydown events and synthetic re-dispatch is unreliable. // stopPropagation prevents the same event from also firing the window listener. diff --git a/src/ui/features/terminal/android-hardware-keyboard.ts b/src/ui/features/terminal/android-hardware-keyboard.ts new file mode 100644 index 00000000..f99e62cf --- /dev/null +++ b/src/ui/features/terminal/android-hardware-keyboard.ts @@ -0,0 +1,30 @@ +import type { HostBackspaceMode } from "@/sidebar/HostEditorData"; + +const CURSOR_SEQUENCES: Record = + { + ArrowUp: ["\x1b[A", "\x1bOA"], + ArrowDown: ["\x1b[B", "\x1bOB"], + ArrowRight: ["\x1b[C", "\x1bOC"], + ArrowLeft: ["\x1b[D", "\x1bOD"], + }; + +export function getAndroidHardwareKeySequence( + event: Pick< + KeyboardEvent, + "key" | "ctrlKey" | "altKey" | "metaKey" | "shiftKey" + >, + applicationCursorKeys: boolean, + backspaceMode: HostBackspaceMode | undefined, +): string | null { + if (event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) { + return null; + } + + const cursor = CURSOR_SEQUENCES[event.key]; + if (cursor) return cursor[applicationCursorKeys ? 1 : 0]; + if (event.key === "Delete") return "\x1b[3~"; + if (event.key === "Backspace" && backspaceMode !== "control-h") { + return "\x7f"; + } + return null; +} diff --git a/src/ui/tests/features/terminal/android-hardware-keyboard.test.ts b/src/ui/tests/features/terminal/android-hardware-keyboard.test.ts new file mode 100644 index 00000000..426c178a --- /dev/null +++ b/src/ui/tests/features/terminal/android-hardware-keyboard.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { getAndroidHardwareKeySequence } from "@/features/terminal/android-hardware-keyboard"; + +const key = ( + value: string, + modifiers: Partial = {}, +): Pick< + KeyboardEvent, + "key" | "ctrlKey" | "altKey" | "metaKey" | "shiftKey" +> => ({ + key: value, + ctrlKey: false, + altKey: false, + metaKey: false, + shiftKey: false, + ...modifiers, +}); + +describe("getAndroidHardwareKeySequence", () => { + it("maps cursor keys in normal and application modes", () => { + expect( + getAndroidHardwareKeySequence(key("ArrowUp"), false, undefined), + ).toBe("\x1b[A"); + expect(getAndroidHardwareKeySequence(key("ArrowUp"), true, undefined)).toBe( + "\x1bOA", + ); + }); + + it("maps Delete and the default Backspace mode", () => { + expect(getAndroidHardwareKeySequence(key("Delete"), false, undefined)).toBe( + "\x1b[3~", + ); + expect( + getAndroidHardwareKeySequence(key("Backspace"), false, undefined), + ).toBe("\x7f"); + }); + + it("leaves control-h Backspace and modified keys to existing handlers", () => { + expect( + getAndroidHardwareKeySequence(key("Backspace"), false, "control-h"), + ).toBeNull(); + expect( + getAndroidHardwareKeySequence( + key("ArrowLeft", { ctrlKey: true }), + false, + undefined, + ), + ).toBeNull(); + }); +});