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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion backend/internal/adapters/runtime/zellij/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions backend/internal/adapters/runtime/zellij/zellij_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
app,
BrowserWindow,
clipboard,
dialog,
ipcMain,
net,
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
Expand Down
134 changes: 131 additions & 3 deletions frontend/src/renderer/components/CenterPane.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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
Expand All @@ -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>
);
Expand Down
19 changes: 15 additions & 4 deletions frontend/src/renderer/components/TerminalPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand All @@ -41,6 +45,7 @@ export function TerminalPane({ session, theme, daemonReady, terminalTarget }: Te
session={session}
theme={theme}
daemonReady={daemonReady}
fontSize={fontSize}
terminalTarget={terminalTarget}
/>
);
Expand All @@ -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 }
Expand Down Expand Up @@ -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">
Expand Down
Loading
Loading