fix: add terminal controls and restore copy/scroll (#372)

* fix(frontend): add terminal controls and reliable copy

* chore: format with prettier [skip ci]

* fix(frontend): preserve terminal mouse scrolling

* fix(backend): enable zellij wheel scrolling

* fix(frontend): forward xterm scroll input

* fix(frontend): restore terminal drag selection copy

* fix(frontend): make terminal wheel scroll zellij scrollback

zellij 0.44.x with mouse-mode true acts on SGR wheel reports written to
its stdin and scrolls the focused pane, but it does not enable host mouse
reporting. xterm therefore never reports the wheel itself (protocol stays
NONE) and, with scrollback:0, converts the wheel into cursor-arrow keys,
which move the agent's cursor/history instead of scrolling.

Synthesize SGR wheel reports from a custom wheel handler and send them
through the existing input pipe; accumulate pixel deltas into line counts
to match xterm's native scroll feel. Ctrl/Cmd wheel is left for the
font-size zoom handler. Drag-copy is unaffected (separate selection path).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: format with prettier [skip ci]

* fix(frontend): handle line/page wheel modes for cross-platform scroll

The wheel-to-SGR translation only divided pixel deltas by row height,
which is the deltaMode browsers emit for trackpads and normalized wheels
(macOS). Many Linux/Windows mouse wheels report whole lines (deltaMode 1)
or pages (deltaMode 2) with small deltaY, which truncated to zero lines
and never scrolled. Mirror xterm's getLinesScrolled across all three
modes so scroll works on every platform.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Harshit Singh Bhandari <dev@theharshitsingh.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Laxman 2026-06-22 02:26:54 +05:30 committed by GitHub
parent 8146629ad6
commit 7ba860741c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 669 additions and 15 deletions

View File

@ -86,7 +86,10 @@ func attachArgs(id string) []string {
func embeddedClientOptions() []string {
return []string{
"--pane-frames", "false",
"--mouse-mode", "false",
// The dashboard terminal disables xterm local scrollback and lets the
// embedded zellij client own scrollback. Keep mouse mode on so wheel
// reports reach zellij, while leaving richer pointer behaviors off.
"--mouse-mode", "true",
"--advanced-mouse-actions", "false",
"--mouse-hover-effects", "false",
"--focus-follows-mouse", "false",

View File

@ -89,7 +89,7 @@ func containsKey(values []string, key string) bool {
func TestCommandBuilders(t *testing.T) {
embeddedOptions := []string{
"--pane-frames", "false",
"--mouse-mode", "false",
"--mouse-mode", "true",
"--advanced-mouse-actions", "false",
"--mouse-hover-effects", "false",
"--focus-follows-mouse", "false",
@ -441,7 +441,7 @@ func TestAttachCommandUsesEmbeddedClientOptions(t *testing.T) {
}
embeddedOptions := []string{
"--pane-frames", "false",
"--mouse-mode", "false",
"--mouse-mode", "true",
"--advanced-mouse-actions", "false",
"--mouse-hover-effects", "false",
"--focus-follows-mouse", "false",

View File

@ -1,6 +1,7 @@
import {
app,
BrowserWindow,
clipboard,
dialog,
ipcMain,
net,
@ -550,6 +551,13 @@ ipcMain.handle("app:chooseDirectory", async () => {
if (result.canceled) return null;
return result.filePaths[0] ?? null;
});
ipcMain.handle("clipboard:writeText", (_event, text: string) => {
clipboard.writeText(text, "clipboard");
if (process.platform === "linux") {
clipboard.writeText(text, "selection");
}
});
ipcMain.handle("clipboard:readText", () => clipboard.readText());
ipcMain.handle("notifications:show", (_event, notification: { id: string; title: string; body?: string }) => {
if (!notification.id || !notification.title || !ElectronNotification.isSupported()) return;

View File

@ -7,6 +7,10 @@ const api = {
getVersion: () => ipcRenderer.invoke("app:getVersion") as Promise<string>,
chooseDirectory: () => ipcRenderer.invoke("app:chooseDirectory") as Promise<string | null>,
},
clipboard: {
writeText: (text: string) => ipcRenderer.invoke("clipboard:writeText", text) as Promise<void>,
readText: () => ipcRenderer.invoke("clipboard:readText") as Promise<string>,
},
daemon: {
getStatus: () => ipcRenderer.invoke("daemon:getStatus") as Promise<DaemonStatus>,
start: () => ipcRenderer.invoke("daemon:start") as Promise<DaemonStatus>,

View File

@ -1,4 +1,5 @@
import { ChevronLeft, Shield } from "lucide-react";
import { ChevronLeft, Maximize2, Minimize2, Shield } from "lucide-react";
import { useCallback, useEffect, useRef, useState, type WheelEvent } from "react";
import type { Theme } from "../stores/ui-store";
import type { TerminalTarget } from "../types/terminal";
import type { WorkspaceSession } from "../types/workspace";
@ -12,11 +13,132 @@ type CenterPaneProps = {
onSelectWorkerTerminal?: () => void;
};
const terminalFontSizeStorageKey = "ao.terminal.fontSize";
const DEFAULT_TERMINAL_FONT_SIZE = 12;
const MIN_TERMINAL_FONT_SIZE = 10;
const MAX_TERMINAL_FONT_SIZE = 20;
const WHEEL_ZOOM_THRESHOLD = 80;
const WHEEL_ZOOM_RESET_MS = 250;
function clampTerminalFontSize(size: number): number {
return Math.min(MAX_TERMINAL_FONT_SIZE, Math.max(MIN_TERMINAL_FONT_SIZE, size));
}
function initialTerminalFontSize(): number {
if (typeof window === "undefined") return DEFAULT_TERMINAL_FONT_SIZE;
const raw = window.localStorage?.getItem(terminalFontSizeStorageKey);
const parsed = raw === null ? Number.NaN : Number(raw);
if (!Number.isFinite(parsed)) return DEFAULT_TERMINAL_FONT_SIZE;
return clampTerminalFontSize(parsed);
}
export function CenterPane({ session, theme, daemonReady, terminalTarget, onSelectWorkerTerminal }: CenterPaneProps) {
const paneRef = useRef<HTMLDivElement | null>(null);
const wheelZoomRemainderRef = useRef(0);
const lastWheelZoomAtRef = useRef(0);
const [fontSize, setFontSize] = useState(initialTerminalFontSize);
const [isFullscreen, setIsFullscreen] = useState(false);
const target = terminalTarget ?? { kind: "worker" };
useEffect(() => {
const handleFullscreenChange = () => setIsFullscreen(document.fullscreenElement === paneRef.current);
document.addEventListener("fullscreenchange", handleFullscreenChange);
return () => document.removeEventListener("fullscreenchange", handleFullscreenChange);
}, []);
const updateFontSize = useCallback((delta: number) => {
setFontSize((current) => {
const next = clampTerminalFontSize(current + delta);
window.localStorage?.setItem(terminalFontSizeStorageKey, String(next));
return next;
});
}, []);
const toggleFullscreen = useCallback(async () => {
const pane = paneRef.current;
if (!pane) return;
try {
if (document.fullscreenElement === pane) {
await document.exitFullscreen();
return;
}
await pane.requestFullscreen();
} catch (error) {
console.warn("Unable to toggle terminal fullscreen", error);
}
}, []);
const handleWheelZoom = useCallback(
(event: WheelEvent<HTMLDivElement>) => {
if (!event.ctrlKey && !event.metaKey) return;
event.preventDefault();
event.stopPropagation();
if (event.timeStamp - lastWheelZoomAtRef.current > WHEEL_ZOOM_RESET_MS) {
wheelZoomRemainderRef.current = 0;
}
lastWheelZoomAtRef.current = event.timeStamp;
wheelZoomRemainderRef.current += event.deltaY;
const steps = Math.floor(Math.abs(wheelZoomRemainderRef.current) / WHEEL_ZOOM_THRESHOLD);
if (steps === 0) return;
const direction = wheelZoomRemainderRef.current > 0 ? -1 : 1;
updateFontSize(direction * steps);
wheelZoomRemainderRef.current -= Math.sign(wheelZoomRemainderRef.current) * steps * WHEEL_ZOOM_THRESHOLD;
},
[updateFontSize],
);
return (
<div className="flex h-full min-h-0 min-w-0 flex-col bg-background">
<div
ref={paneRef}
className="terminal-pane-frame flex h-full min-h-0 min-w-0 flex-col bg-background"
onWheelCapture={handleWheelZoom}
>
<div className="terminal-toolbar">
<div className="terminal-toolbar__label">
<span className="terminal-toolbar__eyebrow">TERMINAL</span>
<span className="terminal-toolbar__session">{session?.id ?? "No session"}</span>
</div>
<div className="terminal-toolbar__controls">
<button
aria-label="Decrease terminal font size"
className="terminal-toolbar__control"
disabled={fontSize <= MIN_TERMINAL_FONT_SIZE}
onClick={() => updateFontSize(-1)}
title="Decrease terminal font size"
type="button"
>
-
</button>
<span className="terminal-toolbar__font-size">{fontSize}px</span>
<button
aria-label="Increase terminal font size"
className="terminal-toolbar__control"
disabled={fontSize >= MAX_TERMINAL_FONT_SIZE}
onClick={() => updateFontSize(1)}
title="Increase terminal font size"
type="button"
>
+
</button>
<button
aria-label={isFullscreen ? "Exit terminal fullscreen" : "Open terminal fullscreen"}
aria-pressed={isFullscreen}
className="terminal-toolbar__control terminal-toolbar__control--icon"
onClick={() => void toggleFullscreen()}
title={isFullscreen ? "Exit fullscreen" : "Fullscreen terminal"}
type="button"
>
{isFullscreen ? (
<Minimize2 className="h-3.5 w-3.5" aria-hidden="true" />
) : (
<Maximize2 className="h-3.5 w-3.5" aria-hidden="true" />
)}
</button>
</div>
</div>
{target.kind === "reviewer" ? (
<div className="reviewer-terminal-header">
<button
@ -36,7 +158,13 @@ export function CenterPane({ session, theme, daemonReady, terminalTarget, onSele
</div>
) : null}
<div className="min-h-0 flex-1">
<TerminalPane daemonReady={daemonReady} session={session} terminalTarget={target} theme={theme} />
<TerminalPane
daemonReady={daemonReady}
fontSize={fontSize}
session={session}
terminalTarget={target}
theme={theme}
/>
</div>
</div>
);

View File

@ -13,16 +13,20 @@ type TerminalPaneProps = {
theme: Theme;
daemonReady: boolean;
terminalTarget?: TerminalTarget;
fontSize: number;
};
export function TerminalPane({ session, theme, daemonReady, terminalTarget }: TerminalPaneProps) {
export function TerminalPane({ session, theme, daemonReady, terminalTarget, fontSize }: TerminalPaneProps) {
const terminalKey =
terminalTarget?.kind === "reviewer" ? terminalTarget.handleId : (session?.terminalHandleId ?? "empty");
if (!window.ao) {
const provider = terminalTarget?.kind === "reviewer" ? terminalTarget.harness : (session?.provider ?? "claude");
return (
<pre className="h-full overflow-auto bg-terminal p-4 font-mono text-[13px] leading-relaxed text-[var(--term-fg)]">
<pre
className="h-full overflow-auto bg-terminal p-4 font-mono leading-relaxed text-[var(--term-fg)]"
style={{ fontSize }}
>
<span className="text-[var(--term-dim)]">~/{session?.workspaceName ?? "reverbcode"}</span>{" "}
<span className="text-[var(--term-blue)]">{session?.branch || "main"}</span> $ {provider}
{"\n"}
@ -41,6 +45,7 @@ export function TerminalPane({ session, theme, daemonReady, terminalTarget }: Te
session={session}
theme={theme}
daemonReady={daemonReady}
fontSize={fontSize}
terminalTarget={terminalTarget}
/>
);
@ -52,7 +57,7 @@ function bannerText(state: TerminalSessionState, error?: string): string | undef
return undefined;
}
function AttachedTerminal({ session, theme, daemonReady, terminalTarget }: TerminalPaneProps) {
function AttachedTerminal({ session, theme, daemonReady, terminalTarget, fontSize }: TerminalPaneProps) {
const attachSession =
session && terminalTarget?.kind === "reviewer"
? { ...session, terminalHandleId: terminalTarget.handleId }
@ -135,7 +140,13 @@ function AttachedTerminal({ session, theme, daemonReady, terminalTarget }: Termi
/>
)}
<div className="relative min-h-0 flex-1">
<XtermTerminal ariaLabel="Session terminal" onError={handleInitError} onReady={handleReady} theme={theme} />
<XtermTerminal
ariaLabel="Session terminal"
fontSize={fontSize}
onError={handleInitError}
onReady={handleReady}
theme={theme}
/>
{showEmptyState && (
<div className="absolute inset-0 grid place-items-center bg-terminal font-mono text-[13px]">
<div className="text-center">

View File

@ -0,0 +1,250 @@
import { render } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { XtermTerminal } from "./XtermTerminal";
const state = vi.hoisted(() => ({
lastTerminal: null as null | {
keyHandler?: (event: KeyboardEvent) => boolean;
wheelHandler?: (event: WheelEvent) => boolean;
selection: string;
options: Record<string, unknown>;
modes: { bracketedPasteMode: boolean };
dataListeners: Set<(data: string) => void>;
selectionListeners: Set<() => void>;
_core: {
element: { classList: { add: ReturnType<typeof vi.fn>; remove: ReturnType<typeof vi.fn> } };
_selectionService: {
enable: ReturnType<typeof vi.fn>;
shouldForceSelection: (event: MouseEvent) => boolean;
};
};
},
}));
vi.mock("@xterm/xterm", () => ({
Terminal: class FakeTerminal {
options: Record<string, unknown>;
cols = 80;
rows = 24;
selection = "";
keyHandler?: (event: KeyboardEvent) => boolean;
wheelHandler?: (event: WheelEvent) => boolean;
modes = { bracketedPasteMode: false };
dataListeners = new Set<(data: string) => void>();
selectionListeners = new Set<() => void>();
_core = {
element: { classList: { add: vi.fn(), remove: vi.fn() } },
_selectionService: {
enable: vi.fn(),
shouldForceSelection: () => false,
},
};
constructor(options: Record<string, unknown>) {
this.options = options;
state.lastTerminal = this;
}
loadAddon() {}
open(host: HTMLElement) {
host.appendChild(document.createElement("textarea"));
}
write() {}
writeln() {}
dispose() {}
onData(listener: (data: string) => void) {
this.dataListeners.add(listener);
return { dispose: () => this.dataListeners.delete(listener) };
}
onResize() {
return { dispose: () => undefined };
}
onRender() {
return { dispose: () => undefined };
}
onKey() {
return { dispose: () => undefined };
}
onSelectionChange(listener: () => void) {
this.selectionListeners.add(listener);
return { dispose: () => this.selectionListeners.delete(listener) };
}
hasSelection() {
return this.selection.length > 0;
}
getSelection() {
return this.selection;
}
attachCustomKeyEventHandler(listener: (event: KeyboardEvent) => boolean) {
this.keyHandler = listener;
}
attachCustomWheelEventHandler(listener: (event: WheelEvent) => boolean) {
this.wheelHandler = listener;
}
unicode = { activeVersion: "" };
},
}));
vi.mock("@xterm/addon-fit", () => ({
FitAddon: class FakeFitAddon {
fit() {}
},
}));
vi.mock("@xterm/addon-search", () => ({
SearchAddon: class FakeSearchAddon {},
}));
vi.mock("@xterm/addon-unicode11", () => ({
Unicode11Addon: class FakeUnicode11Addon {},
}));
vi.mock("@xterm/addon-web-links", () => ({
WebLinksAddon: class FakeWebLinksAddon {},
}));
vi.mock("@xterm/addon-canvas", () => ({
CanvasAddon: class FakeCanvasAddon {},
}));
vi.mock("@xterm/addon-webgl", () => ({
WebglAddon: class FakeWebglAddon {
onContextLoss() {}
dispose() {}
},
}));
describe("XtermTerminal", () => {
beforeEach(() => {
state.lastTerminal = null;
window.ao!.clipboard.writeText = vi.fn().mockResolvedValue(undefined);
});
it("copies selected terminal text on the terminal copy shortcut", () => {
render(<XtermTerminal theme="dark" />);
state.lastTerminal!.selection = "copied selection";
const event = {
key: "c",
metaKey: true,
ctrlKey: false,
shiftKey: false,
preventDefault: vi.fn(),
} as unknown as KeyboardEvent;
const allowed = state.lastTerminal!.keyHandler!(event);
expect(allowed).toBe(false);
expect(event.preventDefault).toHaveBeenCalled();
expect(window.ao!.clipboard.writeText).toHaveBeenCalledWith("copied selection");
});
it("handles native copy events from inside the terminal", () => {
const { container } = render(<XtermTerminal theme="dark" />);
state.lastTerminal!.selection = "native copied selection";
const setData = vi.fn();
const event = new Event("copy", { bubbles: true, cancelable: true }) as ClipboardEvent;
Object.defineProperty(event, "clipboardData", {
value: { setData },
});
container.firstElementChild!.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(setData).toHaveBeenCalledWith("text/plain", "native copied selection");
expect(window.ao!.clipboard.writeText).toHaveBeenCalledWith("native copied selection");
});
it("copies from the focused xterm textarea when the window receives the copy shortcut", () => {
const { container } = render(<XtermTerminal theme="dark" />);
state.lastTerminal!.selection = "focused copied selection";
container.querySelector("textarea")!.focus();
const event = new KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
key: "c",
metaKey: true,
});
window.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(window.ao!.clipboard.writeText).toHaveBeenCalledWith("focused copied selection");
});
it("auto-copies new selections and retries explicit copy if the auto-copy failed", async () => {
render(<XtermTerminal theme="dark" />);
const writeText = vi.fn().mockRejectedValueOnce(new Error("clipboard failed")).mockResolvedValueOnce(undefined);
window.ao!.clipboard.writeText = writeText;
state.lastTerminal!.selection = "retry me";
state.lastTerminal!.selectionListeners.forEach((listener) => listener());
await new Promise((resolve) => window.setTimeout(resolve, 0));
const event = {
key: "c",
metaKey: true,
ctrlKey: false,
shiftKey: false,
preventDefault: vi.fn(),
} as unknown as KeyboardEvent;
const allowed = state.lastTerminal!.keyHandler!(event);
expect(allowed).toBe(false);
expect(writeText).toHaveBeenCalledTimes(2);
expect(writeText).toHaveBeenLastCalledWith("retry me");
});
it("forwards generated xterm input data such as wheel scroll reports", () => {
const onInput = vi.fn();
render(<XtermTerminal theme="dark" onReady={(terminal) => terminal.onUserInput(onInput)} />);
state.lastTerminal!.dataListeners.forEach((listener) => listener("\x1b[A"));
expect(onInput).toHaveBeenCalledWith("\x1b[A", "terminal");
});
it("translates wheel motion into SGR wheel reports for zellij scrollback", () => {
const onInput = vi.fn();
render(<XtermTerminal theme="dark" onReady={(terminal) => terminal.onUserInput(onInput)} />);
// rowHeight = fontSize(12) * lineHeight(1.35) = 16.2px; -50px => 3 lines up.
const suppressed = state.lastTerminal!.wheelHandler!({ deltaY: -50 } as WheelEvent);
expect(suppressed).toBe(false);
expect(onInput).toHaveBeenCalledWith("\x1b[<64;1;1M\x1b[<64;1;1M\x1b[<64;1;1M", "terminal");
});
it("handles line- and page-mode wheels (Linux/Windows mice), not just pixel deltas", () => {
const onInput = vi.fn();
render(<XtermTerminal theme="dark" onReady={(terminal) => terminal.onUserInput(onInput)} />);
// DOM_DELTA_LINE: deltaY is already in lines, so one notch up => one report.
expect(state.lastTerminal!.wheelHandler!({ deltaY: -1, deltaMode: 1 } as WheelEvent)).toBe(false);
expect(onInput).toHaveBeenLastCalledWith("\x1b[<64;1;1M", "terminal");
// DOM_DELTA_PAGE: one page down => rows (24) line reports down.
onInput.mockClear();
expect(state.lastTerminal!.wheelHandler!({ deltaY: 1, deltaMode: 2 } as WheelEvent)).toBe(false);
expect(onInput).toHaveBeenLastCalledWith("\x1b[<65;1;1M".repeat(24), "terminal");
});
it("scrolls down on positive wheel delta and leaves zoom (ctrl/meta) wheel alone", () => {
const onInput = vi.fn();
render(<XtermTerminal theme="dark" onReady={(terminal) => terminal.onUserInput(onInput)} />);
expect(state.lastTerminal!.wheelHandler!({ deltaY: 20 } as WheelEvent)).toBe(false);
expect(onInput).toHaveBeenCalledWith("\x1b[<65;1;1M", "terminal");
onInput.mockClear();
expect(state.lastTerminal!.wheelHandler!({ deltaY: -50, ctrlKey: true } as WheelEvent)).toBe(false);
expect(onInput).not.toHaveBeenCalled();
});
it("forces plain drag selection while preserving xterm data forwarding", () => {
render(<XtermTerminal theme="dark" />);
expect(state.lastTerminal!.options.macOptionClickForcesSelection).toBe(true);
expect(state.lastTerminal!._core._selectionService.enable).toHaveBeenCalled();
expect(state.lastTerminal!._core.element.classList.remove).toHaveBeenCalledWith("enable-mouse-events");
expect(state.lastTerminal!._core._selectionService.shouldForceSelection({} as MouseEvent)).toBe(true);
});
});

View File

@ -28,12 +28,14 @@ import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import type { AttachableTerminal, TerminalUserInputSource } from "../hooks/useTerminalSession";
import { aoBridge } from "../lib/bridge";
import { buildTerminalThemes } from "../lib/terminal-themes";
import type { Theme } from "../stores/ui-store";
export type XtermTerminalProps = {
ariaLabel?: string;
className?: string;
fontSize?: number;
theme: Theme;
/** Terminal construction failed; the owner decides how to surface it. */
onError?: (error: unknown) => void;
@ -82,9 +84,56 @@ function bracketPastedText(text: string, bracketedPasteMode: boolean): string {
return bracketedPasteMode ? `\x1b[200~${text}\x1b[201~` : text;
}
function isTerminalCopyShortcut(event: KeyboardEvent): boolean {
if (event.key.toLowerCase() !== "c") return false;
if (event.metaKey) return true;
return event.ctrlKey && event.shiftKey;
}
function terminalHasFocus(host: HTMLElement): boolean {
const activeElement = document.activeElement;
return !!activeElement && host.contains(activeElement);
}
type XtermInternal = Terminal & {
_core?: {
element?: HTMLElement;
_selectionService?: {
enable: () => void;
shouldForceSelection: (event: MouseEvent) => boolean;
};
};
};
// zellij (started with `--mouse-mode true`, see backend embeddedClientOptions)
// acts on SGR mouse-wheel reports written to its stdin and scrolls the focused
// pane, but it does NOT enable host mouse reporting, so xterm's own mouse
// protocol stays NONE and it never reports the wheel itself. With scrollback:0
// xterm would instead convert the wheel into cursor-arrow keys (its alt-buffer
// fallback), which move the agent's cursor/history rather than scrolling. So we
// synthesize the SGR wheel reports here. SGR button 64 = wheel up, 65 = down;
// reports are 1-based and a single cell is enough for a borderless single pane.
const SGR_WHEEL_UP = 64;
const SGR_WHEEL_DOWN = 65;
function sgrWheelReport(button: number, count: number): string {
return `\x1b[<${button};1;1M`.repeat(count);
}
function forceSelectionMode(term: Terminal): void {
const internal = term as XtermInternal;
const selectionService = internal._core?._selectionService;
const element = internal._core?.element;
if (!selectionService || !element) return;
selectionService.shouldForceSelection = () => true;
selectionService.enable();
element.classList.remove("enable-mouse-events");
}
export function XtermTerminal(props: XtermTerminalProps) {
const hostRef = useRef<HTMLDivElement | null>(null);
const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<(() => void) | null>(null);
// Latest callbacks in a ref so the mount effect stays dependency-free — we
// never tear down and recreate the terminal because a handler identity
// changed between renders.
@ -100,6 +149,15 @@ export function XtermTerminal(props: XtermTerminalProps) {
term.options.theme = props.theme === "dark" ? terminalThemes.dark : terminalThemes.light;
}, [props.theme]);
useEffect(() => {
const term = termRef.current;
if (!term || !props.fontSize) return undefined;
term.options.fontSize = props.fontSize;
fitRef.current?.();
const timer = window.setTimeout(() => fitRef.current?.(), 50);
return () => window.clearTimeout(timer);
}, [props.fontSize]);
useEffect(() => {
const host = hostRef.current;
if (!host) return undefined;
@ -118,7 +176,7 @@ export function XtermTerminal(props: XtermTerminalProps) {
fontFamily:
getComputedStyle(host).getPropertyValue("--font-mono").trim() ||
'ui-monospace, Menlo, Monaco, "Courier New", monospace',
fontSize: 13,
fontSize: props.fontSize ?? 12,
lineHeight: 1.35,
// Agent TUIs leave SGR bold active while using ANSI black for
// separators; keep bold weight-only so black stays black.
@ -153,6 +211,50 @@ export function XtermTerminal(props: XtermTerminalProps) {
term.open(host);
loadRenderer(term);
term.options.macOptionClickForcesSelection = true;
forceSelectionMode(term);
let lastCopiedSelection = "";
const copySelection = (options?: { clipboardData?: DataTransfer | null; dedupe?: boolean }) => {
const selection = term.getSelection();
if (!selection || (options?.dedupe && selection === lastCopiedSelection)) return false;
options?.clipboardData?.setData("text/plain", selection);
void aoBridge.clipboard
.writeText(selection)
.then(() => {
lastCopiedSelection = selection;
})
.catch((error) => {
console.warn("Unable to copy terminal selection", error);
});
return true;
};
const clearCopiedSelection = () => {
lastCopiedSelection = "";
};
term.attachCustomKeyEventHandler((event) => {
if (!isTerminalCopyShortcut(event) || !copySelection()) return true;
event.preventDefault();
return false;
});
const copyInput = (event: ClipboardEvent) => {
if (!copySelection({ clipboardData: event.clipboardData })) return;
event.preventDefault();
};
const copyShortcut = (event: KeyboardEvent) => {
if (!isTerminalCopyShortcut(event) || !terminalHasFocus(host) || !copySelection()) return;
event.preventDefault();
event.stopPropagation();
};
host.addEventListener("copy", copyInput);
window.addEventListener("keydown", copyShortcut, true);
const selectionChange = term.onSelectionChange(() => {
if (!term.hasSelection()) {
clearCopiedSelection();
return;
}
window.setTimeout(() => copySelection({ dedupe: true }), 0);
});
const fitTerminal = () => {
try {
@ -162,6 +264,7 @@ export function XtermTerminal(props: XtermTerminalProps) {
// trigger retries.
}
};
fitRef.current = fitTerminal;
const raf = requestAnimationFrame(fitTerminal);
// 50/250ms catch the common settle; 600/1200ms are a session-bounded
@ -240,7 +343,36 @@ export function XtermTerminal(props: XtermTerminalProps) {
if (data.length === 0) return;
userInputListeners.forEach((listener) => listener(data, source));
};
const keyInput = term.onKey(({ key }) => emitUserInput(key, "keyboard"));
const terminalInput = term.onData((data) => emitUserInput(data, "terminal"));
// Translate wheel motion into SGR wheel reports for zellij (see
// sgrWheelReport), one report per scrolled line. WheelEvent.deltaMode
// varies by platform/device: trackpads and normalized wheels report
// pixels (mode 0, the macOS case), while many Linux/Windows mouse wheels
// report whole lines (mode 1) or pages (mode 2). Mirror xterm's native
// getLinesScrolled across all three so scroll works everywhere; pixel
// deltas accumulate so a full cell-height emits one line. Returning false
// suppresses xterm's arrow-key wheel fallback. Ctrl/Cmd wheel is the
// font-size zoom (CenterPane), so leave it for that handler.
let wheelAccumPx = 0;
term.attachCustomWheelEventHandler((event) => {
if (event.ctrlKey || event.metaKey) return false;
let lines: number;
if (event.deltaMode === 1 /* DOM_DELTA_LINE */) {
lines = Math.trunc(event.deltaY) || Math.sign(event.deltaY);
} else if (event.deltaMode === 2 /* DOM_DELTA_PAGE */) {
lines = (Math.trunc(event.deltaY) || Math.sign(event.deltaY)) * term.rows;
} else {
const rowHeight = (term.options.fontSize ?? 12) * (term.options.lineHeight ?? 1);
wheelAccumPx += event.deltaY;
lines = Math.trunc(wheelAccumPx / rowHeight);
wheelAccumPx -= lines * rowHeight;
}
if (lines === 0) return false;
const button = lines < 0 ? SGR_WHEEL_UP : SGR_WHEEL_DOWN;
emitUserInput(sgrWheelReport(button, Math.abs(lines)), "terminal");
return false;
});
const pasteInput = (event: ClipboardEvent) => {
event.preventDefault();
event.stopPropagation();
@ -277,14 +409,18 @@ export function XtermTerminal(props: XtermTerminalProps) {
return () => {
termRef.current = null;
fitRef.current = null;
cancelAnimationFrame(raf);
for (const timer of settleTimers) window.clearTimeout(timer);
observer.disconnect();
stabilizer.dispose();
window.removeEventListener("resize", fitTerminal);
host.removeEventListener("copy", copyInput);
window.removeEventListener("keydown", copyShortcut, true);
selectionChange.dispose();
host.removeEventListener("paste", pasteInput, true);
host.removeEventListener("compositionend", compositionInput, true);
keyInput.dispose();
terminalInput.dispose();
userInputListeners.clear();
try {
term.dispose();

View File

@ -115,7 +115,7 @@ function createFakeTerminal(): FakeTerminal {
resizeListeners.add(listener);
return { dispose: () => resizeListeners.delete(listener) };
},
typeKeys: (data) => inputListeners.forEach((listener) => listener(data, "keyboard")),
typeKeys: (data) => inputListeners.forEach((listener) => listener(data, "terminal")),
paste: (data) => inputListeners.forEach((listener) => listener(data, "paste")),
emitResize: (cols, rows) => resizeListeners.forEach((listener) => listener({ cols, rows })),
};

View File

@ -18,7 +18,7 @@ import { workspaceQueryKey } from "./useWorkspaceQuery";
* The slice of xterm's Terminal the attachment needs. Structural, so tests can
* drive the hook with a tiny fake instead of a real xterm + DOM.
*/
export type TerminalUserInputSource = "keyboard" | "paste" | "composition";
export type TerminalUserInputSource = "terminal" | "paste" | "composition";
export type AttachableTerminal = {
cols: number;

View File

@ -7,6 +7,14 @@ export const aoBridge: AoBridge =
getVersion: async () => "0.0.0-preview",
chooseDirectory: async () => null,
},
clipboard: {
writeText: async (text: string) => {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
}
},
readText: async () => (navigator.clipboard?.readText ? navigator.clipboard.readText() : ""),
},
daemon: {
getStatus: async () => ({
state: "stopped",

View File

@ -231,6 +231,108 @@ select {
height: 100%;
}
.terminal-pane-frame:fullscreen {
background: var(--bg);
}
.terminal-toolbar {
display: flex;
height: 56px;
flex: 0 0 56px;
align-items: center;
border-bottom: 1px solid var(--border);
background: var(--bg);
padding: 0 18px;
}
.terminal-toolbar__label {
display: flex;
min-width: 0;
align-items: center;
gap: 12px;
}
.terminal-toolbar__eyebrow {
flex: 0 0 auto;
font-family: var(--font-mono);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.09em;
color: var(--fg-muted);
}
.terminal-toolbar__session {
min-width: 0;
overflow: hidden;
font-family: var(--font-mono);
font-size: 13px;
font-weight: 600;
color: var(--fg-passive);
text-overflow: ellipsis;
white-space: nowrap;
}
.terminal-toolbar__controls {
display: flex;
margin-left: auto;
align-items: center;
gap: 12px;
font-family: var(--font-mono);
color: var(--fg-passive);
}
.terminal-toolbar__control {
display: inline-flex;
width: 24px;
height: 24px;
align-items: center;
justify-content: center;
border: 0;
border-radius: 5px;
background: transparent;
color: var(--fg-passive);
cursor: pointer;
font-family: var(--font-mono);
font-size: 13px;
line-height: 1;
transition:
background 120ms ease,
color 120ms ease,
opacity 120ms ease;
}
.terminal-toolbar__control:hover {
background: var(--interactive-hover);
color: var(--fg);
}
.terminal-toolbar__control:focus-visible {
outline: 2px solid color-mix(in srgb, var(--accent) 50%, transparent);
outline-offset: 1px;
}
.terminal-toolbar__control:disabled {
cursor: default;
opacity: 0.35;
}
.terminal-toolbar__control:disabled:hover {
background: transparent;
color: var(--fg-passive);
}
.terminal-toolbar__control--icon {
margin-left: 6px;
}
.terminal-toolbar__font-size {
width: 42px;
text-align: center;
font-size: 12px;
font-weight: 600;
color: var(--fg-muted);
}
:root[data-theme="light"] .xterm .xterm-viewport::-webkit-scrollbar-thumb {
background: rgb(0 0 0 / 0.12);
}

View File

@ -55,6 +55,10 @@ window.ao = {
getVersion: async () => "0.0.0-test",
chooseDirectory: async () => null,
},
clipboard: {
writeText: async () => undefined,
readText: async () => "",
},
daemon: {
getStatus: async () => ({ state: "stopped" }),
start: async () => ({ state: "starting" }),