mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 05:59:43 +02:00
fix(terminal): restore shell scrollback, recover history lost to tmux repaints
Four fixes for the scrollback reports in #205 (plus its follow-up comment). 1. tmux-backed shell/opencode/antigravity sessions were parked in xterm's ALTERNATE buffer for their whole life. The tmux CLIENT emits smcup (\x1b[?1049h) as its first bytes on attach, and the existing strip is gated to claude/codex/gemini, so it reached the browser verbatim. In the alternate buffer baseY is pinned at 0 (no scrollback, so touch scrolling is a no-op) and xterm's own wheel handler translates the wheel into \x1bOA cursor keys, which readline receives as shell history navigation. Both reported symptoms, one sequence. isMuxAltScreenOnlyStripMode() now strips that toggle for those modes, but ONLY under tmux (the direct-PTY fallback still needs a program's own alt screen) and ONLY the alt-screen toggle: 3J from a user's `clear` and the mouse DECSETs a pane's htop/vim rely on are left alone. Safe because tmux never forwards a pane's alt-screen toggles to its client, it repaints; captured from a real attach, vim/less/htop emit zero. 2. "Load more history" on scroll-to-top. xterm's buffer is only ever a window onto tmux's history, and tmux repaints the pane rectangle instead of emitting linefeeds whenever output outpaces its flush, OVERWRITING already-rendered scrollback. Measured: a 60-line burst added 1 row and destroyed 34, while the same 60 lines emitted slowly added all 60. Scrolling up at the top now re-pulls the full tmux scrollback and holds the user's place. Verified end to end: 42 rendered rows -> 213, recovering all 150+60 printed lines. 3. The full-scrollback replay was gated on a single "first load after page load" flag, which whichever session auto-selected consumed, so every other tab started with one visible frame. Now tracked per session. 4. _wheelScrollLines ignored ev.deltaMode, so Firefox (DOM_DELTA_LINE, deltaY 3 per notch) scrolled one line where Chrome scrolls four or five, and capped the forwarded SGR report at one tick. Line and page deltas are now converted, and a pure horizontal swipe no longer falls through to a phantom -1. Analysis and measurements: docs/scrollback-issues-analysis.md
This commit is contained in:
+59
-10
@@ -180,6 +180,37 @@ export function isAltScreenStripMode(mode: SessionMode): boolean {
|
||||
return mode === 'codex' || mode === 'claude' || mode === 'gemini';
|
||||
}
|
||||
|
||||
/**
|
||||
* Modes that need the NARROW strip: alt-screen toggles only, leaving `\x1b[3J`
|
||||
* and the mouse-tracking DECSETs alone. Applies to every mode `isAltScreenStripMode`
|
||||
* excludes, but ONLY when the session is tmux-backed (`useMux`).
|
||||
*
|
||||
* The bug (issue #205): the tmux CLIENT emits `smcup` (`\x1b[?1049h`) as its first
|
||||
* bytes on attach, before any program has run. Unstripped, xterm.js parks in the
|
||||
* alternate buffer for the whole session, where `baseY` is pinned at 0 (no
|
||||
* scrollback to reach, so touch scrolling is a no-op) and xterm's own wheel handler
|
||||
* translates the wheel into `\x1bOA`/`\x1bOB` cursor keys — which readline receives
|
||||
* as shell history navigation. Both reported symptoms, one sequence.
|
||||
*
|
||||
* Why this is safe under tmux, despite the old "shell must keep the alt screen for
|
||||
* vim/less/htop" reasoning: tmux is a full terminal emulator and NEVER forwards a
|
||||
* pane's alt-screen toggles to its client, it repaints instead. Captured from a real
|
||||
* attach, `\x1b[?1049h` appears exactly once (at attach) and vim/less/htop sessions
|
||||
* inside the pane emit zero. So the only thing stripped here is tmux's own smcup.
|
||||
*
|
||||
* Why it is gated on `useMux`: `startShell()`/`startInteractive()` fall back to a
|
||||
* DIRECT PTY when mux creation fails. There the inner program's `\x1b[?1049h` really
|
||||
* does reach xterm, and stripping it would break vim/less/htop for real.
|
||||
*
|
||||
* Why it is narrower than the full strip: with tmux `mouse off`, a mouse-aware
|
||||
* program in the pane (htop, vim with `set mouse=a`) still gets its DECSETs passed
|
||||
* through to the client, so stripping those would break its mouse support. And
|
||||
* `\x1b[3J` from a user's own `clear` is a deliberate "wipe my scrollback".
|
||||
*/
|
||||
export function isMuxAltScreenOnlyStripMode(mode: SessionMode, useMux: boolean): boolean {
|
||||
return useMux && !isAltScreenStripMode(mode);
|
||||
}
|
||||
|
||||
// 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). */
|
||||
@@ -733,6 +764,15 @@ export class Session extends EventEmitter {
|
||||
return this._muxSession?.muxName ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this session's PTY is a tmux client rather than the program itself.
|
||||
* Read by the replay-side alt-screen strip, which must apply the same
|
||||
* `useMux` gate as the live strip (isMuxAltScreenOnlyStripMode).
|
||||
*/
|
||||
get usesMux(): boolean {
|
||||
return this._useMux;
|
||||
}
|
||||
|
||||
get totalCost(): number {
|
||||
return this._totalCost;
|
||||
}
|
||||
@@ -1401,9 +1441,16 @@ export class Session extends EventEmitter {
|
||||
// SSE/WS stream carries them, keeping everything in the main buffer with
|
||||
// scrollback intact. These are controlled TUIs whose cursor-positioned
|
||||
// redraws overwrite only the cells they target, so non-erased rows keep
|
||||
// their content. Gated to Codex/Claude (isAltScreenStripMode) — shell must
|
||||
// keep the alt screen for vim/less/htop.
|
||||
if (isAltScreenStripMode(this.mode)) {
|
||||
// their content. Gated to Codex/Claude/Gemini (isAltScreenStripMode).
|
||||
//
|
||||
// Every OTHER mode (shell/opencode/antigravity) gets the NARROW strip when it
|
||||
// is tmux-backed: alt-screen toggles only, because the sequence that breaks
|
||||
// scrollback there is tmux's own client-side smcup at attach, not anything the
|
||||
// program in the pane emitted (issue #205, see isMuxAltScreenOnlyStripMode).
|
||||
// 3J and the mouse DECSETs stay, so `clear` and mouse-aware TUIs keep working.
|
||||
const fullStrip = isAltScreenStripMode(this.mode);
|
||||
const altOnlyStrip = !fullStrip && isMuxAltScreenOnlyStripMode(this.mode, this._useMux);
|
||||
if (fullStrip || altOnlyStrip) {
|
||||
// Reassemble sequences split across PTY chunk boundaries first: a chunk
|
||||
// ending mid-sequence ('\x1b[?104' now, '9h' next) would slip past the
|
||||
// strip below and leave xterm stuck in the scrollback-less alt buffer
|
||||
@@ -1419,13 +1466,15 @@ export class Session extends EventEmitter {
|
||||
data = data.slice(0, -splitTail[0].length);
|
||||
if (!data) return;
|
||||
}
|
||||
data = data
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/\x1b\[\?(?:47|1047|1049)[hl]/g, '')
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/\x1b\[3J/g, '')
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, '');
|
||||
// eslint-disable-next-line no-control-regex
|
||||
data = data.replace(/\x1b\[\?(?:47|1047|1049)[hl]/g, '');
|
||||
if (fullStrip) {
|
||||
data = data
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/\x1b\[3J/g, '')
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
// Scan terminal output for attachment requests. `codeman://attach?...` is an
|
||||
|
||||
+68
-6
@@ -511,7 +511,14 @@ class CodemanApp {
|
||||
this._initGeneration = 0; // dedup concurrent handleInit calls
|
||||
this._initFallbackTimer = null; // fallback timer if SSE init doesn't arrive
|
||||
this._selectGeneration = 0; // cancel stale selectSession loads
|
||||
this._initialFullBufferLoad = true; // first buffer load after a page load fetches full tmux scrollback (COD-47)
|
||||
// Sessions whose full tmux scrollback has already been replayed this page load
|
||||
// (COD-47). Tracked PER SESSION rather than as a single "first load" flag: the
|
||||
// flag was consumed by whichever session auto-selected at page load, so every
|
||||
// OTHER tab started life with one visible frame of history (issue #205).
|
||||
this._fullHistoryLoaded = new Set();
|
||||
// Cooldown per session for the scroll-to-top "load more history" re-pull.
|
||||
this._fullHistoryRepullAt = new Map(); // Map<sessionId, timestamp>
|
||||
this._fullHistoryRepullInFlight = false;
|
||||
this.terminalLoadStates = new Map(); // Map<sessionId, { generation, phase }>
|
||||
this.respawnStatus = {};
|
||||
this.respawnTimers = {}; // Track timed respawn timers
|
||||
@@ -4098,6 +4105,58 @@ class CodemanApp {
|
||||
this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
|
||||
}
|
||||
|
||||
/**
|
||||
* "Load more history": re-pull the whole tmux scrollback when the user scrolls up
|
||||
* while already at the top of what the browser has.
|
||||
*
|
||||
* xterm's buffer is only ever a WINDOW onto tmux's real history, and two things
|
||||
* shrink it. tmux repaints the pane rectangle instead of emitting linefeeds
|
||||
* whenever output outpaces its flush interval, which OVERWRITES already-rendered
|
||||
* scrollback rather than pushing rows into it (measured: a 60-line burst added 1
|
||||
* row and destroyed 34, while the same 60 lines emitted slowly added all 60). And
|
||||
* a tab switch replays only the visible frame. Either way tmux still holds
|
||||
* everything (history-limit 100k by default), so the fix is to go ask for it with
|
||||
* the same `?full=1` capture a page reload uses (issue #205).
|
||||
*
|
||||
* On demand rather than automatic because that capture is unbounded-ish work: at
|
||||
* the default history limit it can be megabytes, which is fine to pay when the
|
||||
* user is explicitly reaching for history and not fine on every tab switch.
|
||||
*/
|
||||
async _maybeRefetchFullHistory() {
|
||||
const sessionId = this.activeSessionId;
|
||||
if (!sessionId || this._fullHistoryRepullInFlight || this._isLoadingBuffer) return;
|
||||
if (this.detachedSessions?.has(sessionId)) return;
|
||||
const now = Date.now();
|
||||
// Momentum scrolling fires this dozens of times per flick, and a burst of new
|
||||
// output is the normal reason to want a re-pull, so cooldown rather than latch.
|
||||
if (now - (this._fullHistoryRepullAt.get(sessionId) || 0) < 4000) return;
|
||||
this._fullHistoryRepullAt.set(sessionId, now);
|
||||
this._fullHistoryRepullInFlight = true;
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${sessionId}/terminal?full=1`);
|
||||
const buffer = (await res.json())?.data?.terminalBuffer;
|
||||
// Bail on a tab switch mid-fetch: writing here would paint another session's
|
||||
// history into the terminal the user is now looking at.
|
||||
if (!buffer || this.activeSessionId !== sessionId) return;
|
||||
const rowsBefore = this.terminal.buffer.active.length;
|
||||
this._resetTerminalForReplay();
|
||||
await this.chunkedTerminalWrite(buffer, TERMINAL_CHUNK_SIZE, sessionId);
|
||||
if (this.activeSessionId !== sessionId) return;
|
||||
this.terminalBufferCache.set(sessionId, buffer);
|
||||
// Hold the user's place. The replay is a superset that grew the buffer
|
||||
// UPWARD, so what used to be row 0 (what they were looking at) is now `delta`
|
||||
// rows down; scrolling there reveals the recovered history above it instead
|
||||
// of teleporting them to the bottom the way a normal buffer load does.
|
||||
const delta = this.terminal.buffer.active.length - rowsBefore;
|
||||
if (delta > 0) this.terminal.scrollToLine(delta);
|
||||
else this.terminal.scrollToTop();
|
||||
} catch {
|
||||
// Transient (offline, 5xx) — the next scroll-up past the cooldown retries.
|
||||
} finally {
|
||||
this._fullHistoryRepullInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
_shouldFocusTerminalForTabSwitch() {
|
||||
if (typeof MobileDetection === 'undefined' || !MobileDetection.isTouchDevice()) {
|
||||
return true;
|
||||
@@ -4368,11 +4427,14 @@ class CodemanApp {
|
||||
|
||||
this._setTerminalLoadState(sessionId, selectGen, 'fetching');
|
||||
_crashDiag.log('FETCH_START');
|
||||
// The FIRST buffer load after a page load requests the full tmux scrollback
|
||||
// (?full=1, COD-47) so history that scrolled off the server's byte buffer
|
||||
// comes back after a reload. Tab switches keep the fast ?tail= frame path.
|
||||
const useFullHistory = this._initialFullBufferLoad === true;
|
||||
this._initialFullBufferLoad = false;
|
||||
// The first load OF EACH SESSION this page load requests the full tmux
|
||||
// scrollback (?full=1, COD-47) so history that scrolled off the server's byte
|
||||
// buffer comes back. Later switches to an already-replayed session keep the
|
||||
// fast ?tail= frame path, which is why this is a Set and not a flag: the flag
|
||||
// version gave the full replay to the auto-selected tab and one frame of
|
||||
// history to every other one (issue #205).
|
||||
const useFullHistory = !this._fullHistoryLoaded.has(sessionId);
|
||||
if (useFullHistory) this._fullHistoryLoaded.add(sessionId);
|
||||
const res = await fetch(
|
||||
useFullHistory
|
||||
? `/api/sessions/${sessionId}/terminal?full=1`
|
||||
|
||||
@@ -429,6 +429,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
this._noteTerminalUserScroll(lines);
|
||||
this.terminal.scrollLines(lines);
|
||||
this._maybeLoadMoreHistoryOnScroll(lines);
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
@@ -453,7 +454,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!isTouching && Math.abs(velocity) > 0.3) {
|
||||
// Momentum phase — convert pixel velocity to lines
|
||||
const lines = Math.round(velocity / cellHeight());
|
||||
if (lines !== 0) this.terminal.scrollLines(lines);
|
||||
if (lines !== 0) {
|
||||
this.terminal.scrollLines(lines);
|
||||
this._maybeLoadMoreHistoryOnScroll(lines);
|
||||
}
|
||||
velocity *= 0.92;
|
||||
scrollFrame = requestAnimationFrame(scrollLoop);
|
||||
} else if (!isTouching) {
|
||||
@@ -516,6 +520,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (lines !== 0) {
|
||||
this._noteTerminalUserScroll(lines);
|
||||
this.terminal.scrollLines(lines);
|
||||
this._maybeLoadMoreHistoryOnScroll(lines);
|
||||
pixelAccum -= lines * ch;
|
||||
}
|
||||
}
|
||||
@@ -2009,6 +2014,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Post-scroll companion to _noteTerminalUserScroll: hitting the TOP of the
|
||||
* buffer while scrolling up is the user reaching for history the browser does
|
||||
* not have, so pull the rest of tmux's scrollback (issue #205, see
|
||||
* _maybeRefetchFullHistory). Must be called AFTER scrollLines(), since the
|
||||
* check is on the resulting position, and it is deliberately not folded into
|
||||
* _noteTerminalUserScroll for exactly that reason. Cheap: one integer compare
|
||||
* per scroll event, and the pull itself is cooldown-guarded.
|
||||
*/
|
||||
_maybeLoadMoreHistoryOnScroll(lines) {
|
||||
if (lines >= 0) return;
|
||||
if (this.terminal?.buffer?.active?.viewportY === 0) this._maybeRefetchFullHistory?.();
|
||||
},
|
||||
|
||||
_hasRecentUserScrollUp() {
|
||||
if (typeof this._lastUserScrollUpAt !== 'number') return false;
|
||||
return performance.now() - this._lastUserScrollUpAt < window.CodemanTerminalInput.USER_SCROLL_STICKY_SUPPRESS_MS;
|
||||
@@ -2815,9 +2834,22 @@ Object.assign(CodemanApp.prototype, {
|
||||
// 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.
|
||||
//
|
||||
// `deltaMode` says what UNIT the delta is in, and ignoring it made every
|
||||
// non-pixel browser scroll ~4x too slowly: Firefox reports DOM_DELTA_LINE (1)
|
||||
// with deltaY≈3 per notch, so the pixel math rounded to 0 and fell through to
|
||||
// the ±1 fallback — one line per notch, versus 4-5 for Chrome's ~110px. In
|
||||
// Claude mode the same value also capped the forwarded SGR report at one tick.
|
||||
_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);
|
||||
if (!delta) return 0; // pure horizontal swipe: don't fall through to -1
|
||||
const lines =
|
||||
ev.deltaMode === 1 // DOM_DELTA_LINE (Firefox mouse wheel)
|
||||
? delta
|
||||
: ev.deltaMode === 2 // DOM_DELTA_PAGE
|
||||
? delta * (this.terminal?.rows || 24)
|
||||
: delta / 25; // DOM_DELTA_PIXEL (Chrome/WebKit, and every trackpad)
|
||||
return Math.round(lines) || (delta > 0 ? 1 : -1);
|
||||
},
|
||||
|
||||
_shouldForwardWheelToApp(ev) {
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
type GeminiConfig,
|
||||
type AntigravityConfig,
|
||||
} from '../../types.js';
|
||||
import { Session, isAltScreenStripMode } from '../../session.js';
|
||||
import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode } from '../../session.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import {
|
||||
CreateSessionSchema,
|
||||
@@ -1703,6 +1703,11 @@ export function registerSessionRoutes(
|
||||
.replace(ALT_SCREEN_TOGGLE_PATTERN, '')
|
||||
.replace(ERASE_SCROLLBACK_PATTERN, '')
|
||||
.replace(MOUSE_TRACKING_PATTERN, '');
|
||||
} else if (isMuxAltScreenOnlyStripMode(session.mode, session.usesMux)) {
|
||||
// tmux-backed shell/opencode/antigravity: drop tmux's own client smcup only.
|
||||
// A byte buffer recorded before the live-side strip existed can still carry
|
||||
// it, and one replayed `\x1b[?1049h` re-parks xterm in the alt buffer (#205).
|
||||
strippedBuffer = strippedBuffer.replace(ALT_SCREEN_TOGGLE_PATTERN, '');
|
||||
}
|
||||
|
||||
if (tailBytes > 0 && strippedBuffer.length > tailBytes) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Session, isAltScreenStripMode } from '../src/session.js';
|
||||
import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode } from '../src/session.js';
|
||||
|
||||
type SessionInternals = {
|
||||
_handleTerminalOutput(data: string): void;
|
||||
@@ -81,7 +81,7 @@ describe('Claude terminal scrollback strip', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Shell terminal output is NOT stripped (vim/less/htop need the alt screen)', () => {
|
||||
describe('Shell terminal output on a DIRECT PTY 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' });
|
||||
|
||||
@@ -91,3 +91,59 @@ describe('Shell terminal output is NOT stripped (vim/less/htop need the alt scre
|
||||
expect(session.terminalBuffer).toBe(vimLike);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMuxAltScreenOnlyStripMode', () => {
|
||||
it('covers exactly the modes the full strip does not, and only under tmux', () => {
|
||||
for (const mode of ['shell', 'opencode', 'antigravity'] as const) {
|
||||
expect(isMuxAltScreenOnlyStripMode(mode, true)).toBe(true);
|
||||
// Direct-PTY fallback: the program's own alt screen really does reach xterm.
|
||||
expect(isMuxAltScreenOnlyStripMode(mode, false)).toBe(false);
|
||||
}
|
||||
// The full strip already owns these; never double-gate them here.
|
||||
for (const mode of ['claude', 'codex', 'gemini'] as const) {
|
||||
expect(isMuxAltScreenOnlyStripMode(mode, true)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('tmux-backed shell: strip tmux’s own client smcup, keep everything else (#205)', () => {
|
||||
it('drops alt-screen toggles so xterm keeps a scrollback buffer', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'shell', useMux: true });
|
||||
|
||||
// What a real `tmux attach` emits as its first bytes.
|
||||
handleOutput(session, '\x1b[?1049h\x1b[22;0;0t\x1b[?1h\x1b=\x1b[H\x1b[2Jprompt$ ');
|
||||
|
||||
expect(session.terminalBuffer).toBe('\x1b[22;0;0t\x1b[?1h\x1b=\x1b[H\x1b[2Jprompt$ ');
|
||||
expect(session.terminalBuffer).not.toContain('\x1b[?1049h');
|
||||
});
|
||||
|
||||
it('KEEPS 3J and mouse-tracking, unlike the full strip', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'shell', useMux: true });
|
||||
|
||||
// `clear` legitimately wipes scrollback; htop/vim mouse modes are passed
|
||||
// through by tmux even with `mouse off` and must keep working.
|
||||
handleOutput(session, '\x1b[3J\x1b[?1002h\x1b[?1006hhtop\x1b[?1006l\x1b[?1002l');
|
||||
|
||||
expect(session.terminalBuffer).toBe('\x1b[3J\x1b[?1002h\x1b[?1006hhtop\x1b[?1006l\x1b[?1002l');
|
||||
});
|
||||
|
||||
it('reassembles alt-screen sequences split across PTY chunk boundaries', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'shell', useMux: true });
|
||||
const emitted: string[] = [];
|
||||
session.on('terminal', (data) => emitted.push(data));
|
||||
|
||||
handleOutput(session, 'before\x1b[?104');
|
||||
handleOutput(session, '9h after');
|
||||
|
||||
expect(session.terminalBuffer).toBe('before after');
|
||||
expect(emitted).toEqual(['before', ' after']);
|
||||
});
|
||||
|
||||
it('applies to opencode and antigravity too', () => {
|
||||
for (const mode of ['opencode', 'antigravity'] as const) {
|
||||
const session = new Session({ workingDir: '/tmp', mode, useMux: true });
|
||||
handleOutput(session, '\x1b[?1049hTUI\x1b[3J');
|
||||
expect(session.terminalBuffer).toBe('TUI\x1b[3J');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user