Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
46 changes: 46 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
Expand Down
111 changes: 111 additions & 0 deletions components/TerminalExtraKeys.tsx
Original file line number Diff line number Diff line change
@@ -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<TerminalExtraKey | TerminalModifier, string> = {
Escape: "ESC", "/": "/", "-": "-", Home: "HOME", ArrowUp: "↑", End: "END", PageUp: "PGUP",
Tab: "TAB", ctrl: "CTRL", alt: "ALT", ArrowLeft: "↓", ArrowDown: "↓", ArrowRight: "↑", PageDown: "PGDN",
};

const KEY_NAMES: Partial<Record<TerminalExtraKey, string>> = {
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<number | null>(null);

const stopRepeat = () => {
if (repeatTimer.current !== null) window.clearTimeout(repeatTimer.current);
repeatTimer.current = null;
};
useEffect(() => stopRepeat, []);

const press = (event: PointerEvent<HTMLButtonElement>, 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 (
<div className="terminal-extra-keys" role="toolbar" aria-label={t("terminal.extraKeys")}>
{TERMINAL_EXTRA_KEY_ROWS.flat().map((key) => {
const modifier = key === "ctrl" || key === "alt";
return (
<button
key={key}
type="button"
tabIndex={-1}
className={modifier && modifiers[key] ? "is-active" : undefined}
aria-pressed={modifier ? modifiers[key] : undefined}
aria-label={modifier ? undefined : KEY_NAMES[key]}
onPointerDown={(event) => press(event, key)}
onPointerUp={stopRepeat}
onPointerCancel={stopRepeat}
onPointerLeave={stopRepeat}
onMouseDown={(event) => event.preventDefault()}
onContextMenu={(event) => event.preventDefault()}
>
{key === "ArrowLeft" || key === "ArrowRight" ? <span className="is-rotated">{KEY_LABELS[key]}</span> : KEY_LABELS[key]}
</button>
);
})}
</div>
);
}
39 changes: 36 additions & 3 deletions components/TerminalPanel.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -31,6 +40,29 @@ export function TerminalPanel({ tab, active, onRestart, onClosed, onCloseError }
const [error, setError] = useState<string | null>(null);
const [exitCode, setExitCode] = useState<number | null>(null);
const [reconnectKey, setReconnectKey] = useState(0);
const [modifiers, setModifiers] = useState<TerminalModifiers>(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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -243,6 +275,7 @@ export function TerminalPanel({ tab, active, onRestart, onClosed, onCloseError }
{status === "exited" && <div className="terminal-panel-exit" role="status">{exitCode === null ? t("terminal.exited") : t("terminal.exitCode", { code: exitCode })}</div>}
</div>
<div className="terminal-xterm"><div ref={containerRef} className="terminal-xterm-host" /></div>
{showExtraKeys && <TerminalExtraKeys modifiers={modifiers} onKey={sendExtraKey} onToggleModifier={toggleModifier} />}
</section>
);
}
5 changes: 5 additions & 0 deletions docs/agents/client-platform.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
- The same check sets `<html data-keyboard-open>`. 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.

Expand Down
1 change: 1 addition & 0 deletions lib/i18n/messages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions lib/i18n/messages/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,7 @@ export const zhCNLocale: LocalePlugin = {
"terminal.close": "终止终端",
"terminal.restart": "重启终端",
"terminal.reconnect": "重新连接终端",
"terminal.extraKeys": "终端按键",
"terminal.connecting": "连接中",
"terminal.ready": "已连接",
"terminal.exited": "终端已关闭",
Expand Down
1 change: 1 addition & 0 deletions lib/i18n/messages/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,7 @@ export const zhTWLocale: LocalePlugin = {
"terminal.close": "終止終端機",
"terminal.restart": "重新啟動終端機",
"terminal.reconnect": "重新連線終端機",
"terminal.extraKeys": "終端機按鍵",
"terminal.connecting": "連線中",
"terminal.ready": "已連線",
"terminal.exited": "終端機已關閉",
Expand Down
52 changes: 52 additions & 0 deletions lib/terminal-extra-keys.test.mjs
Original file line number Diff line number Diff line change
@@ -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");
});
Loading
Loading