From faa96d1964a6eafb42b7bd4ccbbd3f3eddb6e76a Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Mon, 2 Nov 2026 15:32:46 +0700 Subject: [PATCH] fix(terminal): Claude panes scroll 5 lines per wheel notch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fullscreen Claude Code scrolls one line per wheel report when its terminal does not answer XTVERSION as xterm.js (behind tmux it never does), and xterm.js sends one report per notch — so a notch moved a single line. spawnRun now sets CLAUDE_CODE_SCROLL_SPEED=5 unless the dashboard's env already sets it; settings.json env and /config Scroll speed still win. --- docs/API.md | 2 +- server/__tests__/lane-sources.test.js | 3 +++ server/lib/pty-run.js | 11 +++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/API.md b/docs/API.md index 4b2f3c00..6d201e12 100644 --- a/docs/API.md +++ b/docs/API.md @@ -3804,7 +3804,7 @@ Sent when a notification is created. #### /ws-pty/:runId — PTY frames -A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on workspace load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub workspace shell`, other tools), all receiving the same frames live-synced. **The attach URL carries the client's size** (`/ws-pty/:runId?cols=&rows=`, integers in `[2, 1000]`). A terminal with no size to give — one on a hidden tab, whose box is 0×0 — names none and reports none until it is shown, and its attach is spawned at the window's CURRENT size (`tmux.windowSize`, 80×24 only when tmux cannot answer), so a background tab never shrinks the pane (it used to fit to xterm's 12×6 minimum and resize tmux to that): tmux resizes the window to whichever client attached most recently, so spawning the attach at a default size and correcting it from the first `resize` message a beat later made the pane's program redraw its whole UI at 80 columns — and everything painted at that width stayed in the scrollback, surfacing as torn left-margin fragments the moment the user scrolled back (issue #33). Resize events after that: the client sends a `{"type":"resize","cols","rows"}` text frame and the server applies it as a `TIOCSWINSZ` ioctl down the pane's pty, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The client reports its size from **xterm's own `onResize`**, not from the container's `ResizeObserver` — cols also move when the webfont finishes loading or the page zooms, neither of which resizes the observed element — and takes the lead whenever the terminal regains focus. **Several browsers on one pane share ONE size** (`server/lib/pty-attach.js`): tmux's `window-size latest` snapped the pane to whoever typed last, so two viewers on screens of different sizes kept resizing it under each other and one saw it cropped. The server names a DRIVER per pane — the first attach, then whichever sends `{"type":"resize","cols","rows","drive":true}` (the client does on focus and on a keystroke, never on a mouse report). Every other attach is a follower: its PTY is held at the driver's size, a follower's own `resize` without `drive` is remembered but not applied, and the server tells it `{"type":"size","cols","rows"}` — the client resizes xterm to that grid and scales it down with CSS to fit, so the browser in use decides the size and the others just shrink. A follower attaches at the driver's size, so joining never resizes the pane. When the driver's socket closes the rest get `{"type":"lead"}` and size themselves again. On Windows every attach is also sent `{"type":"wheel"}`: psmux never turns mouse tracking on for its client, so the browser sends the SGR wheel report (`ESC[<64;1;1M` up, `ESC[<65;1;1M` down) itself while xterm reports no tracking — psmux scrolls, or forwards it to a pane app that tracks the mouse (Claude Code fullscreen). Driver tracking is per server process: a real terminal (`agenthub workspace shell`) or a second dashboard on the same tmux still follows tmux's own `latest` rule. Keeping the two widths equal is not cosmetic: a tmux pane **wider** than xterm makes every over-long row autowrap its tail onto the next row's left margin, and tmux never repaints a row it believes unchanged — so stray characters stay glued to the left edge of blank lines, visible as soon as a scroll repaints history. A zero size (the console collapses with CSS, leaving the container 0×0) is never reported. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended — the Workspace takes that frame as the signal to restore the run setup form, since there is nothing left to attach to. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again. +A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on workspace load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub workspace shell`, other tools), all receiving the same frames live-synced. **The attach URL carries the client's size** (`/ws-pty/:runId?cols=&rows=`, integers in `[2, 1000]`). A terminal with no size to give — one on a hidden tab, whose box is 0×0 — names none and reports none until it is shown, and its attach is spawned at the window's CURRENT size (`tmux.windowSize`, 80×24 only when tmux cannot answer), so a background tab never shrinks the pane (it used to fit to xterm's 12×6 minimum and resize tmux to that): tmux resizes the window to whichever client attached most recently, so spawning the attach at a default size and correcting it from the first `resize` message a beat later made the pane's program redraw its whole UI at 80 columns — and everything painted at that width stayed in the scrollback, surfacing as torn left-margin fragments the moment the user scrolled back (issue #33). Resize events after that: the client sends a `{"type":"resize","cols","rows"}` text frame and the server applies it as a `TIOCSWINSZ` ioctl down the pane's pty, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The client reports its size from **xterm's own `onResize`**, not from the container's `ResizeObserver` — cols also move when the webfont finishes loading or the page zooms, neither of which resizes the observed element — and takes the lead whenever the terminal regains focus. **Several browsers on one pane share ONE size** (`server/lib/pty-attach.js`): tmux's `window-size latest` snapped the pane to whoever typed last, so two viewers on screens of different sizes kept resizing it under each other and one saw it cropped. The server names a DRIVER per pane — the first attach, then whichever sends `{"type":"resize","cols","rows","drive":true}` (the client does on focus and on a keystroke, never on a mouse report). Every other attach is a follower: its PTY is held at the driver's size, a follower's own `resize` without `drive` is remembered but not applied, and the server tells it `{"type":"size","cols","rows"}` — the client resizes xterm to that grid and scales it down with CSS to fit, so the browser in use decides the size and the others just shrink. A follower attaches at the driver's size, so joining never resizes the pane. When the driver's socket closes the rest get `{"type":"lead"}` and size themselves again. On Windows every attach is also sent `{"type":"wheel"}`: psmux never turns mouse tracking on for its client, so the browser sends the SGR wheel report (`ESC[<64;1;1M` up, `ESC[<65;1;1M` down) itself while xterm reports no tracking — psmux scrolls, or forwards it to a pane app that tracks the mouse (Claude Code fullscreen). Fullscreen Claude Code scrolls ONE line per wheel report when it cannot tell its terminal is xterm.js (behind tmux it never can), and xterm.js sends one report per notch, so `spawnRun` gives every Claude pane `CLAUDE_CODE_SCROLL_SPEED=5` unless the dashboard's own env sets it; the user's `settings.json` `env` or `/config` → Scroll speed still wins. Driver tracking is per server process: a real terminal (`agenthub workspace shell`) or a second dashboard on the same tmux still follows tmux's own `latest` rule. Keeping the two widths equal is not cosmetic: a tmux pane **wider** than xterm makes every over-long row autowrap its tail onto the next row's left margin, and tmux never repaints a row it believes unchanged — so stray characters stay glued to the left edge of blank lines, visible as soon as a scroll repaints history. A zero size (the console collapses with CSS, leaving the container 0×0) is never reported. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended — the Workspace takes that frame as the signal to restore the run setup form, since there is nothing left to attach to. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again. #### cc_config_changed diff --git a/server/__tests__/lane-sources.test.js b/server/__tests__/lane-sources.test.js index f2f829ac..5c1247ad 100644 --- a/server/__tests__/lane-sources.test.js +++ b/server/__tests__/lane-sources.test.js @@ -208,6 +208,9 @@ describe("lane-sources instructions and launch", () => { // Claude Code hides its task tools from newer models without this, and the // chat Progress panel is drawn from those calls. assert.ok(newSession.includes("CLAUDE_CODE_ENABLE_TODO_TOOLS=1")); + // One wheel notch reaches claude as one report; it scrolls this many lines. + if (!process.env.CLAUDE_CODE_SCROLL_SPEED) + assert.ok(newSession.includes("CLAUDE_CODE_SCROLL_SPEED=5")); assert.deepEqual(fs.readdirSync(dir), ["CLAUDE.md"]); const text = fs.readFileSync(path.join(dir, "CLAUDE.md"), "utf8"); assert.match(text, /^- always answer in Vietnamese/); diff --git a/server/lib/pty-run.js b/server/lib/pty-run.js index 752d7956..502c0f1d 100644 --- a/server/lib/pty-run.js +++ b/server/lib/pty-run.js @@ -49,6 +49,8 @@ const RUN_ID_RE = /^agenthub-lane-(\d+)(?:-([1-9]))?$/; /** Highest pane slot a lane may use: ten sessions per workspace, 0–9 (the * Workspace groups them into tabs of up to four split panes each). */ const MAX_PANE_SLOT = 9; +// Lines a fullscreen `claude` scrolls per wheel report (see spawnRun's env). +const CLAUDE_SCROLL_SPEED = "5"; const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]); // `auto` is the CLI's own default-ish mode (`claude --permission-mode auto`): // it asks only for what its classifier reads as risky. `default` is kept for @@ -663,6 +665,15 @@ function spawnRun(args) { // (routes/sessions.js sessionOutline). A user's settings.json `env` still // wins, so `"0"` there turns them back off. ...(entry.launcher === "claude" ? { CLAUDE_CODE_ENABLE_TODO_TOOLS: "1" } : {}), + // Fullscreen claude scrolls by itself, and through tmux it cannot tell it + // runs in xterm.js (XTVERSION answers tmux), so it takes ONE line per wheel + // report — while xterm.js sends one report per notch (and damps a trackpad + // ×0.3). A notch moved a single line. CLAUDE_CODE_SCROLL_SPEED is Claude's + // own multiplier; the dashboard's env or the user's settings.json `env` + // (or /config → Scroll speed) still wins. + ...(entry.launcher === "claude" && !process.env.CLAUDE_CODE_SCROLL_SPEED + ? { CLAUDE_CODE_SCROLL_SPEED: CLAUDE_SCROLL_SPEED } + : {}), // No CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: an inline claude duplicated its // output whenever the pane narrowed (tmux re-wraps the full-width rules and // pushes lines into history its redraw cannot clear). The user's own