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% {