diff --git a/AGENTS.md b/AGENTS.md index d9c08105a..2e72864e2 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 e717dc292..fe7c39267 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 000000000..1cb25776b --- /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 f3a2abd07..53dd82112 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 47924841c..1355e17fd 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 ceaab54dc..2a693ef71 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 2e1f9baac..235af6b3b 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 12d99476d..fa2968e3b 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.test.mjs b/lib/terminal-extra-keys.test.mjs new file mode 100644 index 000000000..dafc85c86 --- /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"); +}); diff --git a/lib/terminal-extra-keys.ts b/lib/terminal-extra-keys.ts new file mode 100644 index 000000000..b25ebcafe --- /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); +}