From 7b79d4207c30a5cef77f48f5e0549b42adaa4985 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 16 Jul 2026 09:33:39 +0200 Subject: [PATCH] fix(terminal): restore Claude scroll-back on macOS trackpads (#154) Deterministic claude --version probe seeds cliVersion so wheel-forwarding to Claude's transcript engages (banner scrape was unreliable on 2.1.187+ and resumed sessions). Shift+wheel reads the dominant axis so a trackpad's horizontal Shift-scroll reaches local scrollback. New per-device "Wheel Scrolls Local History" opt-out. Wheel reports use a fire-and-forget send path so they no longer flicker the pending-bytes indicator. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 10 ++++++++ CLAUDE.md | 2 +- package-lock.json | 4 +-- package.json | 2 +- src/session.ts | 22 ++++++++++++++++ src/utils/claude-cli-resolver.ts | 42 +++++++++++++++++++++++++++++- src/utils/index.ts | 2 +- src/web/public/app.js | 33 ++++++++++++++++++++++++ src/web/public/index.html | 10 ++++++++ src/web/public/settings-ui.js | 6 +++++ src/web/public/terminal-ui.js | 37 +++++++++++++++++++++------ test/terminal-touch-tap.test.ts | 44 +++++++++++++++++++++++++++++++- 12 files changed, 199 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bee46e45..978b55e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # aicodeman +## 1.3.3 + +### Patch Changes + +- Fix terminal scroll-back in Claude sessions, especially on macOS trackpads (#154). + - **Deterministic CLI version detection.** `cliVersion` was often `undefined` because it was scraped from the `Claude Code vX.Y.Z` startup banner, which newer Claude Code builds (2.1.187+) don't reliably print and resumed sessions never show. With the version unknown, wheel-forwarding to Claude's transcript was silently disabled — and since repaint-mode Claude keeps no local terminal scrollback, scrolling up reached nothing. A new `getClaudeCliVersion()` probe (`claude --version`, cached, local-only) seeds the version at session start so forwarding engages. Restored sessions pick it up on restart. + - **Trackpad Shift+scroll.** The wheel handler now reads the dominant axis, so a macOS trackpad's Shift+two-finger scroll — which the browser reports as horizontal `deltaX` — reaches xterm's local scrollback instead of collapsing to a fixed one line per tick. + - **Opt-out setting.** New per-device App Settings → Input → "Wheel Scrolls Local History" (default off) pins the plain wheel to local scrollback (the pre-#144 behavior) for shell and other non-repaint sessions. + - **No more "queued bytes" flicker on scroll.** Wheel-scroll reports now use a fire-and-forget send path (seq-less input frame) instead of the durable exactly-once input queue, so they no longer appear in the pending-bytes connection indicator or churn localStorage. Keystrokes, taps, and clicks still use the durable queue. + ## 1.3.2 ### Patch Changes diff --git a/CLAUDE.md b/CLAUDE.md index 5be58caa..52c2eb0c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,7 @@ When user says "COM": CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed. -**Version**: 1.3.2 (must match `package.json`) +**Version**: 1.3.3 (must match `package.json`) ## Project Overview diff --git a/package-lock.json b/package-lock.json index c5282875..e9d506bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aicodeman", - "version": "1.3.2", + "version": "1.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aicodeman", - "version": "1.3.2", + "version": "1.3.3", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 54576db5..72351ab7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aicodeman", - "version": "1.3.2", + "version": "1.3.3", "description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/src/session.ts b/src/session.ts index c5dfbc47..a509040a 100644 --- a/src/session.ts +++ b/src/session.ts @@ -62,6 +62,7 @@ import { SPINNER_PATTERN, MAX_SESSION_TOKENS, execPattern, + getClaudeCliVersion, } from './utils/index.js'; import { MAX_TERMINAL_BUFFER_SIZE, @@ -1308,6 +1309,27 @@ export class Session extends EventEmitter { `[Session] Starting interactive ${modeLabel} session` + (this._useMux ? ` (with ${this._mux!.backend})` : '') ); + // Seed the CLI version deterministically for LOCAL Claude sessions. The + // banner scrape in parseClaudeCodeInfo() is unreliable — newer Claude Code + // builds don't print "Claude Code vX.Y.Z" at startup and resumed sessions + // never show it — which left cliVersion undefined and silently disabled + // wheel-forwarding to Claude's own transcript (the only route to history in + // repaint/alt-screen mode; issue #154). Remote sessions run claude on + // another host, so a local probe wouldn't reflect their version — skip them + // and let the banner scrape handle those. Cached process-wide, best-effort. + if (this.mode === 'claude' && !this._remote && !this._cliVersion) { + const probedVersion = getClaudeCliVersion(); + if (probedVersion) { + this._cliVersion = probedVersion; + this.emit('cliInfoUpdated', { + version: this._cliVersion, + model: this._cliModel, + accountType: this._cliAccountType, + latestVersion: this._cliLatestVersion, + }); + } + } + // If mux wrapping is enabled, create or attach to a mux session if (this._useMux && this._mux) { try { diff --git a/src/utils/claude-cli-resolver.ts b/src/utils/claude-cli-resolver.ts index 575fd9ff..398680d2 100644 --- a/src/utils/claude-cli-resolver.ts +++ b/src/utils/claude-cli-resolver.ts @@ -8,7 +8,7 @@ * @module utils/claude-cli-resolver */ -import { execSync } from 'node:child_process'; +import { execSync, execFileSync } from 'node:child_process'; import { existsSync } from 'node:fs'; import { delimiter, dirname, join } from 'node:path'; import { homedir } from 'node:os'; @@ -83,3 +83,43 @@ export function getAugmentedPath(): string { _augmentedPath = currentPath; return _augmentedPath; } + +/** Cached `claude --version` result: string = version, null = probed but unavailable, undefined = not probed */ +let _claudeVersion: string | null | undefined = undefined; + +/** + * Returns the installed Claude CLI version (e.g. `"2.1.210"`), or null if it + * can't be determined. Runs `claude --version` once and caches the result. + * + * This is a deterministic alternative to scraping the interactive startup + * banner (`parseClaudeCodeInfo` in session.ts): newer Claude Code builds don't + * reliably print `Claude Code vX.Y.Z` at startup, and resumed sessions never + * show it, which left `cliVersion` undefined and silently disabled features + * gated on it (e.g. wheel-forwarding to Claude's transcript — issue #154). + */ +export function getClaudeCliVersion(): string | null { + if (_claudeVersion !== undefined) return _claudeVersion; + // Keep the test suite hermetic — never spawn a real `claude` subprocess under + // vitest (matches IS_TEST_MODE in tmux-manager). Tests that need a version set + // it on the session directly. + if (process.env.VITEST) { + _claudeVersion = null; + return _claudeVersion; + } + try { + const dir = findClaudeDir(); + const bin = dir ? join(dir, 'claude') : 'claude'; + // execFileSync (no shell) — the resolved path may contain spaces, and there + // is no untrusted input, but avoid a shell either way. + const out = execFileSync(bin, ['--version'], { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + env: { ...process.env, PATH: getAugmentedPath() }, + }); + const match = out.match(/(\d+\.\d+\.\d+)/); + _claudeVersion = match ? match[1] : null; + } catch { + _claudeVersion = null; + } + return _claudeVersion; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index da0babd9..82fa9a5c 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -26,7 +26,7 @@ export { isSafePushEndpoint } from './push-endpoint-validation.js'; export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-similarity.js'; export { assertNever } from './type-safety.js'; export { wrapWithNice } from './nice-wrapper.js'; -export { findClaudeDir, getAugmentedPath } from './claude-cli-resolver.js'; +export { findClaudeDir, getAugmentedPath, getClaudeCliVersion } from './claude-cli-resolver.js'; export { resolveOpenCodeDir } from './opencode-cli-resolver.js'; export { resolveCodexDir, isCodexAvailable } from './codex-cli-resolver.js'; export { resolveGeminiDir, isGeminiAvailable } from './gemini-cli-resolver.js'; diff --git a/src/web/public/app.js b/src/web/public/app.js index ea3ccd66..c27c7f48 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2369,6 +2369,39 @@ class CodemanApp { this._reliableSend(sessionId, input, opts?.useMux === true); } + /** + * Fire-and-forget input for EPHEMERAL, loss-tolerant streams (e.g. wheel-scroll + * reports). Unlike _sendInputAsync, this never enters the durable seq/ACK queue, + * so it isn't persisted, retried, or counted in the pending-bytes connection + * indicator (which was flickering "11b/22b queued" on every scroll tick). A + * dropped scroll tick is harmless; keystrokes still go through _sendInputAsync. + * The server applies a seq-less {t:'i'} frame / seq-less POST unconditionally + * and sends no ACK (ws-routes.ts, session-routes input handler). + */ + _sendInputEphemeral(sessionId, input) { + if (!sessionId || !input) return; + if (this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId) { + try { + this._ws.send(JSON.stringify({ t: 'i', d: input })); + return; + } catch { + // socket died mid-send — fall through to a best-effort POST + } + } + // No usable WS for this session: best-effort POST, not queued. Dropped on + // failure — a scroll tick lost while offline needs no recovery. + try { + fetch(`/api/sessions/${sessionId}/input`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ input }), + keepalive: true, + }).catch(() => {}); + } catch { + // ignore — loss-tolerant + } + } + /** Record one input frame and kick delivery. The record lives until ACKed. */ _reliableSend(sessionId, data, useMux) { const seq = this._nextSeq(sessionId); diff --git a/src/web/public/index.html b/src/web/public/index.html index 97eb6d1b..362e9a07 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1179,6 +1179,16 @@
Input
+
+
+ Wheel Scrolls Local History + Plain wheel/trackpad pages the terminal scrollback +
+ +
Local Echo diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index e0562c3a..8341d23b 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -337,6 +337,8 @@ Object.assign(CodemanApp.prototype, { document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false; this.loadTunnelStatus(); document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice(); + document.getElementById('appSettingsTerminalWheelLocal').checked = + settings.terminalWheelLocalScrollback ?? defaults.terminalWheelLocalScrollback ?? false; document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false; document.getElementById('appSettingsExtendedKeyboardBar').checked = settings.extendedKeyboardBar ?? false; document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false; @@ -1436,6 +1438,7 @@ Object.assign(CodemanApp.prototype, { imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked, tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked, localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked, + terminalWheelLocalScrollback: document.getElementById('appSettingsTerminalWheelLocal').checked, cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked, webglRendererEnabled: document.getElementById('appSettingsWebglRenderer').checked, extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked, @@ -1609,6 +1612,7 @@ Object.assign(CodemanApp.prototype, { showPlanUsageLimits: _pul, showAttachmentsButton: _ahb, webglRendererEnabled: _wgl, + terminalWheelLocalScrollback: _twls, ...serverSettings } = settings; try { @@ -1775,6 +1779,7 @@ Object.assign(CodemanApp.prototype, { ralphTrackerEnabled: false, tabTwoRows: false, cjkInputEnabled: false, + terminalWheelLocalScrollback: false, // mobile scrolls via touch, not wheel webglRendererEnabled: false, // mobile always uses the DOM renderer skin: 'daylight-blue', }; @@ -2149,6 +2154,7 @@ Object.assign(CodemanApp.prototype, { 'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents', 'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar', 'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'webglRendererEnabled', + 'terminalWheelLocalScrollback', ]); // The plan-usage chip is a PER-DEVICE display setting (default OFF): desktop // can show it while mobile stays hidden. It used to sync, so an older diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 53b54bfa..05d2fead 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -385,7 +385,7 @@ Object.assign(CodemanApp.prototype, { 'wheel', (ev) => { ev.preventDefault(); - const lines = Math.round(ev.deltaY / 25) || (ev.deltaY > 0 ? 1 : -1); + const lines = this._wheelScrollLines(ev); if (this._shouldForwardWheelToApp(ev)) { this._sendSyntheticSgrWheel(ev.clientX, ev.clientY, lines); return; @@ -2520,8 +2520,25 @@ Object.assign(CodemanApp.prototype, { // unknown version is treated as older). Gemini is a strip mode too but its // wheel behavior is unverified, so it keeps the local wheel — taps/clicks // are still forwarded for it (harmless no-ops at worst). + // Wheel delta → whole scroll lines. macOS trackpads turn Shift+two-finger + // scroll into a HORIZONTAL wheel (deltaY≈0, deltaX carries the magnitude), and + // Shift routes the wheel to local scrollback (_shouldForwardWheelToApp returns + // false on Shift). So under Shift, read whichever axis dominates — otherwise + // deltaY≈0 collapses to a fixed ±1 line/tick and the gesture can't page through + // history on a trackpad (issue #154). Non-Shift and mouse-wheel paths are + // unchanged (they carry deltaY). The `|| ±1` keeps sub-25px deltas moving. + _wheelScrollLines(ev) { + const delta = ev.shiftKey && Math.abs(ev.deltaX) > Math.abs(ev.deltaY) ? ev.deltaX : ev.deltaY; + return Math.round(delta / 25) || (delta > 0 ? 1 : -1); + }, + _shouldForwardWheelToApp(ev) { if (ev.shiftKey) return false; + // Opt-out (App Settings → Input → "Wheel scrolls local history"): pin the + // plain wheel to xterm's own scrollback like pre-#144, for users who prefer + // it over forwarding the wheel to the CLI's transcript (issue #154). Cheap — + // loadAppSettingsFromStorage() is cache-backed. + if (this.loadAppSettingsFromStorage?.()?.terminalWheelLocalScrollback) return false; const mode = this.terminal?.modes?.mouseTrackingMode; if (mode && mode !== 'none') return false; const session = this.sessions?.get(this.activeSessionId); @@ -2535,12 +2552,13 @@ Object.assign(CodemanApp.prototype, { }, // Encode wheel ticks as SGR reports (button 64 = up, 65 = down) at the pointer - // cell. Reports are coalesced into one PTY write per ~40ms: a trackpad emits - // dozens of wheel events per second and each _sendInputAsync becomes a tmux - // send-keys on the server — unbatched, a single flick would spawn a process - // storm. Per-event tick count is capped (Claude applies its own scroll-speed - // multiplier and acceleration on top), and the queue is bounded so a wild - // scroll can't build a backlog that keeps scrolling after the finger stops. + // cell. Reports are coalesced into one fire-and-forget write per ~40ms: a + // trackpad emits dozens of wheel events per second and each send becomes a + // tmux send-keys on the server — unbatched, a single flick would spawn a + // process storm. Per-event tick count is capped (Claude applies its own + // scroll-speed multiplier and acceleration on top), and the queue is bounded + // so a wild scroll can't build a backlog that keeps scrolling after the finger + // stops. Flushed via _sendInputEphemeral — loss-tolerant, off the durable queue. _sendSyntheticSgrWheel(clientX, clientY, lines) { if (!this.activeSessionId || !lines) return; const pos = this._clientPointToCell(clientX, clientY); @@ -2558,7 +2576,10 @@ Object.assign(CodemanApp.prototype, { this._wheelSgrFlushTimer = null; const data = this._wheelSgrQueue; this._wheelSgrQueue = ''; - if (data && this.activeSessionId) this._sendInputAsync(this.activeSessionId, data); + // Ephemeral (fire-and-forget): wheel reports are loss-tolerant, so they skip + // the durable seq/ACK queue — no localStorage churn, no "Nb queued" flicker + // in the connection indicator on every scroll tick. + if (data && this.activeSessionId) this._sendInputEphemeral(this.activeSessionId, data); }, // Desktop counterpart of the touchend tap branch: hand-encode an SGR report diff --git a/test/terminal-touch-tap.test.ts b/test/terminal-touch-tap.test.ts index 49862e74..7c4ad557 100644 --- a/test/terminal-touch-tap.test.ts +++ b/test/terminal-touch-tap.test.ts @@ -370,12 +370,54 @@ describe('terminal touch tap mouse guard', () => { expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); }); + it('wheel: the local-scrollback opt-out pins the plain wheel to local scrollback (issue #154)', () => { + const { app } = loadTerminalUiHarness(); + app.activeSessionId = 'sess-1'; + app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.187' }]]); + app.terminal = { + modes: { mouseTrackingMode: 'none' }, + buffer: { active: { viewportY: 50, baseY: 50 } }, + }; + + // Default (setting absent) forwards the plain wheel to the CLI transcript. + expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true); + + // Opt-out ON → the plain wheel stays on xterm's own scrollback (pre-#144). + app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true }); + expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); + + // OFF again → forwarding resumes. + app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: false }); + expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true); + }); + + it('wheel: reads the dominant axis under Shift so a macOS trackpad can page scrollback (issue #154)', () => { + const { app } = loadTerminalUiHarness(); + + // Plain vertical wheel: unchanged, driven by deltaY. + expect(app._wheelScrollLines({ shiftKey: false, deltaX: 0, deltaY: 100 })).toBe(4); + expect(app._wheelScrollLines({ shiftKey: false, deltaX: 0, deltaY: -50 })).toBe(-2); + + // Shift on a macOS trackpad: deltaY≈0, deltaX carries direction+magnitude. + // Old code collapsed this to a fixed -1; now it tracks the horizontal delta. + expect(app._wheelScrollLines({ shiftKey: true, deltaX: -100, deltaY: 0 })).toBe(-4); // scroll up + expect(app._wheelScrollLines({ shiftKey: true, deltaX: 75, deltaY: 0 })).toBe(3); // scroll down + + // Shift with a real vertical wheel (mouse): deltaY dominates, deltaX ignored. + expect(app._wheelScrollLines({ shiftKey: true, deltaX: 2, deltaY: 100 })).toBe(4); + + // Sub-25px delta still nudges one line in the gesture's direction. + expect(app._wheelScrollLines({ shiftKey: true, deltaX: -5, deltaY: 0 })).toBe(-1); + }); + it('wheel: encodes SGR 64/65 ticks, caps per event, and coalesces into one flush', () => { const { app } = loadTerminalUiHarness(); const sent: Array<{ id: string; data: string }> = []; app.activeSessionId = 'sess-1'; app.sessions = new Map([['sess-1', { mode: 'claude' }]]); - app._sendInputAsync = (id: string, data: string) => sent.push({ id, data }); + // Wheel reports flush via the ephemeral (fire-and-forget) path, not the + // durable queue — so they never show in the pending-bytes indicator (#154). + app._sendInputEphemeral = (id: string, data: string) => sent.push({ id, data }); app.terminal = { cols: 80, rows: 24,