From 2cfad3766e69db48c2b2da2dfa54a8883501903d Mon Sep 17 00:00:00 2001 From: villjiang <24198247+vill-jiang@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:29:59 +0800 Subject: [PATCH 1/2] feat(terminal): add Termux-style extra keys on portrait touch screens Phones held in portrait get two rows of keys under the terminal: ESC / - HOME UP END PGUP and TAB CTRL ALT LEFT DOWN RIGHT PGDN. The row is part of the panel's grid, so the terminal refits above it instead of being covered, and tapping a key keeps the soft keyboard open. Keys send the same sequences as a physical keyboard, following xterm's application cursor mode. CTRL and ALT are one-shot and also apply to the next character typed on the soft keyboard. The arrows use Noto Sans Mono's up and down glyphs, rotated for left and right, so all four match. --- AGENTS.md | 2 +- app/globals.css | 46 +++++++++++++ components/TerminalExtraKeys.tsx | 111 +++++++++++++++++++++++++++++++ components/TerminalPanel.tsx | 39 ++++++++++- docs/agents/client-platform.md | 5 ++ lib/i18n/messages/en.ts | 1 + lib/i18n/messages/zh-CN.ts | 1 + lib/i18n/messages/zh-TW.ts | 1 + lib/terminal-extra-keys.ts | 65 ++++++++++++++++++ 9 files changed, 267 insertions(+), 4 deletions(-) create mode 100644 components/TerminalExtraKeys.tsx create mode 100644 lib/terminal-extra-keys.ts diff --git a/AGENTS.md b/AGENTS.md index d9c08105a1..2e72864e21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -223,7 +223,7 @@ Design decisions and traps live in `docs/agents/`, one note per area. Read every - [files-and-access.md](docs/agents/files-and-access.md): worktrees and project grouping, the file access allow-list (the `/api/files` security boundary), file tree visibility, uploads and chat file drops, the CSV/TSV table view, web password throttling. Files: `app/api/files/**`, `app/api/cwd/**`, `app/api/worktrees/**`, `app/api/file-index/**`, `app/api/web-auth/**`, `proxy.ts`, `lib/path-security.ts`, `lib/file-access.ts`, `lib/linked-directory.ts`, `lib/session-file-references*.ts`, `lib/file-tree-visibility.ts`, `lib/file-upload-client.ts`, `lib/worktree.ts`, `lib/paths.ts`, `lib/auth-throttle.ts`, `components/ProjectWorktreePicker.tsx`, `components/NewSessionContextBar.tsx`, `components/FileExplorer.tsx`, `lib/delimited-table.ts`, `components/DelimitedTable.tsx`, the table branch of `components/FileViewer.tsx`, `hooks/useDragDrop.ts`. - [settings-ui.md](docs/agents/settings-ui.md): Plugins and Skills routes, sidebar group switches, the shared `SettingsUi` blocks every settings panel and add pane uses. Files: `app/api/plugins/**`, `app/api/skills/**`, `components/SettingsUi.tsx`, `components/settings-ui-helpers.ts`, `components/SkillsConfig.tsx`, `components/PluginsConfig.tsx`; also before adding a settings section or add pane. - [subagents.md](docs/agents/subagents.md): the built-in subagent setting, profiles and their files, run status, completion notifications, the subagent viewer tab. Files: `lib/subagent*.ts`, `app/api/subagents/**`, `components/AgentsConfig.tsx`, `components/SubagentViewer.tsx`, `components/subagent-viewer-state.ts`. -- [client-platform.md](docs/agents/client-platform.md): mobile software keyboard and viewport height, completion sound. Files: `hooks/useViewportHeight.ts`, `hooks/useAudio.ts`, the keyboard-open CSS. +- [client-platform.md](docs/agents/client-platform.md): mobile software keyboard and viewport height, the terminal's extra keys, completion sound. Files: `hooks/useViewportHeight.ts`, `hooks/useAudio.ts`, `components/TerminalExtraKeys.tsx`, `lib/terminal-extra-keys.ts`, the keyboard-open CSS. --- diff --git a/app/globals.css b/app/globals.css index e717dc2928..fe7c392677 100644 --- a/app/globals.css +++ b/app/globals.css @@ -428,6 +428,52 @@ html.dark .web-login-error { height: 100%; } +.terminal-extra-keys { + grid-row: 4; + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + grid-auto-rows: 36px; + padding-bottom: env(safe-area-inset-bottom); + border-top: 1px solid #2f3540; + background: #181b21; +} + +.terminal-xterm:has(+ .terminal-extra-keys) { + padding-bottom: 6px; +} + +html[data-keyboard-open] .terminal-extra-keys { + padding-bottom: 0; +} + +.terminal-extra-keys button { + min-width: 0; + padding: 0; + border: none; + background: transparent; + color: #d7dce5; + font-family: var(--font-mono); + font-size: 12px; + touch-action: manipulation; + user-select: none; + -webkit-user-select: none; + -webkit-touch-callout: none; + -webkit-tap-highlight-color: transparent; +} + +.terminal-extra-keys button:active { + background: #242932; +} + +.terminal-extra-keys .is-rotated { + display: inline-block; + transform: rotate(90deg); +} + +.terminal-extra-keys button.is-active { + color: #60a5fa; +} + .terminal-xterm-host { width: 100%; height: 100%; diff --git a/components/TerminalExtraKeys.tsx b/components/TerminalExtraKeys.tsx new file mode 100644 index 0000000000..1cb25776b5 --- /dev/null +++ b/components/TerminalExtraKeys.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useEffect, useRef, useSyncExternalStore, type PointerEvent } from "react"; +import { useI18n } from "@/hooks/useI18n"; +import { + TERMINAL_EXTRA_KEY_ROWS, + TERMINAL_REPEATING_KEYS, + type TerminalExtraKey, + type TerminalModifier, + type TerminalModifiers, +} from "@/lib/terminal-extra-keys"; + +const KEY_LABELS: Record = { + Escape: "ESC", "/": "/", "-": "-", Home: "HOME", ArrowUp: "↑", End: "END", PageUp: "PGUP", + Tab: "TAB", ctrl: "CTRL", alt: "ALT", ArrowLeft: "↓", ArrowDown: "↓", ArrowRight: "↑", PageDown: "PGDN", +}; + +const KEY_NAMES: Partial> = { + ArrowUp: "Up", ArrowDown: "Down", ArrowLeft: "Left", ArrowRight: "Right", PageUp: "Page Up", PageDown: "Page Down", +}; + +const REPEAT_DELAY_MS = 400; +const REPEAT_INTERVAL_MS = 50; + +function isPortrait(): boolean { + const type = window.screen.orientation?.type; + if (type) return type.startsWith("portrait"); + const angle = (window as { orientation?: number }).orientation; + if (typeof angle === "number") return angle % 180 === 0; + return window.innerHeight >= window.innerWidth; +} + +function subscribeTouchPortrait(callback: () => void): () => void { + const coarse = window.matchMedia("(pointer: coarse)"); + coarse.addEventListener("change", callback); + window.screen.orientation?.addEventListener("change", callback); + window.addEventListener("orientationchange", callback); + window.addEventListener("resize", callback); + return () => { + coarse.removeEventListener("change", callback); + window.screen.orientation?.removeEventListener("change", callback); + window.removeEventListener("orientationchange", callback); + window.removeEventListener("resize", callback); + }; +} + +const getTouchPortrait = () => window.matchMedia("(pointer: coarse)").matches && isPortrait(); + +export function useTerminalExtraKeysVisible(): boolean { + return useSyncExternalStore(subscribeTouchPortrait, getTouchPortrait, () => false); +} + +interface Props { + modifiers: TerminalModifiers; + onKey: (key: TerminalExtraKey) => void; + onToggleModifier: (modifier: TerminalModifier) => void; +} + +export function TerminalExtraKeys({ modifiers, onKey, onToggleModifier }: Props) { + const { t } = useI18n(); + const repeatTimer = useRef(null); + + const stopRepeat = () => { + if (repeatTimer.current !== null) window.clearTimeout(repeatTimer.current); + repeatTimer.current = null; + }; + useEffect(() => stopRepeat, []); + + const press = (event: PointerEvent, key: TerminalExtraKey | TerminalModifier) => { + event.preventDefault(); + if (event.button !== 0) return; + stopRepeat(); + if (key === "ctrl" || key === "alt") { + onToggleModifier(key); + return; + } + onKey(key); + if (!TERMINAL_REPEATING_KEYS.has(key)) return; + const repeat = () => { + onKey(key); + repeatTimer.current = window.setTimeout(repeat, REPEAT_INTERVAL_MS); + }; + repeatTimer.current = window.setTimeout(repeat, REPEAT_DELAY_MS); + }; + + return ( +
+ {TERMINAL_EXTRA_KEY_ROWS.flat().map((key) => { + const modifier = key === "ctrl" || key === "alt"; + return ( + + ); + })} +
+ ); +} diff --git a/components/TerminalPanel.tsx b/components/TerminalPanel.tsx index f3a2abd078..53dd821125 100644 --- a/components/TerminalPanel.tsx +++ b/components/TerminalPanel.tsx @@ -1,13 +1,22 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { FitAddon } from "@xterm/addon-fit"; import { Terminal } from "@xterm/xterm"; import { useI18n } from "@/hooks/useI18n"; import { subscribeFontPreferences } from "@/hooks/useFontPreferences"; import { readFontWeight } from "@/lib/font-preferences"; import { createTerminalWriter, terminalRequest } from "@/lib/terminal-client"; +import { + applyTerminalModifiers, + NO_TERMINAL_MODIFIERS, + terminalExtraKeySequence, + type TerminalExtraKey, + type TerminalModifier, + type TerminalModifiers, +} from "@/lib/terminal-extra-keys"; import type { TerminalEvent } from "@/lib/terminal-manager"; +import { TerminalExtraKeys, useTerminalExtraKeysVisible } from "./TerminalExtraKeys"; import type { TerminalTab } from "./terminal-tab-state"; interface Props { @@ -31,6 +40,29 @@ export function TerminalPanel({ tab, active, onRestart, onClosed, onCloseError } const [error, setError] = useState(null); const [exitCode, setExitCode] = useState(null); const [reconnectKey, setReconnectKey] = useState(0); + const [modifiers, setModifiers] = useState(NO_TERMINAL_MODIFIERS); + const modifiersRef = useRef(modifiers); + const showExtraKeys = useTerminalExtraKeysVisible(); + + const consumeModifiers = useCallback(() => { + const current = modifiersRef.current; + if (current.ctrl || current.alt) { + modifiersRef.current = NO_TERMINAL_MODIFIERS; + setModifiers(NO_TERMINAL_MODIFIERS); + } + return current; + }, []); + const sendExtraKey = (key: TerminalExtraKey) => { + const terminal = terminalRef.current; + if (!terminal) return; + const sequence = terminalExtraKeySequence(key, consumeModifiers(), terminal.modes.applicationCursorKeysMode); + terminal.input(sequence, true); + }; + const toggleModifier = (modifier: TerminalModifier) => { + const next = { ...modifiersRef.current, [modifier]: !modifiersRef.current[modifier] }; + modifiersRef.current = next; + setModifiers(next); + }; useEffect(() => { const container = containerRef.current; @@ -92,7 +124,7 @@ export function TerminalPanel({ tab, active, onRestart, onClosed, onCloseError } }); writerRef.current = writer; const onData = terminal.onData((data) => { - if (connected && !exited && !inputFailed) writer.write(data); + if (connected && !exited && !inputFailed) writer.write(applyTerminalModifiers(data, consumeModifiers())); }); const fitAndResize = () => { if (!container.offsetWidth || !container.offsetHeight) return; @@ -194,7 +226,7 @@ export function TerminalPanel({ tab, active, onRestart, onClosed, onCloseError } terminal.dispose(); terminalRef.current = null; }; - }, [id, cwd, restored, reconnectKey]); + }, [id, cwd, restored, reconnectKey, consumeModifiers]); useEffect(() => { if (active) terminalRef.current?.focus(); @@ -243,6 +275,7 @@ export function TerminalPanel({ tab, active, onRestart, onClosed, onCloseError } {status === "exited" &&
{exitCode === null ? t("terminal.exited") : t("terminal.exitCode", { code: exitCode })}
}
+ {showExtraKeys && } ); } diff --git a/docs/agents/client-platform.md b/docs/agents/client-platform.md index 47924841ca..1355e17fda 100644 --- a/docs/agents/client-platform.md +++ b/docs/agents/client-platform.md @@ -6,6 +6,11 @@ - The same check sets ``. Under `(max-width: 640px), (pointer: coarse) and (max-height: 500px)` (phone landscape included; tablets keep their controls) CSS hides `.chat-input-controls`, `.extension-status-shelf` and a fresh composer's project bar (`.new-session-context`, its own rule: tapping it would close the keyboard anyway) and drops the bottom safe-area padding the keyboard covers. `MobilePwaLayout.test.mjs` asserts each targeted class exists on its component, so a rename cannot leave a rule silently dead. - Anything fixed to the bottom of a phone screen sits inside `--app-viewport-height`, never at `bottom: 0`: iOS keeps the layout viewport, and with it `bottom: 0`, under the keyboard. The drawer and the right panel use `top: 0` with that height; the sidebar's bottom sheet (`.sidebar-sheet-layer` in `app/sidebar-menu.css`) is a layer of that height with the sheet at its bottom, and drops its safe-area padding under `html[data-keyboard-open]`. +## Terminal extra keys (`components/TerminalExtraKeys.tsx`, `lib/terminal-extra-keys.ts`) +- A touch device held in portrait gets Termux's two key rows under the terminal (`ESC / - HOME ↑ END PGUP`, `TAB CTRL ALT ← ↓ → PGDN`). Portrait comes from `screen.orientation` (then `window.orientation`), never from the viewport: the soft keyboard makes the viewport wider than tall, and the keys must stay while it is open. Left and right are Noto Sans Mono's `↓` and `↑` rotated 90deg: its latin subset has `↑`/`↓` but no `←`/`→`, which would fall back to a heavier system font. +- The keys are the panel's last grid row, so xterm's `ResizeObserver` refits above them instead of being covered. Buttons call `preventDefault()` on pointer and mouse down so the hidden xterm textarea keeps focus and the keyboard stays up. +- Keys go through `terminal.input(sequence, true)`, the same `onData` path (and the same connected / disableStdin checks) as typing. Arrows and Home/End follow xterm's application cursor mode; CTRL and ALT are one-shot and also modify the next character typed on the soft keyboard (`applyTerminalModifiers` in `onData`). + ## Sidebar breakpoint (`components/AppShell.tsx`) - The mobile drawer starts closed on entering the mobile breakpoint. Desktop open state is remembered separately, seeded from the URL's initial `sidebarCollapsed` value and updated only by explicit desktop toggles. Returning to desktop restores that preference; mobile drawer toggles, backdrop clicks and toolbar actions must not replace it. diff --git a/lib/i18n/messages/en.ts b/lib/i18n/messages/en.ts index ceaab54dcc..2a693ef711 100644 --- a/lib/i18n/messages/en.ts +++ b/lib/i18n/messages/en.ts @@ -529,6 +529,7 @@ export const enLocale: LocalePlugin = { "terminal.close": "Terminate terminal", "terminal.restart": "Restart terminal", "terminal.reconnect": "Reconnect terminal", + "terminal.extraKeys": "Terminal keys", "terminal.connecting": "Connecting", "terminal.ready": "Connected", "terminal.exited": "Terminal closed", diff --git a/lib/i18n/messages/zh-CN.ts b/lib/i18n/messages/zh-CN.ts index 2e1f9baac0..235af6b3ba 100644 --- a/lib/i18n/messages/zh-CN.ts +++ b/lib/i18n/messages/zh-CN.ts @@ -529,6 +529,7 @@ export const zhCNLocale: LocalePlugin = { "terminal.close": "终止终端", "terminal.restart": "重启终端", "terminal.reconnect": "重新连接终端", + "terminal.extraKeys": "终端按键", "terminal.connecting": "连接中", "terminal.ready": "已连接", "terminal.exited": "终端已关闭", diff --git a/lib/i18n/messages/zh-TW.ts b/lib/i18n/messages/zh-TW.ts index 12d99476d3..fa2968e3b1 100644 --- a/lib/i18n/messages/zh-TW.ts +++ b/lib/i18n/messages/zh-TW.ts @@ -529,6 +529,7 @@ export const zhTWLocale: LocalePlugin = { "terminal.close": "終止終端機", "terminal.restart": "重新啟動終端機", "terminal.reconnect": "重新連線終端機", + "terminal.extraKeys": "終端機按鍵", "terminal.connecting": "連線中", "terminal.ready": "已連線", "terminal.exited": "終端機已關閉", diff --git a/lib/terminal-extra-keys.ts b/lib/terminal-extra-keys.ts new file mode 100644 index 0000000000..b25ebcafe4 --- /dev/null +++ b/lib/terminal-extra-keys.ts @@ -0,0 +1,65 @@ +export type TerminalExtraKey = + | "Escape" | "/" | "-" | "Home" | "ArrowUp" | "End" | "PageUp" + | "Tab" | "ArrowLeft" | "ArrowDown" | "ArrowRight" | "PageDown"; + +export type TerminalModifier = "ctrl" | "alt"; + +export interface TerminalModifiers { + ctrl: boolean; + alt: boolean; +} + +export const NO_TERMINAL_MODIFIERS: TerminalModifiers = { ctrl: false, alt: false }; + +export const TERMINAL_EXTRA_KEY_ROWS: readonly (readonly (TerminalExtraKey | TerminalModifier)[])[] = [ + ["Escape", "/", "-", "Home", "ArrowUp", "End", "PageUp"], + ["Tab", "ctrl", "alt", "ArrowLeft", "ArrowDown", "ArrowRight", "PageDown"], +]; + +export const TERMINAL_REPEATING_KEYS: ReadonlySet = new Set([ + "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "PageUp", "PageDown", +]); + +const CURSOR_FINALS: Partial> = { + ArrowUp: "A", ArrowDown: "B", ArrowRight: "C", ArrowLeft: "D", Home: "H", End: "F", +}; + +const TILDE_CODES: Partial> = { PageUp: 5, PageDown: 6 }; + +function modifierParam({ ctrl, alt }: TerminalModifiers): number { + return 1 + (alt ? 2 : 0) + (ctrl ? 4 : 0); +} + +function ctrlChar(char: string): string { + if (/^[a-z]$/i.test(char)) return String.fromCharCode(char.toUpperCase().charCodeAt(0) & 0x1f); + const code = char.charCodeAt(0); + if (code >= 64 && code <= 95) return String.fromCharCode(code & 0x1f); + if (char === " ") return "\x00"; + if (char === "/" || char === "-") return "\x1f"; + if (char === "?") return "\x7f"; + return char; +} + +export function applyTerminalModifiers(data: string, modifiers: TerminalModifiers): string { + if ((!modifiers.ctrl && !modifiers.alt) || [...data].length !== 1) return data; + const char = modifiers.ctrl ? ctrlChar(data) : data; + return modifiers.alt ? `\x1b${char}` : char; +} + +export function terminalExtraKeySequence( + key: TerminalExtraKey, + modifiers: TerminalModifiers, + applicationCursorKeys: boolean, +): string { + const modified = modifiers.ctrl || modifiers.alt; + const final = CURSOR_FINALS[key]; + if (final) { + if (modified) return `\x1b[1;${modifierParam(modifiers)}${final}`; + return applicationCursorKeys ? `\x1bO${final}` : `\x1b[${final}`; + } + const tilde = TILDE_CODES[key]; + if (tilde) return modified ? `\x1b[${tilde};${modifierParam(modifiers)}~` : `\x1b[${tilde}~`; + if (key === "Escape") return modifiers.alt ? "\x1b\x1b" : "\x1b"; + if (key === "Tab") return modifiers.alt ? "\x1b\t" : "\t"; + return applyTerminalModifiers(key, modifiers); +} From 76a1cd5ab1d775fed13cba09d00534008626d21d Mon Sep 17 00:00:00 2001 From: Alex Yang Date: Sun, 11 Oct 2026 16:42:43 +0900 Subject: [PATCH 2/2] test(terminal): pin the extra keys' sequences against xterm's Co-Authored-By: Claude Opus 5.5 --- lib/terminal-extra-keys.test.mjs | 52 ++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 lib/terminal-extra-keys.test.mjs diff --git a/lib/terminal-extra-keys.test.mjs b/lib/terminal-extra-keys.test.mjs new file mode 100644 index 0000000000..dafc85c86f --- /dev/null +++ b/lib/terminal-extra-keys.test.mjs @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createJiti } from "jiti"; + +const jiti = createJiti(import.meta.url); +const { + applyTerminalModifiers, + NO_TERMINAL_MODIFIERS, + terminalExtraKeySequence, +} = await jiti.import("./terminal-extra-keys.ts"); + +const ctrl = { ctrl: true, alt: false }; +const alt = { ctrl: false, alt: true }; +const both = { ctrl: true, alt: true }; + +test("cursor keys follow xterm's normal and application cursor modes", () => { + assert.equal(terminalExtraKeySequence("ArrowUp", NO_TERMINAL_MODIFIERS, false), "\x1b[A"); + assert.equal(terminalExtraKeySequence("ArrowLeft", NO_TERMINAL_MODIFIERS, false), "\x1b[D"); + assert.equal(terminalExtraKeySequence("Home", NO_TERMINAL_MODIFIERS, false), "\x1b[H"); + assert.equal(terminalExtraKeySequence("ArrowDown", NO_TERMINAL_MODIFIERS, true), "\x1bOB"); + assert.equal(terminalExtraKeySequence("End", NO_TERMINAL_MODIFIERS, true), "\x1bOF"); +}); + +test("modified cursor and page keys carry xterm's modifier parameter", () => { + assert.equal(terminalExtraKeySequence("ArrowRight", ctrl, true), "\x1b[1;5C"); + assert.equal(terminalExtraKeySequence("ArrowUp", alt, false), "\x1b[1;3A"); + assert.equal(terminalExtraKeySequence("Home", both, false), "\x1b[1;7H"); + assert.equal(terminalExtraKeySequence("PageUp", NO_TERMINAL_MODIFIERS, false), "\x1b[5~"); + assert.equal(terminalExtraKeySequence("PageDown", ctrl, false), "\x1b[6;5~"); +}); + +test("Escape, Tab and the character keys take ALT as an ESC prefix", () => { + assert.equal(terminalExtraKeySequence("Escape", NO_TERMINAL_MODIFIERS, false), "\x1b"); + assert.equal(terminalExtraKeySequence("Escape", alt, false), "\x1b\x1b"); + assert.equal(terminalExtraKeySequence("Tab", NO_TERMINAL_MODIFIERS, false), "\t"); + assert.equal(terminalExtraKeySequence("Tab", alt, false), "\x1b\t"); + assert.equal(terminalExtraKeySequence("/", NO_TERMINAL_MODIFIERS, false), "/"); + assert.equal(terminalExtraKeySequence("-", alt, false), "\x1b-"); + assert.equal(terminalExtraKeySequence("/", ctrl, false), "\x1f"); +}); + +test("a one-shot modifier changes one typed character only", () => { + assert.equal(applyTerminalModifiers("c", ctrl), "\x03"); + assert.equal(applyTerminalModifiers("C", ctrl), "\x03"); + assert.equal(applyTerminalModifiers("[", ctrl), "\x1b"); + assert.equal(applyTerminalModifiers(" ", ctrl), "\x00"); + assert.equal(applyTerminalModifiers("x", alt), "\x1bx"); + assert.equal(applyTerminalModifiers("x", both), "\x1b\x18"); + assert.equal(applyTerminalModifiers("ab", ctrl), "ab"); + assert.equal(applyTerminalModifiers("é", alt), "\x1bé"); + assert.equal(applyTerminalModifiers("c", NO_TERMINAL_MODIFIERS), "c"); +});