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" }),