diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 835440a01..334c6873a 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -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/. Keeps the app's entire footprint diff --git a/frontend/src/renderer/components/TerminalPane.tsx b/frontend/src/renderer/components/TerminalPane.tsx index 5e61254d0..7d009b617 100644 --- a/frontend/src/renderer/components/TerminalPane.tsx +++ b/frontend/src/renderer/components/TerminalPane.tsx @@ -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"}`; @@ -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"; @@ -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 && ( diff --git a/frontend/src/renderer/components/XtermTerminal.test.tsx b/frontend/src/renderer/components/XtermTerminal.test.tsx index 59892b552..affdc8f10 100644 --- a/frontend/src/renderer/components/XtermTerminal.test.tsx +++ b/frontend/src/renderer/components/XtermTerminal.test.tsx @@ -9,7 +9,7 @@ const state = vi.hoisted(() => ({ wheelHandler?: (event: WheelEvent) => boolean; selection: string; options: Record; - modes: { bracketedPasteMode: boolean }; + modes: { bracketedPasteMode: boolean; mouseTrackingMode: string }; dataListeners: Set<(data: string) => void>; keyListeners: Set<(event: { key: string }) => void>; selectionListeners: Set<() => void>; @@ -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>(); @@ -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( 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( 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( 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(); diff --git a/frontend/src/renderer/components/XtermTerminal.tsx b/frontend/src/renderer/components/XtermTerminal.tsx index 48de2c8df..102a642b6 100644 --- a/frontend/src/renderer/components/XtermTerminal.tsx +++ b/frontend/src/renderer/components/XtermTerminal.tsx @@ -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; /** @@ -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; @@ -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;