mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
fix(terminal): keep Claude scrollback reachable — strip alt-screen/3J/mouse for claude mode (v1.1.7)
Terminal scroll-up intermittently broke for Claude sessions (most visible on iPhone). Claude Code periodically emits alt-screen switches (?1049h/?47h/?1047h), scrollback-erase (3J), and mouse-tracking enables for full-screen UIs, which move xterm.js to the scrollback-less alt buffer / wipe saved lines / hijack the wheel. Codeman stripped these but only for codex mode. Share the strip via isAltScreenStripMode(mode) = codex || claude, applied at both sites that were codex-only: the live PTY stream (Session._handleTerminalOutput, incl. the chunk-boundary carry) and the /terminal buffer replay. shell stays excluded (vim/less/htop need the alt screen); opencode unchanged. Tests: test/claude-scrollback-strip.test.ts (8 new); codex strip tests unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,17 @@
|
|||||||
# aicodeman
|
# aicodeman
|
||||||
|
|
||||||
|
## 1.1.7
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Fix: terminal scroll-up (scrollback) intermittently breaking for **Claude** sessions — most visible on iPhone, where you suddenly "can't scroll up the Claude console."
|
||||||
|
|
||||||
|
Root cause: Claude Code periodically emits alternate-screen switches (`\x1b[?1049h`/`\x1b[?47h`/`\x1b[?1047h`), scrollback-erase (`\x1b[3J`), and mouse-tracking enables — typically when it draws a full-screen UI (pickers/dialogs, the boot welcome). xterm.js obeys these by moving to the scrollback-less alternate buffer (or wiping saved lines / hijacking the wheel), so the conversation history becomes unreachable until Claude returns to its normal view. Codeman already stripped these sequences so history stays scrollable, but the strip was gated to **Codex mode only** — Claude (and the equivalent buffer-replay path) let them through.
|
||||||
|
|
||||||
|
The strip is now shared via a single `isAltScreenStripMode(mode)` predicate (`codex || claude`) applied at BOTH sites that were Codex-only: the live PTY stream (`Session._handleTerminalOutput`, including the split-across-chunks carry reassembly) and the `/terminal` buffer replay used on tab-switch/reconnect. `shell` is deliberately excluded so full-screen TUIs run from a shell (vim/less/htop) keep their alternate screen; `opencode` is also unchanged.
|
||||||
|
|
||||||
|
Verified end-to-end on an isolated instance against a real Claude session: the replayed buffer and live stream now carry zero alt-screen/scrollback-erase/mouse sequences, the terminal stays in the normal buffer with scrollback intact, and touch swipe-up scrolls correctly. Covered by new unit tests (`test/claude-scrollback-strip.test.ts`); the existing Codex strip tests are unchanged.
|
||||||
|
|
||||||
## 1.1.6
|
## 1.1.6
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -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.
|
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.1.6 (must match `package.json`)
|
**Version**: 1.1.7 (must match `package.json`)
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.1.6",
|
"version": "1.1.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.1.6",
|
"version": "1.1.7",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.1.6",
|
"version": "1.1.7",
|
||||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
+34
-13
@@ -136,6 +136,21 @@ export function isExternalCliMode(mode: SessionMode): boolean {
|
|||||||
return mode === 'opencode' || mode === 'codex';
|
return mode === 'opencode' || mode === 'codex';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modes whose TUI emits alt-screen / scrollback-erase / mouse-tracking sequences
|
||||||
|
* that we strip so the browser keeps everything in the main buffer with scrollback
|
||||||
|
* reachable (the strip runs on both the live stream and the buffer replay).
|
||||||
|
*
|
||||||
|
* Codex and Claude Code are known, controlled TUIs that repaint via cursor
|
||||||
|
* positioning, so dropping the alt-screen switch is safe — content stays in the
|
||||||
|
* normal buffer. Excluded: `shell` (arbitrary programs like vim/less/htop
|
||||||
|
* legitimately need the alt screen) and `opencode` (renders its own TUI that
|
||||||
|
* may rely on it). Keep parity with the replay-side strip in session-routes.ts.
|
||||||
|
*/
|
||||||
|
export function isAltScreenStripMode(mode: SessionMode): boolean {
|
||||||
|
return mode === 'codex' || mode === 'claude';
|
||||||
|
}
|
||||||
|
|
||||||
// Note: Claude CLI PATH resolution moved to session-cli-builder.ts (buildClaudeEnv)
|
// Note: Claude CLI PATH resolution moved to session-cli-builder.ts (buildClaudeEnv)
|
||||||
|
|
||||||
/** PTY fallback geometry when tmux can't be queried (matches pre-#80 hardcoded values). */
|
/** PTY fallback geometry when tmux can't be queried (matches pre-#80 hardcoded values). */
|
||||||
@@ -265,9 +280,10 @@ export class Session extends EventEmitter {
|
|||||||
private _messages: ClaudeMessage[] = [];
|
private _messages: ClaudeMessage[] = [];
|
||||||
private _lineBuffer: string = '';
|
private _lineBuffer: string = '';
|
||||||
private _lineBufferFlushTimer: NodeJS.Timeout | null = null;
|
private _lineBufferFlushTimer: NodeJS.Timeout | null = null;
|
||||||
// Codex only: trailing partial CSI held back so sequences split across PTY
|
// Alt-screen-strip modes (Codex/Claude): trailing partial CSI held back so
|
||||||
// chunks can't slip past the alt-screen/scrollback strip (see _handleTerminalOutput)
|
// sequences split across PTY chunks can't slip past the alt-screen/scrollback
|
||||||
private _codexSeqCarry: string = '';
|
// strip (see _handleTerminalOutput / isAltScreenStripMode)
|
||||||
|
private _altScreenSeqCarry: string = '';
|
||||||
private resolvePromise: ((value: { result: string; cost: number }) => void) | null = null;
|
private resolvePromise: ((value: { result: string; cost: number }) => void) | null = null;
|
||||||
private rejectPromise: ((reason: Error) => void) | null = null;
|
private rejectPromise: ((reason: Error) => void) | null = null;
|
||||||
private _promptResolved: boolean = false; // Guard against race conditions in runPrompt
|
private _promptResolved: boolean = false; // Guard against race conditions in runPrompt
|
||||||
@@ -1134,35 +1150,40 @@ export class Session extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _handleTerminalOutput(data: string): void {
|
private _handleTerminalOutput(data: string): void {
|
||||||
// Codex emits sequences that wipe xterm.js scrollback, plus mouse-tracking
|
// Codex AND Claude Code emit sequences that wipe xterm.js scrollback, plus
|
||||||
// enables that hijack the scroll wheel so the user can't reach scrollback:
|
// mouse-tracking enables that hijack the scroll wheel so the user can't reach
|
||||||
|
// scrollback. Claude Code does this intermittently (e.g. full-screen pickers /
|
||||||
|
// dialogs), which is why terminal scroll-up "randomly" breaks for Claude
|
||||||
|
// sessions on mobile and desktop until the dialog closes:
|
||||||
// - \x1b[?1049h / \x1b[?47h / \x1b[?1047h: switch to the alt buffer (no
|
// - \x1b[?1049h / \x1b[?47h / \x1b[?1047h: switch to the alt buffer (no
|
||||||
// scrollback) — \x1b[?...l switches back.
|
// scrollback) — \x1b[?...l switches back.
|
||||||
// - \x1b[3J: erase saved lines (scrollback). (\x1b[2J / \x1b[J — erase
|
// - \x1b[3J: erase saved lines (scrollback). (\x1b[2J / \x1b[J — erase
|
||||||
// the visible viewport — are left intact; the TUI repaints those rows.)
|
// the visible viewport — are left intact; the TUI repaints those rows.)
|
||||||
// - \x1b[?1000h / 1002h / 1003h / 1005h / 1006h / 1007h: mouse-tracking
|
// - \x1b[?1000h / 1002h / 1003h / 1005h / 1006h / 1007h: mouse-tracking
|
||||||
// modes (X10, button-event, any-event, UTF-8, SGR, alt-scroll). Once on,
|
// modes (X10, button-event, any-event, UTF-8, SGR, alt-scroll). Once on,
|
||||||
// xterm.js forwards wheel events to codex instead of scrolling the
|
// xterm.js forwards wheel events to the CLI instead of scrolling the
|
||||||
// viewport, so the conversation is in scrollback but unreachable.
|
// viewport, so the conversation is in scrollback but unreachable.
|
||||||
// (Focus events at ?1004 are left alone — codeman uses them for
|
// (Focus events at ?1004 are left alone — codeman uses them for
|
||||||
// active-tab detection.)
|
// active-tab detection.)
|
||||||
// Strip them at the source so neither the persisted buffer nor the live
|
// Strip them at the source so neither the persisted buffer nor the live
|
||||||
// SSE/WS stream carries them, keeping everything in the main buffer with
|
// SSE/WS stream carries them, keeping everything in the main buffer with
|
||||||
// scrollback intact. Codex's cursor-positioned redraws overwrite only the
|
// scrollback intact. These are controlled TUIs whose cursor-positioned
|
||||||
// cells they actually target, so the non-erased rows keep their content.
|
// redraws overwrite only the cells they target, so non-erased rows keep
|
||||||
if (this.mode === 'codex') {
|
// their content. Gated to Codex/Claude (isAltScreenStripMode) — shell must
|
||||||
|
// keep the alt screen for vim/less/htop.
|
||||||
|
if (isAltScreenStripMode(this.mode)) {
|
||||||
// Reassemble sequences split across PTY chunk boundaries first: a chunk
|
// Reassemble sequences split across PTY chunk boundaries first: a chunk
|
||||||
// ending mid-sequence ('\x1b[?104' now, '9h' next) would slip past the
|
// ending mid-sequence ('\x1b[?104' now, '9h' next) would slip past the
|
||||||
// strip below and leave xterm stuck in the scrollback-less alt buffer
|
// strip below and leave xterm stuck in the scrollback-less alt buffer
|
||||||
// until the next buffer replay. Hold back an incomplete digit-only CSI
|
// until the next buffer replay. Hold back an incomplete digit-only CSI
|
||||||
// tail (≤7 chars — the longest strippable intro is '\x1b[?1049') and
|
// tail (≤7 chars — the longest strippable intro is '\x1b[?1049') and
|
||||||
// prepend it to the next chunk; complete sequences are never held.
|
// prepend it to the next chunk; complete sequences are never held.
|
||||||
data = this._codexSeqCarry + data;
|
data = this._altScreenSeqCarry + data;
|
||||||
this._codexSeqCarry = '';
|
this._altScreenSeqCarry = '';
|
||||||
// eslint-disable-next-line no-control-regex
|
// eslint-disable-next-line no-control-regex
|
||||||
const splitTail = data.match(/\x1b(?:\[\??[0-9]{0,4})?$/);
|
const splitTail = data.match(/\x1b(?:\[\??[0-9]{0,4})?$/);
|
||||||
if (splitTail) {
|
if (splitTail) {
|
||||||
this._codexSeqCarry = splitTail[0];
|
this._altScreenSeqCarry = splitTail[0];
|
||||||
data = data.slice(0, -splitTail[0].length);
|
data = data.slice(0, -splitTail[0].length);
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
}
|
}
|
||||||
@@ -1809,7 +1830,7 @@ export class Session extends EventEmitter {
|
|||||||
this._errorBuffer = '';
|
this._errorBuffer = '';
|
||||||
this._messages = [];
|
this._messages = [];
|
||||||
this._lineBuffer = '';
|
this._lineBuffer = '';
|
||||||
this._codexSeqCarry = '';
|
this._altScreenSeqCarry = '';
|
||||||
this._lastActivityAt = Date.now();
|
this._lastActivityAt = Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
type ApiResponse,
|
type ApiResponse,
|
||||||
type SessionColor,
|
type SessionColor,
|
||||||
} from '../../types.js';
|
} from '../../types.js';
|
||||||
import { Session } from '../../session.js';
|
import { Session, isAltScreenStripMode } from '../../session.js';
|
||||||
import { SseEvent } from '../sse-events.js';
|
import { SseEvent } from '../sse-events.js';
|
||||||
import {
|
import {
|
||||||
CreateSessionSchema,
|
CreateSessionSchema,
|
||||||
@@ -79,12 +79,13 @@ const LEADING_WHITESPACE_PATTERN = /^[\s\r\n]+/;
|
|||||||
* (1049 also saves cursor and clears the alt buffer).
|
* (1049 also saves cursor and clears the alt buffer).
|
||||||
* - CSI 3 J = erase saved lines (scrollback).
|
* - CSI 3 J = erase saved lines (scrollback).
|
||||||
*
|
*
|
||||||
* Codex emits `\x1b[?1049h` and clear-scrollback sequences during startup and
|
* Codex AND Claude Code emit `\x1b[?1049h` and clear-scrollback sequences (the
|
||||||
* on repaint. xterm.js obeys them by switching to the alt buffer (no native
|
* latter intermittently, e.g. full-screen pickers/dialogs). xterm.js obeys them
|
||||||
* scrollback) and wiping saved lines, so the user's conversation history
|
* by switching to the alt buffer (no native scrollback) and wiping saved lines,
|
||||||
* disappears on every tab switch / pane refresh. Stripping these from the
|
* so the user's conversation history disappears on every tab switch / pane
|
||||||
* replayed byte stream keeps everything in the main buffer with scrollback
|
* refresh (and scroll-up breaks live). Stripping these from the replayed byte
|
||||||
* intact. Mirrors the live-stream strip in Session._handleTerminalOutput.
|
* stream keeps everything in the main buffer with scrollback intact. Mirrors the
|
||||||
|
* live-stream strip in Session._handleTerminalOutput (isAltScreenStripMode).
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line no-control-regex
|
// eslint-disable-next-line no-control-regex
|
||||||
const ALT_SCREEN_TOGGLE_PATTERN = /\x1b\[\?(?:47|1047|1049)[hl]/g;
|
const ALT_SCREEN_TOGGLE_PATTERN = /\x1b\[\?(?:47|1047|1049)[hl]/g;
|
||||||
@@ -989,10 +990,11 @@ export function registerSessionRoutes(
|
|||||||
// the terminal appears empty when switching tabs.
|
// the terminal appears empty when switching tabs.
|
||||||
let strippedBuffer = stripInkRedrawBloat(rawBuffer);
|
let strippedBuffer = stripInkRedrawBloat(rawBuffer);
|
||||||
|
|
||||||
// Strip alt-screen toggles and scrollback-erase from codex byte streams.
|
// Strip alt-screen toggles and scrollback-erase from Codex/Claude byte
|
||||||
// xterm.js obeys them by switching to its scrollback-less alt buffer and
|
// streams. xterm.js obeys them by switching to its scrollback-less alt
|
||||||
// wiping saved lines, so conversation history disappears on tab switch.
|
// buffer and wiping saved lines, so conversation history disappears on tab
|
||||||
if (session.mode === 'codex') {
|
// switch. Same gate as the live-stream strip in session.ts.
|
||||||
|
if (isAltScreenStripMode(session.mode)) {
|
||||||
strippedBuffer = strippedBuffer
|
strippedBuffer = strippedBuffer
|
||||||
.replace(ALT_SCREEN_TOGGLE_PATTERN, '')
|
.replace(ALT_SCREEN_TOGGLE_PATTERN, '')
|
||||||
.replace(ERASE_SCROLLBACK_PATTERN, '')
|
.replace(ERASE_SCROLLBACK_PATTERN, '')
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { Session, isAltScreenStripMode } from '../src/session.js';
|
||||||
|
|
||||||
|
type SessionInternals = {
|
||||||
|
_handleTerminalOutput(data: string): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleOutput(session: Session, data: string): void {
|
||||||
|
(session as unknown as SessionInternals)._handleTerminalOutput(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('isAltScreenStripMode', () => {
|
||||||
|
it('strips for the controlled TUIs (codex + claude), not shell/opencode', () => {
|
||||||
|
expect(isAltScreenStripMode('codex')).toBe(true);
|
||||||
|
expect(isAltScreenStripMode('claude')).toBe(true);
|
||||||
|
expect(isAltScreenStripMode('shell')).toBe(false);
|
||||||
|
expect(isAltScreenStripMode('opencode')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Claude terminal scrollback strip', () => {
|
||||||
|
it('strips alt-screen toggles, scrollback-erase, and mouse-tracking', () => {
|
||||||
|
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||||
|
const emitted: string[] = [];
|
||||||
|
session.on('terminal', (data) => emitted.push(data));
|
||||||
|
|
||||||
|
handleOutput(session, '\x1b[?1049h\x1b[55;1Hdialog\x1b[3J\x1b[?1006h\x1b[?1049l');
|
||||||
|
|
||||||
|
expect(emitted[0]).toBe('\x1b[55;1Hdialog');
|
||||||
|
expect(session.terminalBuffer).toBe('\x1b[55;1Hdialog');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the visible-screen erase (2J / [J) — only scrollback-erase (3J) is dropped', () => {
|
||||||
|
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||||
|
|
||||||
|
handleOutput(session, '\x1b[?1049h\x1b[2Jvisible\x1b[3Jscrollback\x1b[?1049l');
|
||||||
|
|
||||||
|
expect(session.terminalBuffer).toBe('\x1b[2Jvisiblescrollback');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves an ordinary erase-display redraw (no scrollback sequences)', () => {
|
||||||
|
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||||
|
|
||||||
|
handleOutput(session, '\x1b[H\x1b[Jclaude redraw');
|
||||||
|
|
||||||
|
expect(session.terminalBuffer).toBe('\x1b[H\x1b[Jclaude redraw');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips sequences split across PTY chunk boundaries (carry reassembly)', () => {
|
||||||
|
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||||
|
const emitted: string[] = [];
|
||||||
|
session.on('terminal', (data) => emitted.push(data));
|
||||||
|
|
||||||
|
handleOutput(session, 'before\x1b[?104');
|
||||||
|
handleOutput(session, '9h\x1b[2Jafter\x1b[3');
|
||||||
|
handleOutput(session, 'Jtail');
|
||||||
|
|
||||||
|
expect(session.terminalBuffer).toBe('before\x1b[2Jaftertail');
|
||||||
|
expect(emitted).toEqual(['before', '\x1b[2Jafter', 'tail']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits nothing for a chunk that is only a partial CSI, then completes it', () => {
|
||||||
|
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||||
|
const emitted: string[] = [];
|
||||||
|
session.on('terminal', (data) => emitted.push(data));
|
||||||
|
|
||||||
|
handleOutput(session, '\x1b[?100'); // pure partial — held, nothing emitted
|
||||||
|
handleOutput(session, '6h done'); // completes ?1006h (stripped); rest passes
|
||||||
|
|
||||||
|
expect(emitted).toEqual([' done']);
|
||||||
|
expect(session.terminalBuffer).toBe(' done');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not touch ordinary Claude conversation output', () => {
|
||||||
|
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||||
|
|
||||||
|
const text = 'Here is line one\r\nHere is line two\r\n\x1b[2mdim status\x1b[0m';
|
||||||
|
handleOutput(session, text);
|
||||||
|
|
||||||
|
expect(session.terminalBuffer).toBe(text);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Shell terminal output is NOT stripped (vim/less/htop need the alt screen)', () => {
|
||||||
|
it('leaves alt-screen toggles, scrollback-erase, and mouse-tracking intact for shell', () => {
|
||||||
|
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
||||||
|
|
||||||
|
const vimLike = '\x1b[?1049h\x1b[?1002h\x1b[2J~ editing\x1b[3J\x1b[?1002l\x1b[?1049l';
|
||||||
|
handleOutput(session, vimLike);
|
||||||
|
|
||||||
|
expect(session.terminalBuffer).toBe(vimLike);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user