fix(terminal): capture remote history and preserve isolated wheel travel (#7)

This commit is contained in:
Saqeb Akhter committed 2026-10-22 02:01:17 +04:00
1 parent 131ee1a31e
commit 242bbfcd31
9 files changed
+173 -16

No files matched your search

+8
View File
@@ -89,6 +89,14 @@ export function remoteLoginShellCommand(command: string): string {
return `exec ${REMOTE_LOGIN_SHELL} -i -l -c ${shellescape(command)}`;
}
/** Standard CLI locations missing from non-interactive SSH shells (notably Homebrew). */
export const REMOTE_PATH_BOOTSTRAP =
'export PATH=$HOME/.opencode/bin:$HOME/.local/bin:$HOME/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH';
export function withRemotePathBootstrap(command: string): string {
return `${REMOTE_PATH_BOOTSTRAP}; ${command}`;
}
/**
* The CLI text a location overlay should launch for `mode`, or null when this build has no
* entry for it. `overlays.<location>.command` when the entry names one, otherwise the bare
+11 -2
View File
@@ -48,7 +48,13 @@ import { exec } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { promisify } from 'node:util';
import type { SessionRemote } from './types/session.js';
import { buildSshConnectionArgs, classifyRemoteAliveExit, remoteSshTarget, shellescape } from './remote-hosts.js';
import {
buildSshConnectionArgs,
classifyRemoteAliveExit,
remoteSshTarget,
shellescape,
withRemotePathBootstrap,
} from './remote-hosts.js';
import { RemoteSshSlotTimeoutError, runWithRemoteSshLimit } from './remote-ssh-limiter.js';
import { remoteTmuxLocation } from './tmux-manager.js';
@@ -153,7 +159,10 @@ export function buildRemoteScrollbackCommand(
marker: string
): string {
const { socket, sessionName } = remoteTmuxLocation(remote, sessionId);
const script = buildRemoteScrollbackScript(socket, sessionName, historyLines, tailBytes, marker);
// Non-interactive macOS shells omit Homebrew and other per-user CLI paths.
const script = withRemotePathBootstrap(
buildRemoteScrollbackScript(socket, sessionName, historyLines, tailBytes, marker)
);
// `exec` so a timeout kill reaches ssh itself, not only the local `sh -c`.
return ['exec', ...buildSshConnectionArgs(remote), remoteSshTarget(remote), shellescape(script)].join(' ');
}
+3 -3
View File
@@ -1074,11 +1074,11 @@
// SGR wheel forwarding, the twin of the primary pane's capture-phase wheel
// handler and _forwardScrollToApp (terminal-ui.js; keep them in step).
// Claude's fullscreen renderer (claude 2.1.187+ while its mouse tracking is
// on, cliMouseTracking) scrolls its own transcript on SGR wheel reports,
// Fullscreen Claude (2.1.187+) and measured tracked Codex scroll their own
// transcripts on SGR wheel reports while cliMouseTracking is on,
// while this xterm holds only Codeman's replayed repaint frames (tmux keeps
// no history for such a pane). Left to xterm, the wheel dragged those stale
// frames, Claude's pinned input box with them, up the tile, or scrolled
// frames, including the CLI's pinned input box, up the tile, or scrolled
// nothing at all. The gate is the primary pane's own, asked for THIS pane
// (its terminal, its session, never the active one), so the CLI rules stay
// in terminal-ui.js and this file names no CLI; the reports go to this
+12 -8
View File
@@ -137,9 +137,9 @@
return `\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks);
}
// A deliberate opening event pre-pays a tick, so a lone small mouse click
// responds immediately. The signed remainder is debt repaid by subsequent
// same-direction travel: ten 0.1-line events still total one tick. Debt and
// A deliberate opening event preserves upstream rounding and pre-pays at
// least one tick, so isolated clicks retain their travel. The signed remainder
// is repaid by subsequent same-direction travel: ten 0.1-line events still total one tick. Debt and
// fractions expire with a 150ms gesture gap or reversal; a reversal within
// the gesture accumulates without another pre-payment (like page gestures).
function wheelTicksForGesture(state, lines, now) {
@@ -147,8 +147,12 @@
const dir = Math.sign(lines);
const fresh = !state || typeof state.lastAt !== 'number' || now - state.lastAt > PAGE_KEY_GESTURE_GAP_MS;
const reversed = state?.dir && state.dir !== dir;
if (fresh && Math.abs(lines) >= PAGE_KEY_MIN_START_ROWS && Math.abs(lines) < 1) {
return { state: { dir, lastAt: now, pending: lines - dir }, ticks: dir };
if (fresh && Math.abs(lines) >= PAGE_KEY_MIN_START_ROWS) {
const rounded = dir * Math.max(1, Math.round(Math.abs(lines)));
return {
state: { dir, lastAt: now, pending: lines - rounded },
ticks: dir * Math.min(Math.abs(rounded), SGR_WHEEL_MAX_TICKS),
};
}
const total = (fresh || reversed ? 0 : state.pending || 0) + lines;
const whole = Math.trunc(total + Math.sign(total) * 1e-9);
@@ -5852,9 +5856,9 @@ Object.assign(CodemanApp.prototype, {
if (sessionMode === 'copilot') return session?.cliMouseTracking === true;
if (sessionMode !== 'claude' && sessionMode !== 'codex') return false;
if (sessionMode === 'claude' && !this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false;
// Only while Claude is actually listening for the mouse. In its default
// inline renderer (2.1.280 measured: alternate_on=0, mouse_any_flag=0) the
// transcript lives in real scrollback, like codex, and SGR wheel reports are
// Only while Claude or Codex is actually listening for the mouse. Claude's
// inline renderer (2.1.280 measured: alternate_on=0, mouse_any_flag=0), like
// older inline Codex, keeps its transcript in real scrollback; SGR reports are
// ignored, so forwarding made every swipe and wheel tick dead. Fullscreen
// (CLAUDE_CODE_NO_FLICKER=1, or "tui": "fullscreen" in ~/.claude/settings.json)
// turns on alt-screen + mode 1003/1006, which the server records as