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
21 changes: 20 additions & 1 deletion frontend/src/renderer/components/TerminalPane.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => <div data-testid="xterm" />,
Expand Down Expand Up @@ -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);
});
});
11 changes: 9 additions & 2 deletions frontend/src/renderer/components/TerminalPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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…";
Expand Down Expand Up @@ -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 && (
Expand Down
41 changes: 31 additions & 10 deletions frontend/src/renderer/components/XtermTerminal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ const state = vi.hoisted(() => ({
selection: string;
options: Record<string, unknown>;
modes: { bracketedPasteMode: boolean; mouseTrackingMode: string };
buffer: { active: { type: string } };
scrollLines: ReturnType<typeof vi.fn>;
dataListeners: Set<(data: string) => void>;
keyListeners: Set<(event: { key: string }) => void>;
selectionListeners: Set<() => void>;
Expand All @@ -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>();
Expand Down Expand Up @@ -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(<XtermTerminal theme="dark" onReady={(terminal) => 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(<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";
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(<XtermTerminal theme="dark" onReady={(terminal) => 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(<XtermTerminal theme="dark" paneScrollsByKeyboard onReady={(terminal) => terminal.onUserInput(onInput)} />);
Expand Down
66 changes: 38 additions & 28 deletions frontend/src/renderer/components/XtermTerminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) => {
Expand Down
21 changes: 13 additions & 8 deletions frontend/src/renderer/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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% {
Expand Down
Loading