From 7ba860741cf213ed32d681e0383158b8fa13ec3b Mon Sep 17 00:00:00 2001 From: Laxman <137504925+laxmanclo@users.noreply.github.com> Date: Mon, 22 Jun 2026 02:26:54 +0530 Subject: [PATCH] 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 * 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 --------- Co-authored-by: github-actions[bot] Co-authored-by: Harshit Singh Bhandari Co-authored-by: Claude Opus 4.8 --- .../adapters/runtime/zellij/commands.go | 5 +- .../adapters/runtime/zellij/zellij_test.go | 4 +- frontend/src/main.ts | 8 + frontend/src/preload.ts | 4 + .../src/renderer/components/CenterPane.tsx | 134 +++++++++- .../src/renderer/components/TerminalPane.tsx | 19 +- .../components/XtermTerminal.test.tsx | 250 ++++++++++++++++++ .../src/renderer/components/XtermTerminal.tsx | 142 +++++++++- .../hooks/useTerminalSession.test.tsx | 2 +- .../src/renderer/hooks/useTerminalSession.ts | 2 +- frontend/src/renderer/lib/bridge.ts | 8 + frontend/src/renderer/styles.css | 102 +++++++ frontend/src/renderer/test/setup.ts | 4 + 13 files changed, 669 insertions(+), 15 deletions(-) create mode 100644 frontend/src/renderer/components/XtermTerminal.test.tsx diff --git a/backend/internal/adapters/runtime/zellij/commands.go b/backend/internal/adapters/runtime/zellij/commands.go index e64d1921b..b410bd504 100644 --- a/backend/internal/adapters/runtime/zellij/commands.go +++ b/backend/internal/adapters/runtime/zellij/commands.go @@ -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", diff --git a/backend/internal/adapters/runtime/zellij/zellij_test.go b/backend/internal/adapters/runtime/zellij/zellij_test.go index 610a62ec0..657b575fb 100644 --- a/backend/internal/adapters/runtime/zellij/zellij_test.go +++ b/backend/internal/adapters/runtime/zellij/zellij_test.go @@ -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", diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 4f60d50ba..e2a80e365 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -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; diff --git a/frontend/src/preload.ts b/frontend/src/preload.ts index b6cce80d2..09e7ac29f 100644 --- a/frontend/src/preload.ts +++ b/frontend/src/preload.ts @@ -7,6 +7,10 @@ const api = { getVersion: () => ipcRenderer.invoke("app:getVersion") as Promise, chooseDirectory: () => ipcRenderer.invoke("app:chooseDirectory") as Promise, }, + clipboard: { + writeText: (text: string) => ipcRenderer.invoke("clipboard:writeText", text) as Promise, + readText: () => ipcRenderer.invoke("clipboard:readText") as Promise, + }, daemon: { getStatus: () => ipcRenderer.invoke("daemon:getStatus") as Promise, start: () => ipcRenderer.invoke("daemon:start") as Promise, diff --git a/frontend/src/renderer/components/CenterPane.tsx b/frontend/src/renderer/components/CenterPane.tsx index 613ca2dd3..09d494ae5 100644 --- a/frontend/src/renderer/components/CenterPane.tsx +++ b/frontend/src/renderer/components/CenterPane.tsx @@ -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(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) => { + 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 ( -
+
+
+
+ TERMINAL + {session?.id ?? "No session"} +
+
+ + {fontSize}px + + +
+
{target.kind === "reviewer" ? (
); diff --git a/frontend/src/renderer/components/TerminalPane.tsx b/frontend/src/renderer/components/TerminalPane.tsx index 5d6109083..841762e88 100644 --- a/frontend/src/renderer/components/TerminalPane.tsx +++ b/frontend/src/renderer/components/TerminalPane.tsx @@ -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 ( -
+			
 				~/{session?.workspaceName ?? "reverbcode"}{" "}
 				{session?.branch || "main"} $ {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
 				/>
 			)}
 			
- + {showEmptyState && (
diff --git a/frontend/src/renderer/components/XtermTerminal.test.tsx b/frontend/src/renderer/components/XtermTerminal.test.tsx new file mode 100644 index 000000000..d3f0b0d32 --- /dev/null +++ b/frontend/src/renderer/components/XtermTerminal.test.tsx @@ -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; + modes: { bracketedPasteMode: boolean }; + dataListeners: Set<(data: string) => void>; + selectionListeners: Set<() => void>; + _core: { + element: { classList: { add: ReturnType; remove: ReturnType } }; + _selectionService: { + enable: ReturnType; + shouldForceSelection: (event: MouseEvent) => boolean; + }; + }; + }, +})); + +vi.mock("@xterm/xterm", () => ({ + Terminal: class FakeTerminal { + options: Record; + 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) { + 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(); + 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(); + 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(); + 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(); + 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( 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( 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( 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( 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(); + + 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); + }); +}); diff --git a/frontend/src/renderer/components/XtermTerminal.tsx b/frontend/src/renderer/components/XtermTerminal.tsx index 3851bc5f2..9f354f588 100644 --- a/frontend/src/renderer/components/XtermTerminal.tsx +++ b/frontend/src/renderer/components/XtermTerminal.tsx @@ -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(null); const termRef = useRef(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(); diff --git a/frontend/src/renderer/hooks/useTerminalSession.test.tsx b/frontend/src/renderer/hooks/useTerminalSession.test.tsx index 10b4c8918..29a59cad8 100644 --- a/frontend/src/renderer/hooks/useTerminalSession.test.tsx +++ b/frontend/src/renderer/hooks/useTerminalSession.test.tsx @@ -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 })), }; diff --git a/frontend/src/renderer/hooks/useTerminalSession.ts b/frontend/src/renderer/hooks/useTerminalSession.ts index cb97a8ab7..0f914af2b 100644 --- a/frontend/src/renderer/hooks/useTerminalSession.ts +++ b/frontend/src/renderer/hooks/useTerminalSession.ts @@ -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; diff --git a/frontend/src/renderer/lib/bridge.ts b/frontend/src/renderer/lib/bridge.ts index a4fdfb72a..b3cabf3f9 100644 --- a/frontend/src/renderer/lib/bridge.ts +++ b/frontend/src/renderer/lib/bridge.ts @@ -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", diff --git a/frontend/src/renderer/styles.css b/frontend/src/renderer/styles.css index 08d1d87d2..858b5714f 100644 --- a/frontend/src/renderer/styles.css +++ b/frontend/src/renderer/styles.css @@ -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); } diff --git a/frontend/src/renderer/test/setup.ts b/frontend/src/renderer/test/setup.ts index 008df470c..28a200876 100644 --- a/frontend/src/renderer/test/setup.ts +++ b/frontend/src/renderer/test/setup.ts @@ -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" }),