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,