diff --git a/frontend/src/renderer/components/TerminalPane.test.tsx b/frontend/src/renderer/components/TerminalPane.test.tsx index 5be8fb7c2a..c418951539 100644 --- a/frontend/src/renderer/components/TerminalPane.test.tsx +++ b/frontend/src/renderer/components/TerminalPane.test.tsx @@ -2,7 +2,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import type { WorkspaceSession } from "../types/workspace"; -import { TerminalPane } from "./TerminalPane"; +import { TerminalPane, providerScrollsByKeyboard } from "./TerminalPane"; vi.mock("./XtermTerminal", () => ({ XtermTerminal: () =>
, @@ -94,3 +94,22 @@ describe("TerminalPane empty states", () => { } }); }); + +describe("providerScrollsByKeyboard", () => { + // opencode and its fork kilocode share a TUI that scrolls its own transcript + // by keyboard and ignores SGR wheel reports, so both must opt into the + // PageUp/PageDown wheel routing (see XtermTerminal's paneScrollsByKeyboard). + it("is true for keyboard-scroll TUIs (opencode and its kilocode fork)", () => { + expect(providerScrollsByKeyboard("opencode")).toBe(true); + expect(providerScrollsByKeyboard("kilocode")).toBe(true); + }); + + it("is false for mouse-report/native-scroll providers", () => { + expect(providerScrollsByKeyboard("codex")).toBe(false); + expect(providerScrollsByKeyboard("claude-code")).toBe(false); + }); + + it("is false when the provider is unknown", () => { + expect(providerScrollsByKeyboard(undefined)).toBe(false); + }); +}); diff --git a/frontend/src/renderer/components/TerminalPane.tsx b/frontend/src/renderer/components/TerminalPane.tsx index 126dc858b0..9969dc530f 100644 --- a/frontend/src/renderer/components/TerminalPane.tsx +++ b/frontend/src/renderer/components/TerminalPane.tsx @@ -120,7 +120,14 @@ function reviewerPreviewLines(session: WorkspaceSession | undefined): string[] { // 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"]); +// kilocode is a fork of opencode and shares its TUI surface, so it scrolls the +// same way. +const KEYBOARD_SCROLL_PROVIDERS = new Set(["opencode", "kilocode"]); + +// Whether the given provider's TUI is one of the keyboard-scroll agents above. +export function providerScrollsByKeyboard(provider?: string): boolean { + return provider ? KEYBOARD_SCROLL_PROVIDERS.has(provider) : false; +} function bannerText(state: TerminalSessionState, error?: string): string | undefined { if (state === "reattaching") return "Terminal disconnected — reattaching…"; @@ -231,7 +238,7 @@ function AttachedTerminal({ session, theme, daemonReady, terminalTarget, fontSiz fontSize={fontSize} onError={handleInitError} onReady={handleReady} - paneScrollsByKeyboard={provider ? KEYBOARD_SCROLL_PROVIDERS.has(provider) : false} + paneScrollsByKeyboard={providerScrollsByKeyboard(provider)} theme={theme} /> {showEmptyState && ( diff --git a/frontend/src/renderer/components/XtermTerminal.test.tsx b/frontend/src/renderer/components/XtermTerminal.test.tsx index affdc8f106..4d62b7124c 100644 --- a/frontend/src/renderer/components/XtermTerminal.test.tsx +++ b/frontend/src/renderer/components/XtermTerminal.test.tsx @@ -10,6 +10,8 @@ const state = vi.hoisted(() => ({ selection: string; options: Record; modes: { bracketedPasteMode: boolean; mouseTrackingMode: string }; + buffer: { active: { type: string } }; + scrollLines: ReturnType; dataListeners: Set<(data: string) => void>; keyListeners: Set<(event: { key: string }) => void>; selectionListeners: Set<() => void>; @@ -32,6 +34,8 @@ vi.mock("@xterm/xterm", () => ({ keyHandler?: (event: KeyboardEvent) => boolean; wheelHandler?: (event: WheelEvent) => boolean; modes = { bracketedPasteMode: false, mouseTrackingMode: "vt200" }; + buffer = { active: { type: "normal" } }; + scrollLines = vi.fn(); dataListeners = new Set<(data: string) => void>(); keyListeners = new Set<(event: { key: string }) => void>(); selectionListeners = new Set<() => void>(); @@ -492,35 +496,52 @@ describe("XtermTerminal", () => { expect(onInput).not.toHaveBeenCalled(); }); - it("sends PageUp/PageDown instead of SGR reports when the pane app has mouse tracking off", () => { + it("scrolls xterm's own viewport for normal-buffer panes with mouse tracking off (codex, plain shell)", () => { const onInput = vi.fn(); render( terminal.onUserInput(onInput)} />); state.lastTerminal!.modes.mouseTrackingMode = "none"; + state.lastTerminal!.buffer.active.type = "normal"; - // A keyboard-scroll TUI: one page key per notch regardless of line count, - // so 3 lines up => a single PageUp. + // rowHeight = 16.2px; -50px => 3 lines up. The pane never sees these bytes; + // we scroll the terminal's retained scrollback locally instead. expect(state.lastTerminal!.wheelHandler!({ deltaY: -50 } as WheelEvent)).toBe(false); - expect(onInput).toHaveBeenLastCalledWith("\x1b[5~", "wheel"); + expect(state.lastTerminal!.scrollLines).toHaveBeenLastCalledWith(-3); + expect(onInput).not.toHaveBeenCalled(); expect(state.lastTerminal!.wheelHandler!({ deltaY: 20 } as WheelEvent)).toBe(false); - expect(onInput).toHaveBeenLastCalledWith("\x1b[6~", "wheel"); + expect(state.lastTerminal!.scrollLines).toHaveBeenLastCalledWith(1); + expect(onInput).not.toHaveBeenCalled(); }); - it("sends PageUp/PageDown on Windows even when the pane app tracks the mouse (conpty, no mux)", () => { - setNavigatorPlatform("Win32"); + it("falls back to PageUp/PageDown for alt-buffer panes with mouse tracking off", () => { 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"; + state.lastTerminal!.modes.mouseTrackingMode = "none"; + // Alt buffer: no local scrollback to move, and no keyboard-scroll hint, so a + // page key per notch is the best fallback. + state.lastTerminal!.buffer.active.type = "alternate"; expect(state.lastTerminal!.wheelHandler!({ deltaY: -50 } as WheelEvent)).toBe(false); expect(onInput).toHaveBeenLastCalledWith("\x1b[5~", "wheel"); + expect(state.lastTerminal!.scrollLines).not.toHaveBeenCalled(); expect(state.lastTerminal!.wheelHandler!({ deltaY: 20 } as WheelEvent)).toBe(false); expect(onInput).toHaveBeenLastCalledWith("\x1b[6~", "wheel"); }); + it("sends SGR reports on Windows when the pane tracks the mouse (conpty delivers them to the app)", () => { + setNavigatorPlatform("Win32"); + const onInput = vi.fn(); + render( terminal.onUserInput(onInput)} />); + // A mouse-tracking pane gets SGR reports on every platform; on Windows conpty + // forwards them straight to the app. Keyboard-scroll panes (opencode) opt out + // via the paneScrollsByKeyboard hint, tested separately. + state.lastTerminal!.modes.mouseTrackingMode = "any"; + + expect(state.lastTerminal!.wheelHandler!({ deltaY: -50 } as WheelEvent)).toBe(false); + expect(onInput).toHaveBeenLastCalledWith("\x1b[<64;1;1M".repeat(3), "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)} />); diff --git a/frontend/src/renderer/components/XtermTerminal.tsx b/frontend/src/renderer/components/XtermTerminal.tsx index 102a642b65..82a89fc245 100644 --- a/frontend/src/renderer/components/XtermTerminal.tsx +++ b/frontend/src/renderer/components/XtermTerminal.tsx @@ -169,13 +169,12 @@ type XtermInternal = Terminal & { }; }; -// We never scroll locally (scrollback:0). Instead we synthesize SGR mouse-wheel -// reports and write them to the pane; tmux (with `mouse on`, set by the runtime -// adapter) acts on them and scrolls its scrollback via copy-mode. With -// scrollback:0 xterm would otherwise convert the wheel into cursor-arrow keys -// (its alt-buffer fallback), which move the agent's cursor rather than scrolling. -// SGR button 64 = wheel up, 65 = down; reports are 1-based and a single cell is -// enough for a borderless single pane. +// For mouse-tracking panes we synthesize SGR mouse-wheel reports and write them +// to the pane; tmux (with `mouse on`, set by the runtime adapter) acts on them +// and scrolls its scrollback via copy-mode. Left to itself xterm would convert +// the wheel into cursor-arrow keys (its alt-buffer fallback), which move the +// agent's cursor rather than scrolling. 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; @@ -258,13 +257,14 @@ export function XtermTerminal(props: XtermTerminalProps) { // background, the way VS Code's terminal does; without it dim colors // render washed out. minimumContrastRatio: 4.5, - // The pane PTY runs a full-screen alt-buffer app (tmux attach) that - // owns scrollback itself, so xterm's own buffer never accumulates - // history (the alt screen doesn't feed scrollback) and wheel events - // are forwarded as mouse reports instead of scrolling locally. 0 also - // stops FitAddon reserving ~14px on the right for a scrollbar that can - // never appear. - scrollback: 0, + // Alt-buffer panes (tmux attach, mouse-tracking agent TUIs) never feed + // this buffer — the alt screen doesn't accumulate scrollback — so this + // only matters for normal-buffer panes that print their transcript and + // rely on the terminal's scrollback (codex, a plain shell). Keep it > 0 + // so that history survives to be scrolled locally (see the wheel + // handler's normal-buffer branch). The scrollbar itself is hidden in + // CSS so FitAddon's ~14px reservation doesn't shift the grid. + scrollback: 5000, theme: props.theme === "dark" ? terminalThemes.dark : terminalThemes.light, }); } catch (error) { @@ -503,23 +503,33 @@ 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" - ) { + // A full-screen TUI that keeps its own transcript and scrolls it only by + // keyboard (opencode) ignores wheel/mouse reports on every platform; route + // its wheel to page keys. Kept first so opencode is unaffected by the + // buffer-aware paths below. + if (callbacksRef.current.paneScrollsByKeyboard) { emitUserInput(pageKeyReport(lines), "wheel"); return false; } - const button = lines < 0 ? SGR_WHEEL_UP : SGR_WHEEL_DOWN; - emitUserInput(sgrWheelReport(button, Math.abs(lines)), "wheel"); + // A normal-buffer pane with mouse tracking off (codex, a plain shell) + // prints its transcript and relies on the terminal's own scrollback — the + // way it scrolls in a raw terminal. Scroll xterm's viewport locally; the + // pane never sees these bytes. Requires scrollback > 0 (see Terminal opts). + if (term.modes.mouseTrackingMode === "none" && term.buffer.active.type === "normal") { + term.scrollLines(lines); + return false; + } + // Mouse tracking on: the pane (tmux/zellij copy-mode, or any app that + // tracks the mouse) acts on SGR wheel reports. On Windows conpty this + // reaches the app directly; under a mux it drives copy-mode. + if (term.modes.mouseTrackingMode !== "none") { + const button = lines < 0 ? SGR_WHEEL_UP : SGR_WHEEL_DOWN; + emitUserInput(sgrWheelReport(button, Math.abs(lines)), "wheel"); + return false; + } + // Alt-buffer pane with mouse tracking off and no keyboard-scroll hint: + // no scrollback to move locally, so fall back to page keys. + emitUserInput(pageKeyReport(lines), "wheel"); return false; }); const pasteInput = (event: ClipboardEvent) => { diff --git a/frontend/src/renderer/styles.css b/frontend/src/renderer/styles.css index a8172d8560..d318741a93 100644 --- a/frontend/src/renderer/styles.css +++ b/frontend/src/renderer/styles.css @@ -233,6 +233,19 @@ select { height: 100%; } +/* Normal-buffer panes (codex, a plain shell) now keep scrollback (scrollback:5000 + in XtermTerminal), so the viewport becomes scrollable. Hide the scrollbar so it + doesn't reserve width and shift the cell grid; the wheel still scrolls via the + terminal's wheel handler. */ +.xterm .xterm-viewport { + scrollbar-width: none; +} + +.xterm .xterm-viewport::-webkit-scrollbar { + width: 0; + height: 0; +} + .terminal-pane-frame:fullscreen { background: var(--bg); } @@ -335,14 +348,6 @@ select { color: var(--fg-muted); } -:root[data-theme="light"] .xterm .xterm-viewport::-webkit-scrollbar-thumb { - background: rgb(0 0 0 / 0.12); -} - -:root[data-theme="light"] .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb { - background: rgb(0 0 0 / 0.2); -} - @keyframes status-pulse { 0%, 100% {