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
8 changes: 8 additions & 0 deletions frontend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@ process.stderr.on("error", ignoreStdStreamError);
// Must run before app ready so the About panel and default-menu role labels use it.
app.setName("Agent Orchestrator");

// Windows shows native toasts only when the app declares an AppUserModelID that
// matches its installer shortcut (the NSIS maker's appId). Without it,
// Notification.isSupported() still returns true but show() silently drops the
// toast, so notifications never appear. No-op on macOS/Linux.
if (process.platform === "win32") {
app.setAppUserModelId("dev.agent-orchestrator.desktop");
}

// Pin ALL Electron-owned state (Chromium cache, cookies, local/session storage,
// crash dumps) under the canonical AO home at ~/.ao instead of Electron's macOS
// default ~/Library/Application Support/<name>. Keeps the app's entire footprint
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/renderer/components/TerminalPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ export function TerminalPane({ session, theme, daemonReady, terminalTarget, font
);
}

// Agents whose full-screen TUI keeps its own transcript and scrolls it only by
// keyboard, ignoring SGR wheel reports. The terminal routes the wheel to
// PageUp/PageDown for these (see XtermTerminal's paneScrollsByKeyboard).
const KEYBOARD_SCROLL_PROVIDERS = new Set(["opencode"]);

function bannerText(state: TerminalSessionState, error?: string): string | undefined {
if (state === "reattaching") return "Terminal disconnected — reattaching…";
if (state === "error") return `Terminal error: ${error ?? "connection failed"}`;
Expand All @@ -74,6 +79,7 @@ function AttachedTerminal({ session, theme, daemonReady, terminalTarget, fontSiz
const queryClient = useQueryClient();
const { attach, state, error } = useTerminalSession(attachSession, { daemonReady });
const handleId = attachSession?.terminalHandleId;
const provider = terminalTarget?.kind === "reviewer" ? terminalTarget.harness : session?.provider;
const hadAttachmentRef = useRef(false);
const canRestoreSession = terminalTarget?.kind !== "reviewer" && session?.status === "terminated";

Expand Down Expand Up @@ -154,6 +160,7 @@ function AttachedTerminal({ session, theme, daemonReady, terminalTarget, fontSiz
fontSize={fontSize}
onError={handleInitError}
onReady={handleReady}
paneScrollsByKeyboard={provider ? KEYBOARD_SCROLL_PROVIDERS.has(provider) : false}
theme={theme}
/>
{showEmptyState && (
Expand Down
44 changes: 42 additions & 2 deletions frontend/src/renderer/components/XtermTerminal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const state = vi.hoisted(() => ({
wheelHandler?: (event: WheelEvent) => boolean;
selection: string;
options: Record<string, unknown>;
modes: { bracketedPasteMode: boolean };
modes: { bracketedPasteMode: boolean; mouseTrackingMode: string };
dataListeners: Set<(data: string) => void>;
keyListeners: Set<(event: { key: string }) => void>;
selectionListeners: Set<() => void>;
Expand All @@ -31,7 +31,7 @@ vi.mock("@xterm/xterm", () => ({
selection = "";
keyHandler?: (event: KeyboardEvent) => boolean;
wheelHandler?: (event: WheelEvent) => boolean;
modes = { bracketedPasteMode: false };
modes = { bracketedPasteMode: false, mouseTrackingMode: "vt200" };
dataListeners = new Set<(data: string) => void>();
keyListeners = new Set<(event: { key: string }) => void>();
selectionListeners = new Set<() => void>();
Expand Down Expand Up @@ -492,6 +492,46 @@ describe("XtermTerminal", () => {
expect(onInput).not.toHaveBeenCalled();
});

it("sends PageUp/PageDown instead of SGR reports when the pane app has mouse tracking off", () => {
const onInput = vi.fn();
render(<XtermTerminal theme="dark" onReady={(terminal) => terminal.onUserInput(onInput)} />);
state.lastTerminal!.modes.mouseTrackingMode = "none";

// A keyboard-scroll TUI: one page key per notch regardless of line count,
// so 3 lines up => a single PageUp.
expect(state.lastTerminal!.wheelHandler!({ deltaY: -50 } as WheelEvent)).toBe(false);
expect(onInput).toHaveBeenLastCalledWith("\x1b[5~", "wheel");

expect(state.lastTerminal!.wheelHandler!({ deltaY: 20 } as WheelEvent)).toBe(false);
expect(onInput).toHaveBeenLastCalledWith("\x1b[6~", "wheel");
});

it("sends PageUp/PageDown on Windows even when the pane app tracks the mouse (conpty, no mux)", () => {
setNavigatorPlatform("Win32");
const onInput = vi.fn();
render(<XtermTerminal theme="dark" onReady={(terminal) => terminal.onUserInput(onInput)} />);
// opencode enables full mouse tracking but scrolls its transcript only by
// keyboard; with no mux to consume SGR reports, Windows must use page keys.
state.lastTerminal!.modes.mouseTrackingMode = "any";

expect(state.lastTerminal!.wheelHandler!({ deltaY: -50 } as WheelEvent)).toBe(false);
expect(onInput).toHaveBeenLastCalledWith("\x1b[5~", "wheel");

expect(state.lastTerminal!.wheelHandler!({ deltaY: 20 } as WheelEvent)).toBe(false);
expect(onInput).toHaveBeenLastCalledWith("\x1b[6~", "wheel");
});

it("sends PageUp/PageDown for keyboard-scroll panes even under a mux (opencode on macOS/Linux)", () => {
const onInput = vi.fn();
render(<XtermTerminal theme="dark" paneScrollsByKeyboard onReady={(terminal) => terminal.onUserInput(onInput)} />);
// Linux (beforeEach) + mouse tracking on: without the paneScrollsByKeyboard
// hint this would send SGR reports; the hint forces page keys.
state.lastTerminal!.modes.mouseTrackingMode = "any";

expect(state.lastTerminal!.wheelHandler!({ deltaY: -50 } as WheelEvent)).toBe(false);
expect(onInput).toHaveBeenLastCalledWith("\x1b[5~", "wheel");
});

it("opens terminal links via window.open so Electron routes them to the OS browser", () => {
const open = vi.spyOn(window, "open").mockReturnValue(null);
render(<XtermTerminal theme="dark" />);
Expand Down
32 changes: 32 additions & 0 deletions frontend/src/renderer/components/XtermTerminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ export type XtermTerminalProps = {
className?: string;
fontSize?: number;
theme: Theme;
/**
* The pane app scrolls its transcript by keyboard (PageUp/PageDown) rather
* than acting on SGR wheel reports — e.g. opencode, which enables mouse
* tracking but never scrolls on wheel reports. Routes the wheel to page keys
* on every platform (see the wheel handler), fixing it under a mux too.
*/
paneScrollsByKeyboard?: boolean;
/** Terminal construction failed; the owner decides how to surface it. */
onError?: (error: unknown) => void;
/**
Expand Down Expand Up @@ -176,6 +183,16 @@ function sgrWheelReport(button: number, count: number): string {
return `\x1b[<${button};1;1M`.repeat(count);
}

// PageUp (CSI 5~) / PageDown (CSI 6~) for pane apps that scroll their transcript
// by keyboard rather than mouse reports. One page key per wheel notch: a page
// already scrolls a full screen, so scaling by line count would over-scroll.
const PAGE_UP = "\x1b[5~";
const PAGE_DOWN = "\x1b[6~";

function pageKeyReport(lines: number): string {
return lines < 0 ? PAGE_UP : PAGE_DOWN;
}

function forceSelectionMode(term: Terminal): void {
const internal = term as XtermInternal;
const selectionService = internal._core?._selectionService;
Expand Down Expand Up @@ -486,6 +503,21 @@ export function XtermTerminal(props: XtermTerminalProps) {
wheelAccumPx -= lines * rowHeight;
}
if (lines === 0) return false;
// The SGR wheel path exists to drive tmux/zellij copy-mode on
// macOS/Linux. It cannot scroll a full-screen TUI that keeps its own
// transcript and only scrolls on PageUp/PageDown (opencode): the report
// is either consumed by the mux or handed to an app that ignores it.
// Send page keys for such apps (paneScrollsByKeyboard), on Windows
// (conpty has no mux, so SGR reaches the app and is ignored), and for
// any pane app with mouse tracking fully off.
if (
callbacksRef.current.paneScrollsByKeyboard ||
isWindowsPlatform() ||
term.modes.mouseTrackingMode === "none"
) {
emitUserInput(pageKeyReport(lines), "wheel");
return false;
}
const button = lines < 0 ? SGR_WHEEL_UP : SGR_WHEEL_DOWN;
emitUserInput(sgrWheelReport(button, Math.abs(lines)), "wheel");
return false;
Expand Down
Loading