fix(terminal): bound shell history replay

This commit is contained in:
d fei
2026-08-21 08:23:31 -04:00
parent 07b9c7fd7b
commit dab432b3fd
16 changed files with 401 additions and 132 deletions
+2 -2
View File
@@ -222,7 +222,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Circuit breakers**: the Ralph breaker prevents respawn thrashing (`CLOSED` → `HALF_OPEN` → `OPEN`; reset via `/api/sessions/:id/ralph-circuit-breaker/reset`). **Distinct: the PTY-exit breaker** (`session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive`; the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. → [architecture-invariants#circuit-breakers-ralph--pty-exit](docs/architecture-invariants.md#circuit-breakers-ralph-and-pty-exit) **Circuit breakers**: the Ralph breaker prevents respawn thrashing (`CLOSED` → `HALF_OPEN` → `OPEN`; reset via `/api/sessions/:id/ralph-circuit-breaker/reset`). **Distinct: the PTY-exit breaker** (`session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive`; the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. → [architecture-invariants#circuit-breakers-ralph--pty-exit](docs/architecture-invariants.md#circuit-breakers-ralph-and-pty-exit)
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the entire tmux scrollback, bounded by the configured history limit. On success the capture is returned ALONE (`source='mux-full-history'`), superseding the byte buffer so nothing duplicates. The first load of EACH session per page load requests `full=1` (`_fullHistoryLoaded` Set); tab switches keep the cheap `?tail=` path, and scrolling up at the TOP of the buffer re-pulls `full=1` on demand (cooldown-guarded — tmux repaints bursty output in place, so browser scrollback shrinks while tmux's history stays complete). ⚠️ That re-pull must never DOWNGRADE the buffer: a repaint-mode CLI pane keeps no tmux history, so its capture is one frame and the reset+rewrite would delete history mid-scroll — `_replayWouldShrinkBuffer()` refuses it and slows that session's cooldown to 60s. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay) **Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the entire tmux scrollback, bounded by the configured history limit. On success the capture is returned ALONE (`source='mux-full-history'`), superseding the byte buffer so nothing duplicates. The first load of each non-shell TUI session per page requests `full=1` (`_fullHistoryLoaded` Set); Shell selection always starts from a bounded 1 MiB `?tail=` window. Scrolling up at the TOP or pressing **Load full history** re-pulls `full=1` on demand (cooldown-guarded — tmux repaints bursty output in place, so browser scrollback shrinks while tmux's history stays complete). ⚠️ That re-pull must never DOWNGRADE the buffer: a repaint-mode CLI pane keeps no tmux history, so its capture is one frame and the reset+rewrite would delete history mid-scroll — `_replayWouldShrinkBuffer()` refuses it and slows that session's cooldown to 60s. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
**Terminal touch gestures: link taps and text selection**: on a touch device xterm's own handlers see neither — `touch-action: none` plus touchstart's preventDefault suppress the browser's compatibility mouse events, `_installMobileTapMouseGuard` drops the trusted ones that still arrive, and the synthetic `mousedown`/`mouseup` pair dispatched for mouse REPORTING goes to the `.xterm` root, an ANCESTOR of the screen element the linkifier and SelectionService listen on. So both gestures are driven explicitly. ⚠️ **A tap activates the link under it** through the SAME provider that feeds the hover linkifier (`_terminalLinkAtPoint`, containment mirroring xterm's `_linkAtPosition`), synchronously inside `touchend` — that is what keeps the user gesture `window.open` needs — and BEFORE any mouse report, mirroring `_handleDesktopTerminalClick`'s skip for a hovered link. Two rows keep their meaning: the caret's logical line (`_tapIsOnCaretLine`, where a tap places the cursor in text the USER typed) and TUI-owned rows (`_isActionableMobileTerminalTap`, answering a dialog). ⚠️ The caret line is the boundary rather than the tap INTENT, because a shell classifies every tap as `'input'` and gating on that would leave every URL in shell output inert. ⚠️ **Long-press selects** by driving xterm's public `select()` (renderer-independent — under WebGL the glyphs are pixels and native selection cannot exist), drag or a further tap extends, and Copy goes through `copyTerminalSelection()` for its execCommand fallback on plain-HTTP installs. Three guards are load-bearing and each came from a real phone: the compat mouse pair after `touchend` (xterm focuses on mousedown and SelectionService resets the model there, so the keyboard sprang up and the selection vanished on lift), the platform's own ~500ms long-press (Android Chrome focuses the nearest editable element — the helper textarea — through no event a handler can preventDefault, so a bounded focus guard blurs it and `contextmenu` is suppressed for the gesture window), and `copyTerminalSelection()`'s closing `terminal.focus()` (right on desktop, wrong on a phone). Tests: `test/terminal-touch-tap.test.ts`. **Terminal touch gestures: link taps and text selection**: on a touch device xterm's own handlers see neither — `touch-action: none` plus touchstart's preventDefault suppress the browser's compatibility mouse events, `_installMobileTapMouseGuard` drops the trusted ones that still arrive, and the synthetic `mousedown`/`mouseup` pair dispatched for mouse REPORTING goes to the `.xterm` root, an ANCESTOR of the screen element the linkifier and SelectionService listen on. So both gestures are driven explicitly. ⚠️ **A tap activates the link under it** through the SAME provider that feeds the hover linkifier (`_terminalLinkAtPoint`, containment mirroring xterm's `_linkAtPosition`), synchronously inside `touchend` — that is what keeps the user gesture `window.open` needs — and BEFORE any mouse report, mirroring `_handleDesktopTerminalClick`'s skip for a hovered link. Two rows keep their meaning: the caret's logical line (`_tapIsOnCaretLine`, where a tap places the cursor in text the USER typed) and TUI-owned rows (`_isActionableMobileTerminalTap`, answering a dialog). ⚠️ The caret line is the boundary rather than the tap INTENT, because a shell classifies every tap as `'input'` and gating on that would leave every URL in shell output inert. ⚠️ **Long-press selects** by driving xterm's public `select()` (renderer-independent — under WebGL the glyphs are pixels and native selection cannot exist), drag or a further tap extends, and Copy goes through `copyTerminalSelection()` for its execCommand fallback on plain-HTTP installs. Three guards are load-bearing and each came from a real phone: the compat mouse pair after `touchend` (xterm focuses on mousedown and SelectionService resets the model there, so the keyboard sprang up and the selection vanished on lift), the platform's own ~500ms long-press (Android Chrome focuses the nearest editable element — the helper textarea — through no event a handler can preventDefault, so a bounded focus guard blurs it and `contextmenu` is suppressed for the gesture window), and `copyTerminalSelection()`'s closing `terminal.focus()` (right on desktop, wrong on a phone). Tests: `test/terminal-touch-tap.test.ts`.
@@ -413,7 +413,7 @@ Mobile screenshots: `~/.codeman/screenshots/`, accessed via `GET/POST /api/scree
Target: 20 sessions, 50 agent windows at 60fps. Limits live in `src/config/` (terminal 32MB, text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100), most env-overridable. Target: 20 sessions, 50 agent windows at 60fps. Limits live in `src/config/` (terminal 32MB, text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100), most env-overridable.
Two constraints worth knowing before you touch them: the env-derived PTY buffer trim is **clamped to ≤75% of max**, because a trim ≥ max would disable `BufferAccumulator` trimming entirely and make memory unbounded; and browser xterm scrollback is a **separate hardcoded 50k** (`DEFAULT_SCROLLBACK` in constants.js), deliberately lower than tmux's 100k history because 100k per tab is a mobile-memory hazard. The settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but **inert**; only `tmuxHistoryLimit` is wired live. → [architecture-invariants#buffers-uploads-and-terminal-history](docs/architecture-invariants.md#buffers-uploads-and-terminal-history), `docs/terminal-anti-flicker.md` Two constraints worth knowing before you touch them: the env-derived PTY buffer trim is **clamped to ≤75% of max**, because a trim ≥ max would disable `BufferAccumulator` trimming entirely and make memory unbounded; and browser xterm scrollback is a **separate hardcoded 50k** (`DEFAULT_SCROLLBACK` in constants.js), deliberately lower than tmux's 100k history because 100k per tab is a mobile-memory hazard. tmux <3.7 allocates `history-limit` at pane creation, while tmux 3.7+ can resize live panes (lowering the value can discard retained lines); already-evicted lines never return. The settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but **inert**; only `tmuxHistoryLimit` is wired. → [architecture-invariants#buffers-uploads-and-terminal-history](docs/architecture-invariants.md#buffers-uploads-and-terminal-history), `docs/terminal-anti-flicker.md`
**Memory leaks (24+ hour sessions)**: use `CleanupManager`, clear Maps in `stop()`, guard async with `if (this.cleanup.isStopped) return`. Frontend: store handler refs, clean in `close*()`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Verify: `npm test -- test/memory-leak-prevention.test.ts`. **Memory leaks (24+ hour sessions)**: use `CleanupManager`, clear Maps in `stop()`, guard async with `if (this.cleanup.isStopped) return`. Frontend: store handler refs, clean in `close*()`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Verify: `npm test -- test/memory-leak-prevention.test.ts`.
+2 -2
View File
@@ -92,7 +92,7 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
### Full-scrollback replay ### Full-scrollback replay
**Full-scrollback replay** (COD-164/#148, reworked for #205): `GET /api/sessions/:id/terminal?full=1` returns the ENTIRE tmux scrollback (capture-pane `-e -S -<lines>` bounded by the configured history limit, explicit `maxBuffer` from the terminal-history config, early byte-cap before normalization, CRLF-normalized for shell panes). On success the capture is returned ALONE (`source='mux-full-history'` — it supersedes the byte buffer; no duplication). The first load OF EACH SESSION per page load requests `full=1` (`_fullHistoryLoaded` Set in app.js — the old one-shot `_initialFullBufferLoad` flag was consumed by whichever tab auto-selected, leaving every other tab one frame of history); later switches keep the cheap `?tail=` visible-frame path. On top of that, scrolling up while already at the TOP of the buffer re-pulls `full=1` on demand (`_maybeRefetchFullHistory`, 4s per-session cooldown, in-flight + tab-switch guards, viewport position held across the replay). The re-pull exists because xterm's buffer is only a WINDOW onto tmux's history and two things shrink it: tmux coalesces bursty output into pane REPAINTS that overwrite rows instead of emitting linefeeds (measured: a 60-line burst added 1 row of browser scrollback and destroyed 34), and a tab switch replays only the visible frame. tmux's own history is intact throughout — the browser just has to ask for it again. On-demand rather than automatic because at a 100k history limit the capture can be megabytes. ⚠️ **The re-pull must never DOWNGRADE the buffer** (#205 round 2): the same reasoning that makes it a win for a shell pane makes it destructive for a repaint-mode CLI pane, where tmux keeps no history of its own (`history_size≈0` measured for a Claude pane) and the capture is roughly ONE frame while xterm may hold hundreds of rows of replayed frames — `_resetTerminalForReplay()` + rewrite then deletes history mid-scroll ("goes back a bit, repeats blocks, gets worse the further up I go"; measured A/B on a live pane: 341 rows → 42 with the guard off). `_replayWouldShrinkBuffer()` (terminal-ui.js) estimates the capture's rendered rows — escape sequences stripped, `capture-pane -J` re-wrapping accounted for — and the pull is skipped when that is more than one screen short of `buffer.active.length`. The one-screen tolerance matters: both sides are estimates (the buffer length counts trailing blank rows), so only a clear downgrade is refused. A refused session joins `_fullHistoryRepullUseless`, raising its cooldown from 4s to 60s so a hollow pane stops re-fetching megabytes on every scroll-up. Tests: `test/tmux-capture-full-history.test.ts`, `test/tmux-scrollback-eol.test.ts`, `test/terminal-scroll-routing.test.ts`. **Full-scrollback replay** (COD-164/#148, reworked for #205): `GET /api/sessions/:id/terminal?full=1` returns the ENTIRE tmux scrollback (capture-pane `-e -S -<lines>` bounded by the configured history limit, explicit `maxBuffer` from the terminal-history config, early byte-cap before normalization, CRLF-normalized for shell panes). On success the capture is returned ALONE (`source='mux-full-history'` — it supersedes the byte buffer; no duplication). The first load of each non-shell TUI session per page requests `full=1` (`_fullHistoryLoaded` Set in app.js — the old one-shot `_initialFullBufferLoad` flag was consumed by whichever tab auto-selected, leaving every other TUI tab one frame of history). Shell sessions instead load a bounded 1 MiB `?tail=` window on every selection: a 100k-line shell capture can be tens of MiB, and automatically parsing it makes tab-switch latency scale with the entire session. Scrolling up while already at the TOP, or pressing **Load full history**, still re-pulls `full=1` on demand (`_maybeRefetchFullHistory`, 4s per-session cooldown, in-flight + tab-switch guards, viewport position held across the replay); Shell full pulls are not retained in the tab cache, so the next switch stays bounded. Chunked replay enqueues 32 KiB pieces across safe yields, appends an xterm parse marker, then releases the live-output gate; output arriving after that release stays ordered behind the snapshot, while the marker callback supplies accurate parse timing without extending the pre-existing queued-event discard window. The route exposes capture/prepare totals in `Server-Timing`, while `[TERMINAL-PERF]` separates TTFB, body/JSON, reset+parse and total time for both selection and on-demand full pulls; parse completion is not a browser compositor/GPU paint measurement. The re-pull exists because xterm's buffer is only a WINDOW onto tmux's history and two things shrink it: tmux coalesces bursty output into pane REPAINTS that overwrite rows instead of emitting linefeeds (measured: a 60-line burst added 1 row of browser scrollback and destroyed 34), and a tab switch replays only the visible frame. tmux's own history is intact throughout — the browser just has to ask for it again. On-demand rather than automatic because at a 100k history limit the capture can be megabytes. ⚠️ **The re-pull must never DOWNGRADE the buffer** (#205 round 2): the same reasoning that makes it a win for a shell pane makes it destructive for a repaint-mode CLI pane, where tmux keeps no history of its own (`history_size≈0` measured for a Claude pane) and the capture is roughly ONE frame while xterm may hold hundreds of rows of replayed frames — `_resetTerminalForReplay()` + rewrite then deletes history mid-scroll ("goes back a bit, repeats blocks, gets worse the further up I go"; measured A/B on a live pane: 341 rows → 42 with the guard off). `_replayWouldShrinkBuffer()` (terminal-ui.js) estimates the capture's rendered rows — escape sequences stripped, `capture-pane -J` re-wrapping accounted for — and the pull is skipped when that is more than one screen short of `buffer.active.length`. The one-screen tolerance matters: both sides are estimates (the buffer length counts trailing blank rows), so only a clear downgrade is refused. A refused session joins `_fullHistoryRepullUseless`, raising its cooldown from 4s to 60s so a hollow pane stops re-fetching megabytes on every scroll-up. Tests: `test/tmux-capture-full-history.test.ts`, `test/tmux-scrollback-eol.test.ts`, `test/terminal-scroll-routing.test.ts`.
### Terminal scrollback: strip flavors and wheel/touch forwarding ### Terminal scrollback: strip flavors and wheel/touch forwarding
@@ -359,7 +359,7 @@ Anatomy: `.set-shell` → `.set-shell-head` (title + `.set-head-actions`) + `.se
### Buffers, uploads, and terminal history ### Buffers, uploads, and terminal history
Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired live); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`. Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). tmux <3.7 allocates history at pane creation, so `createSession()` sets the global default in the same command queue immediately before `new-session`; tmux 3.7+ instead creates the session and targets only that pane, because changing the global option can resize and trim unrelated live panes. A settings change resizes tracked panes only on 3.7+ and otherwise affects future panes; no version can recover lines already evicted. Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`.
## Local packages and build artifacts ## Local packages and build artifacts
+3 -2
View File
@@ -133,8 +133,9 @@ TUIs render correctly.
Worth knowing: Worth knowing:
- **Scrollback.** The first time you open a session, Codeman pulls the entire tmux - **Scrollback.** Agent/TUI sessions pull their entire tmux scrollback on first open.
scrollback, not just the recent tail. Scrolling to the very top pulls again on demand. Shell sessions open from a bounded recent tail so a large transcript cannot stall tab
switching; scroll to the very top or press **Load full history** to pull the rest on demand.
- **Wheel and touch scrolling** are forwarded into Claude's own transcript on recent Claude - **Wheel and touch scrolling** are forwarded into Claude's own transcript on recent Claude
versions, so the wheel scrolls the conversation rather than the terminal. `Shift+Wheel` is versions, so the wheel scrolls the conversation rather than the terminal. `Shift+Wheel` is
always local scrollback. Other CLIs scroll locally. always local scrollback. Other CLIs scroll locally.
+2 -1
View File
@@ -8,7 +8,8 @@
* src/web/public/constants.js and deliberately stays at 50k — 100k xterm lines per tab * src/web/public/constants.js and deliberately stays at 50k — 100k xterm lines per tab
* is a mobile-memory hazard — so DEFAULT_TERMINAL_SCROLLBACK_LINES stays 50,000 to match. * is a mobile-memory hazard — so DEFAULT_TERMINAL_SCROLLBACK_LINES stays 50,000 to match.
* The terminalScrollbackLines/terminalBufferMaxBytes/terminalBufferTrimBytes settings keys * The terminalScrollbackLines/terminalBufferMaxBytes/terminalBufferTrimBytes settings keys
* remain schema-validated but inert (a follow-up wires them); only tmuxHistoryLimit is live. * remain schema-validated but inert (a follow-up wires them); only tmuxHistoryLimit is wired.
* tmux <3.7 applies it to new panes; tmux 3.7+ can also resize live panes.
* All values remain env- and settings-overridable and bounds-clamped via * All values remain env- and settings-overridable and bounds-clamped via
* resolveTerminalHistoryConfig(). * resolveTerminalHistoryConfig().
*/ */
+3 -3
View File
@@ -84,7 +84,7 @@ export interface CreateSessionOptions {
envOverrides?: Record<string, string>; envOverrides?: Record<string, string>;
/** Claude CLI effort level, injected as a `--settings` soft default (overridable via /effort in-session) */ /** Claude CLI effort level, injected as a `--settings` soft default (overridable via /effort in-session) */
effort?: EffortLevel; effort?: EffortLevel;
/** tmux history-limit (scrollback lines) to set for this session. */ /** tmux history-limit (scrollback lines) allocated when this session is created. */
historyLimit?: number; historyLimit?: number;
/** Remote execution metadata for local tmux sessions wrapping SSH */ /** Remote execution metadata for local tmux sessions wrapping SSH */
remote?: SessionRemote; remote?: SessionRemote;
@@ -116,7 +116,7 @@ export interface RespawnPaneOptions {
envOverrides?: Record<string, string>; envOverrides?: Record<string, string>;
/** Claude CLI effort level (preserved across respawns, injected via `--settings`) */ /** Claude CLI effort level (preserved across respawns, injected via `--settings`) */
effort?: EffortLevel; effort?: EffortLevel;
/** tmux history-limit (scrollback lines) to set for this session after respawn. */ /** Original tmux history-limit retained for config parity; respawn cannot resize the existing pane. */
historyLimit?: number; historyLimit?: number;
/** Remote execution metadata for local tmux sessions wrapping SSH */ /** Remote execution metadata for local tmux sessions wrapping SSH */
remote?: SessionRemote; remote?: SessionRemote;
@@ -216,7 +216,7 @@ export interface TerminalMultiplexer extends EventEmitter {
/** Update Ralph enabled state for a session */ /** Update Ralph enabled state for a session */
updateRalphEnabled(sessionId: string, enabled: boolean): void; updateRalphEnabled(sessionId: string, enabled: boolean): void;
/** Apply a tmux history-limit to all tracked sessions. */ /** Apply history-limit to live panes where tmux supports it, otherwise to future panes. */
setHistoryLimit(limit: number): Promise<void>; setHistoryLimit(limit: number): Promise<void>;
// ========== Discovery ========== // ========== Discovery ==========
+2 -2
View File
@@ -510,7 +510,7 @@ export class Session extends EventEmitter {
// the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session. // the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session.
private _effort: EffortLevel | undefined; private _effort: EffortLevel | undefined;
// tmux history-limit (scrollback lines) applied to this session's pane. // tmux history-limit (scrollback lines) allocated when this session's pane is created.
private readonly _tmuxHistoryLimit: number; private readonly _tmuxHistoryLimit: number;
// Remote execution metadata, present when this session runs over SSH through local tmux. // Remote execution metadata, present when this session runs over SSH through local tmux.
@@ -600,7 +600,7 @@ export class Session extends EventEmitter {
envOverrides?: Record<string, string>; envOverrides?: Record<string, string>;
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort?: EffortLevel; effort?: EffortLevel;
/** tmux history-limit (scrollback lines) for this session's pane. */ /** tmux history-limit (scrollback lines) allocated when this session's pane is created. */
tmuxHistoryLimit?: number; tmuxHistoryLimit?: number;
/** Restored per-session attachment history. May include server-private external paths. */ /** Restored per-session attachment history. May include server-private external paths. */
attachmentHistory?: SessionAttachmentHistoryItem[]; attachmentHistory?: SessionAttachmentHistoryItem[];
+51 -31
View File
@@ -1562,6 +1562,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
private reconnectGuard: Set<string> = new Set(); private reconnectGuard: Set<string> = new Set();
private trueColorConfigured = false; private trueColorConfigured = false;
/** tmux 3.7+ can resize pane history after creation; older releases cannot. */
private liveHistoryResizeSupported: boolean | null = null;
constructor() { constructor() {
super(); super();
@@ -1580,6 +1582,26 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
return tmuxCommand(this.tmuxSocket); return tmuxCommand(this.tmuxSocket);
} }
private supportsLiveHistoryResize(): boolean {
if (this.liveHistoryResizeSupported !== null) return this.liveHistoryResizeSupported;
try {
const output = execSync(`${this.tmux()} -V`, {
encoding: 'utf8',
timeout: EXEC_TIMEOUT_MS,
stdio: ['ignore', 'pipe', 'ignore'],
});
const match = output.match(/(?:^|\D)(\d+)\.(\d+)/);
const major = match ? Number(match[1]) : 0;
const minor = match ? Number(match[2]) : 0;
this.liveHistoryResizeSupported = major > 3 || (major === 3 && minor >= 7);
} catch {
// Unknown versions take the legacy path required by tmux <3.7.
this.liveHistoryResizeSupported = false;
}
return this.liveHistoryResizeSupported;
}
// Load saved sessions from disk (NEVER called in test mode) // Load saved sessions from disk (NEVER called in test mode)
private loadSessions(): void { private loadSessions(): void {
if (IS_TEST_MODE) return; if (IS_TEST_MODE) return;
@@ -1921,7 +1943,16 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// launched in TMUX_LAUNCH_CWD (/tmp) rather than the real workingDir: a FUSE/rclone // launched in TMUX_LAUNCH_CWD (/tmp) rather than the real workingDir: a FUSE/rclone
// mount that isn't ready yet makes `getcwd` fail and breaks the spawn (see #110). The // mount that isn't ready yet makes `getcwd` fail and breaks the spawn (see #110). The
// pane cd's into workingDir below via respawn-pane. // pane cd's into workingDir below via respawn-pane.
execSync(`${this.tmux()} new-session -ds "${muxName}" -c ${TMUX_LAUNCH_CWD}`, { // tmux <3.7 allocates history only at pane creation, so its global default
// must be set immediately BEFORE new-session. tmux 3.7+ can resize a pane
// after creation; target only the new session there because changing the
// global option can resize (and when lowered, trim) unrelated live panes.
const safeHistoryLimit =
Number.isSafeInteger(historyLimit) && historyLimit > 0 ? Math.trunc(historyLimit) : DEFAULT_TMUX_HISTORY_LIMIT;
const createSessionCommand = this.supportsLiveHistoryResize()
? `${this.tmux()} new-session -ds "${muxName}" -c ${TMUX_LAUNCH_CWD} \\; set-option -t "${muxName}" history-limit ${safeHistoryLimit}`
: `${this.tmux()} set-option -g history-limit ${safeHistoryLimit} \\; new-session -ds "${muxName}" -c ${TMUX_LAUNCH_CWD} \\; set-option -t "${muxName}" history-limit ${safeHistoryLimit}`;
execSync(createSessionCommand, {
cwd: TMUX_LAUNCH_CWD, cwd: TMUX_LAUNCH_CWD,
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
stdio: 'ignore', stdio: 'ignore',
@@ -1986,16 +2017,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
.catch(() => { .catch(() => {
/* Already set globally as fallback */ /* Already set globally as fallback */
}), }),
// Raise tmux scrollback from its 2000-line default so re-attach preserves
// more context. Intentionally exceeds the xterm-side DEFAULT_SCROLLBACK (50k
// in constants.js), which stays lower to protect browser/mobile memory.
execAsync(`${this.tmux()} set-option -t "${muxName}" history-limit ${historyLimit}`, {
timeout: EXEC_TIMEOUT_MS,
})
.then(() => {})
.catch(() => {
/* Non-critical — falls back to tmux default */
}),
]; ];
// Enable 24-bit true color passthrough — server-wide, set once per lifetime // Enable 24-bit true color passthrough — server-wide, set once per lifetime
@@ -2119,7 +2140,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
resumeSessionId, resumeSessionId,
envOverrides, envOverrides,
effort, effort,
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
remote, remote,
docker, docker,
name, name,
@@ -2130,16 +2150,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (!isValidMuxName(muxName) || !isValidPath(workingDir)) return null; if (!isValidMuxName(muxName) || !isValidPath(workingDir)) return null;
// Re-apply the configured tmux history-limit after respawn (kept in sync
// with the live setting via setHistoryLimit()).
if (!IS_TEST_MODE) {
await execAsync(`${this.tmux()} set-option -t ${shellescape(muxName)} history-limit ${historyLimit}`, {
timeout: EXEC_TIMEOUT_MS,
}).catch(() => {
/* Non-critical — keeps existing tmux history-limit */
});
}
// Resolve CLI binary directory based on mode // Resolve CLI binary directory based on mode
const { pathExport } = this.buildPathExport(mode); const { pathExport } = this.buildPathExport(mode);
@@ -2998,9 +3008,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} }
/** /**
* Apply a tmux history-limit to all tracked sessions (e.g. when the user * Apply a tmux history limit. tmux 3.7+ safely targets tracked live sessions;
* changes the terminal-history setting). Invalid limits fall back to the * older releases can only change the global default for future panes. Invalid
* default. Best-effort per session. * limits fall back to the default.
*/ */
async setHistoryLimit(limit: number): Promise<void> { async setHistoryLimit(limit: number): Promise<void> {
const safeLimit = Number.isSafeInteger(limit) && limit > 0 ? Math.trunc(limit) : DEFAULT_TMUX_HISTORY_LIMIT; const safeLimit = Number.isSafeInteger(limit) && limit > 0 ? Math.trunc(limit) : DEFAULT_TMUX_HISTORY_LIMIT;
@@ -3009,12 +3019,22 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
return; return;
} }
const updates = Array.from(this.sessions.values()).map((session) => if (this.supportsLiveHistoryResize()) {
execAsync(`${this.tmux()} set-option -t ${shellescape(session.muxName)} history-limit ${safeLimit}`, { const updates = Array.from(this.sessions.values()).map((session) =>
timeout: EXEC_TIMEOUT_MS, execAsync(`${this.tmux()} set-option -t ${shellescape(session.muxName)} history-limit ${safeLimit}`, {
}) timeout: EXEC_TIMEOUT_MS,
); })
await Promise.allSettled(updates); );
await Promise.allSettled(updates);
return;
}
await execAsync(`${this.tmux()} set-option -g history-limit ${safeLimit}`, {
timeout: EXEC_TIMEOUT_MS,
}).catch(() => {
// No tmux server yet is fine: legacy createSession sets the same default
// immediately before it creates the first pane.
});
} }
/** /**
+127 -37
View File
@@ -536,10 +536,10 @@ class CodemanApp {
this._initGeneration = 0; // dedup concurrent handleInit calls this._initGeneration = 0; // dedup concurrent handleInit calls
this._initFallbackTimer = null; // fallback timer if SSE init doesn't arrive this._initFallbackTimer = null; // fallback timer if SSE init doesn't arrive
this._selectGeneration = 0; // cancel stale selectSession loads this._selectGeneration = 0; // cancel stale selectSession loads
// Sessions whose full tmux scrollback has already been replayed this page load // Non-shell sessions whose full tmux scrollback has already been replayed this
// (COD-47). Tracked PER SESSION rather than as a single "first load" flag: the // page load (COD-47). Shells deliberately start from a bounded tail because
// flag was consumed by whichever session auto-selected at page load, so every // their scrollback can be very large; full history stays available on demand.
// OTHER tab started life with one visible frame of history (issue #205). // Tracked PER SESSION rather than as a single "first load" flag (issue #205).
this._fullHistoryLoaded = new Set(); this._fullHistoryLoaded = new Set();
// Cooldown per session for the scroll-to-top "load more history" re-pull. // Cooldown per session for the scroll-to-top "load more history" re-pull.
this._fullHistoryRepullAt = new Map(); // Map<sessionId, timestamp> this._fullHistoryRepullAt = new Map(); // Map<sessionId, timestamp>
@@ -5268,6 +5268,22 @@ class CodemanApp {
this.terminal.write('\x1b[3J\x1b[H\x1b[2J'); this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
} }
_recordTerminalLoadTiming(timing) {
this._lastTerminalLoadTiming = timing;
console.info('[TERMINAL-PERF]', timing);
const resetAndParseMs =
(timing.cacheResetAndParseMs || 0) +
(timing.freshResetAndParseMs || 0) +
(timing.resetAndParseMs || 0);
const totalMs = timing.selectDoneMs ?? timing.totalMs ?? timing.selectToReplayCompleteMs ?? 0;
_crashDiag.log(
`TERMINAL_LOAD: ${timing.trigger} ${timing.full ? 'full' : 'tail'} ${timing.chars} chars ` +
`ttfb=${timing.ttfbMs.toFixed(0)}ms body+json=${timing.bodyAndJsonMs.toFixed(0)}ms ` +
`reset+parse=${resetAndParseMs.toFixed(0)}ms total=${totalMs.toFixed(0)}ms ` +
`server="${timing.serverTiming}"${timing.refused ? ' refused-downgrade' : ''}`
);
}
/** /**
* "Load more history": re-pull the whole tmux scrollback when the user scrolls up * "Load more history": re-pull the whole tmux scrollback when the user scrolls up
* while already at the top of what the browser has. * while already at the top of what the browser has.
@@ -5307,13 +5323,32 @@ class CodemanApp {
this._fullHistoryRepullAt.set(sessionId, now); this._fullHistoryRepullAt.set(sessionId, now);
this._fullHistoryRepullInFlight = true; this._fullHistoryRepullInFlight = true;
try { try {
const requestStartedAt = performance.now();
const res = await fetch(`/api/sessions/${sessionId}/terminal?full=1`); const res = await fetch(`/api/sessions/${sessionId}/terminal?full=1`);
const headersReceivedAt = performance.now();
const payload = (await res.json())?.data ?? {}; const payload = (await res.json())?.data ?? {};
const bodyParsedAt = performance.now();
const buffer = payload.terminalBuffer; const buffer = payload.terminalBuffer;
const timing = {
trigger: force ? 'full-history-button' : 'full-history-scroll',
mode: this.sessions.get(sessionId)?.mode || 'unknown',
full: true,
source: payload.source || 'unknown',
chars: buffer?.length || 0,
ttfbMs: headersReceivedAt - requestStartedAt,
bodyAndJsonMs: bodyParsedAt - headersReceivedAt,
resetAndParseMs: 0,
totalMs: 0,
serverTiming: res.headers?.get?.('server-timing') || '',
refused: false,
};
// Bail on a tab switch mid-fetch: writing here would paint another session's // Bail on a tab switch mid-fetch: writing here would paint another session's
// history into the terminal the user is now looking at. // history into the terminal the user is now looking at.
if (!buffer || this.activeSessionId !== sessionId) return; if (!buffer || this.activeSessionId !== sessionId) return;
if (this._replayWouldShrinkBuffer(buffer)) { if (this._replayWouldShrinkBuffer(buffer)) {
timing.refused = true;
timing.totalMs = performance.now() - requestStartedAt;
this._recordTerminalLoadTiming(timing);
(this._fullHistoryRepullUseless ||= new Set()).add(sessionId); (this._fullHistoryRepullUseless ||= new Set()).add(sessionId);
this._logScrollRouting?.('repull-refused-downgrade'); this._logScrollRouting?.('repull-refused-downgrade');
// The browser already holds more than tmux can give back, so there is // The browser already holds more than tmux can give back, so there is
@@ -5324,17 +5359,32 @@ class CodemanApp {
this._setHistoryTruncation(sessionId, payload); this._setHistoryTruncation(sessionId, payload);
this._fullHistoryRepullUseless?.delete(sessionId); this._fullHistoryRepullUseless?.delete(sessionId);
const rowsBefore = this.terminal.buffer.active.length; const rowsBefore = this.terminal.buffer.active.length;
const replayStartedAt = performance.now();
this._resetTerminalForReplay(); this._resetTerminalForReplay();
await this.chunkedTerminalWrite(buffer, TERMINAL_CHUNK_SIZE, sessionId); const {
if (this.activeSessionId !== sessionId) return; parsedAt,
this.terminalBufferCache.set(sessionId, buffer); bufferLength: parsedBufferLength,
completed,
} = await this.chunkedTerminalWrite(buffer, TERMINAL_CHUNK_SIZE, sessionId);
timing.resetAndParseMs = parsedAt - replayStartedAt;
if (!completed || this.activeSessionId !== sessionId) return;
// Keep shell tab restores bounded too. A user-triggered full-history pull
// may be tens of MB; caching it would replay that whole payload again on
// the next tab switch before the normal 1MB tail fetch replaces it.
if (this.sessions.get(sessionId)?.mode !== 'shell') {
this.terminalBufferCache.set(sessionId, buffer);
} else {
this.terminalBufferCache.delete(sessionId);
}
// Hold the user's place. The replay is a superset that grew the 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` // 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 // rows down; scrolling there reveals the recovered history above it instead
// of teleporting them to the bottom the way a normal buffer load does. // of teleporting them to the bottom the way a normal buffer load does.
const delta = this.terminal.buffer.active.length - rowsBefore; const delta = parsedBufferLength - rowsBefore;
if (delta > 0) this.terminal.scrollToLine(delta); if (delta > 0) this.terminal.scrollToLine(delta);
else this.terminal.scrollToTop(); else this.terminal.scrollToTop();
timing.totalMs = performance.now() - requestStartedAt;
this._recordTerminalLoadTiming(timing);
} catch { } catch {
// Transient (offline, 5xx) — the next scroll-up past the cooldown retries. // Transient (offline, 5xx) — the next scroll-up past the cooldown retries.
} finally { } finally {
@@ -5614,6 +5664,7 @@ class CodemanApp {
// COD-144: track whether the load painted nothing (empty fetch + no cache). // COD-144: track whether the load painted nothing (empty fetch + no cache).
// For that just-created-session case we flush (not discard) queued SSE events. // For that just-created-session case we flush (not discard) queued SSE events.
let bufferWasEmpty = false; let bufferWasEmpty = false;
let cacheResetAndParseMs = 0;
try { try {
// Fit terminal to container BEFORE writing any buffer data. // Fit terminal to container BEFORE writing any buffer data.
// If the browser was resized while viewing another session, the terminal // If the browser was resized while viewing another session, the terminal
@@ -5691,23 +5742,30 @@ class CodemanApp {
// blank and rewrites with fresh data. Skip the cache and write the fresh // blank and rewrites with fresh data. Skip the cache and write the fresh
// buffer once for a single clean transition. // buffer once for a single clean transition.
const cachedBuffer = this.terminalBufferCache.get(sessionId); const cachedBuffer = this.terminalBufferCache.get(sessionId);
let clearedForBusy = false; let clearedBeforeFresh = false;
if (cachedBuffer && !sessionIsBusy && !restoredSnapshot) { if (cachedBuffer && !sessionIsBusy && !restoredSnapshot && session?.mode !== 'shell') {
_crashDiag.log(`CACHE_WRITE: ${(cachedBuffer.length/1024).toFixed(0)}KB`); _crashDiag.log(`CACHE_WRITE: ${(cachedBuffer.length/1024).toFixed(0)}KB`);
this._setTerminalLoadState(sessionId, selectGen, 'replaying'); this._setTerminalLoadState(sessionId, selectGen, 'replaying');
const cacheReplayStartedAt = performance.now();
this._resetTerminalForReplay(); this._resetTerminalForReplay();
await this.chunkedTerminalWrite(cachedBuffer, TERMINAL_CHUNK_SIZE, bufferLoadOwner); const { parsedAt: cacheParsedAt } = await this.chunkedTerminalWrite(
cachedBuffer,
TERMINAL_CHUNK_SIZE,
bufferLoadOwner
);
cacheResetAndParseMs = cacheParsedAt - cacheReplayStartedAt;
if (this._isStaleSelect(selectGen)) { if (this._isStaleSelect(selectGen)) {
this._clearTerminalLoadState(sessionId, selectGen); this._clearTerminalLoadState(sessionId, selectGen);
return; return;
} }
this.terminal.scrollToBottom(); this.terminal.scrollToBottom();
_crashDiag.log('CACHE_DONE'); _crashDiag.log('CACHE_DONE');
} else if (sessionIsBusy) { } else if (sessionIsBusy || session?.mode === 'shell') {
// Clear stale content immediately — fresh buffer is being fetched // Busy sessions have stale caches. Shell sessions deliberately skip even
// an idle cache so a changed 1MB tail cannot cause two back-to-back parses.
this._resetTerminalForReplay(); this._resetTerminalForReplay();
clearedForBusy = true; clearedBeforeFresh = true;
_crashDiag.log('CACHE_SKIP_BUSY'); _crashDiag.log(session?.mode === 'shell' ? 'CACHE_SKIP_SHELL' : 'CACHE_SKIP_BUSY');
} }
// Give TUI sessions a short chance to redraw after resize before the // Give TUI sessions a short chance to redraw after resize before the
@@ -5725,26 +5783,29 @@ class CodemanApp {
this._setTerminalLoadState(sessionId, selectGen, 'fetching'); this._setTerminalLoadState(sessionId, selectGen, 'fetching');
_crashDiag.log('FETCH_START'); _crashDiag.log('FETCH_START');
// The first load OF EACH SESSION this page load requests the full tmux // TUI sessions still get one canonical full replay per page (COD-47/#205).
// scrollback (?full=1, COD-47) so history that scrolled off the server's byte // A shell can retain hundreds of thousands of plain scrollback lines, so
// buffer comes back. Later switches to an already-replayed session keep the // automatically replaying all of them makes tab selection scale with the
// fast ?tail= frame path, which is why this is a Set and not a flag: the flag // entire session. Load its bounded 1MB tail first; the existing truncation
// version gave the full replay to the auto-selected tab and one frame of // banner / scroll-to-top action fetches ?full=1 when the user asks for it.
// history to every other one (issue #205). const useFullHistory = session?.mode !== 'shell' && !this._fullHistoryLoaded.has(sessionId);
const useFullHistory = !this._fullHistoryLoaded.has(sessionId);
if (useFullHistory) this._fullHistoryLoaded.add(sessionId); if (useFullHistory) this._fullHistoryLoaded.add(sessionId);
const fetchStartedAt = performance.now();
const res = await fetch( const res = await fetch(
useFullHistory useFullHistory
? `/api/sessions/${sessionId}/terminal?full=1` ? `/api/sessions/${sessionId}/terminal?full=1`
: `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}` : `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`
); );
const headersReceivedAt = performance.now();
if (this._isStaleSelect(selectGen)) { if (this._isStaleSelect(selectGen)) {
this._clearTerminalLoadState(sessionId, selectGen); this._clearTerminalLoadState(sessionId, selectGen);
return; return;
} }
const data = (await res.json())?.data ?? {}; const data = (await res.json())?.data ?? {};
const bodyParsedAt = performance.now();
_crashDiag.log(`FETCH_DONE: ${data.terminalBuffer ? (data.terminalBuffer.length/1024).toFixed(0) + 'KB' : 'empty'} truncated=${data.truncated}`); _crashDiag.log(`FETCH_DONE: ${data.terminalBuffer ? (data.terminalBuffer.length/1024).toFixed(0) + 'KB' : 'empty'} truncated=${data.truncated}`);
let freshResetAndParseMs = 0;
if (data.terminalBuffer) { if (data.terminalBuffer) {
// Skip rewrite if fresh buffer matches cache — avoids visible clear+rewrite flash. // Skip rewrite if fresh buffer matches cache — avoids visible clear+rewrite flash.
// On slow connections (mobile 5G), the gap between clear() and chunkedWrite() is // On slow connections (mobile 5G), the gap between clear() and chunkedWrite() is
@@ -5753,10 +5814,11 @@ class CodemanApp {
// something other than the cache, so the fetched buffer must be // something other than the cache, so the fetched buffer must be
// replayed even when it byte-matches the cache. // replayed even when it byte-matches the cache.
const needsRewrite = const needsRewrite =
restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer; restoredSnapshot || clearedBeforeFresh || data.terminalBuffer !== cachedBuffer;
if (needsRewrite) { if (needsRewrite) {
_crashDiag.log(`REWRITE: ${(data.terminalBuffer.length/1024).toFixed(0)}KB`); _crashDiag.log(`REWRITE: ${(data.terminalBuffer.length/1024).toFixed(0)}KB`);
this._setTerminalLoadState(sessionId, selectGen, 'replaying'); this._setTerminalLoadState(sessionId, selectGen, 'replaying');
const replayStartedAt = performance.now();
this._resetTerminalForReplay(); this._resetTerminalForReplay();
// Truncation is reported OUT OF BAND (#258). This used to write a grey // Truncation is reported OUT OF BAND (#258). This used to write a grey
// "... earlier output truncated ..." line into the // "... earlier output truncated ..." line into the
@@ -5764,7 +5826,12 @@ class CodemanApp {
// cannot be actioned, and is indistinguishable from real CLI output. // cannot be actioned, and is indistinguishable from real CLI output.
this._setHistoryTruncation(sessionId, data); this._setHistoryTruncation(sessionId, data);
// Use chunked write for large buffers to avoid UI jank // Use chunked write for large buffers to avoid UI jank
await this.chunkedTerminalWrite(data.terminalBuffer, TERMINAL_CHUNK_SIZE, bufferLoadOwner); const { parsedAt: freshParsedAt } = await this.chunkedTerminalWrite(
data.terminalBuffer,
TERMINAL_CHUNK_SIZE,
bufferLoadOwner
);
freshResetAndParseMs = freshParsedAt - replayStartedAt;
if (this._isStaleSelect(selectGen)) { if (this._isStaleSelect(selectGen)) {
this._clearTerminalLoadState(sessionId, selectGen); this._clearTerminalLoadState(sessionId, selectGen);
return; return;
@@ -5773,22 +5840,42 @@ class CodemanApp {
this.terminal.scrollToBottom(); this.terminal.scrollToBottom();
} }
// Update cache (cap at 20 entries) // Shell selection always uses a fresh bounded tail, so retaining its
this.terminalBufferCache.set(sessionId, data.terminalBuffer); // payload only wastes memory and can evict useful TUI caches.
if (this.terminalBufferCache.size > 20) { if (session?.mode === 'shell') {
// Evict oldest entry (first key in Map iteration order) this.terminalBufferCache.delete(sessionId);
const oldest = this.terminalBufferCache.keys().next().value; } else {
this.terminalBufferCache.delete(oldest); // Update cache (cap at 20 entries)
this.terminalBufferCache.set(sessionId, data.terminalBuffer);
if (this.terminalBufferCache.size > 20) {
// Evict oldest entry (first key in Map iteration order)
const oldest = this.terminalBufferCache.keys().next().value;
this.terminalBufferCache.delete(oldest);
}
} }
} else if (!cachedBuffer) { } else if (!cachedBuffer || clearedBeforeFresh) {
// No fresh buffer and no cache — clear any stale content // Nothing was painted. If this path was not already cleared above,
this._resetTerminalForReplay(); // clear stale content now; either way queued live output must be flushed.
if (!clearedBeforeFresh) this._resetTerminalForReplay();
bufferWasEmpty = true; bufferWasEmpty = true;
} }
const terminalLoadTiming = {
trigger: 'session-select',
mode: session?.mode || 'unknown',
full: useFullHistory,
source: data.source || 'unknown',
chars: data.terminalBuffer?.length || 0,
ttfbMs: headersReceivedAt - fetchStartedAt,
bodyAndJsonMs: bodyParsedAt - headersReceivedAt,
cacheResetAndParseMs,
freshResetAndParseMs,
selectToReplayCompleteMs: performance.now() - _selStart,
serverTiming: res.headers?.get?.('server-timing') || '',
};
// Buffer load complete — unblock live SSE writes. chunkedTerminalWrite calls // Buffer load complete — unblock live SSE writes. chunkedTerminalWrite calls
// _finishBufferLoad internally (discarding queued events to prevent duplicate // _finishBufferLoad after ordering the fetched snapshot in xterm; if we skipped
// content); if we skipped the write (cache hit or empty), call it here. // the write (cache hit or empty), call it here.
// COD-144: when the load painted nothing, FLUSH the queued events instead of // COD-144: when the load painted nothing, FLUSH the queued events instead of
// discarding — a new session's prompt arrives only as a queued SSE event. // discarding — a new session's prompt arrives only as a queued SSE event.
if (this._isLoadingBuffer) { if (this._isLoadingBuffer) {
@@ -5917,9 +6004,12 @@ class CodemanApp {
if (typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible) { if (typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible) {
KeyboardHandler.onKeyboardShow(); KeyboardHandler.onKeyboardShow();
} }
const selectDoneMs = performance.now() - _selStart;
terminalLoadTiming.selectDoneMs = selectDoneMs;
this._recordTerminalLoadTiming(terminalLoadTiming);
this._clearTerminalLoadState(sessionId, selectGen); this._clearTerminalLoadState(sessionId, selectGen);
_crashDiag.log(`SELECT_DONE: ${(performance.now() - _selStart).toFixed(0)}ms`); _crashDiag.log(`SELECT_DONE: ${selectDoneMs.toFixed(0)}ms`);
console.log(`[CRASH-DIAG] selectSession DONE: ${sessionId.slice(0,8)} in ${(performance.now() - _selStart).toFixed(0)}ms`); console.log(`[CRASH-DIAG] selectSession DONE: ${sessionId.slice(0,8)} in ${selectDoneMs.toFixed(0)}ms`);
} catch (err) { } catch (err) {
if (this._isLoadingBuffer) this._finishBufferLoad(bufferLoadOwner); if (this._isLoadingBuffer) this._finishBufferLoad(bufferLoadOwner);
this._restoringFlushedState = false; this._restoringFlushedState = false;
+34 -35
View File
@@ -3571,7 +3571,9 @@ Object.assign(CodemanApp.prototype, {
} }
const buffer = this.terminal.buffer.active; const buffer = this.terminal.buffer.active;
const totalLines = buffer.baseY + buffer.length; // `length` already includes scrollback + viewport rows. Adding baseY scans
// every scrollback row twice, starting with thousands of out-of-range calls.
const totalLines = buffer.length;
let lastNonEmptyLine = -1; let lastNonEmptyLine = -1;
for (let lineIndex = totalLines - 1; lineIndex >= 0; lineIndex--) { for (let lineIndex = totalLines - 1; lineIndex >= 0; lineIndex--) {
@@ -3601,8 +3603,8 @@ Object.assign(CodemanApp.prototype, {
* Uses _safeYield to spread work across frames; falls back to setTimeout * Uses _safeYield to spread work across frames; falls back to setTimeout
* and a tick-Worker so progress continues on occluded / idle-throttled tabs. * and a tick-Worker so progress continues on occluded / idle-throttled tabs.
* @param {string} buffer - The full terminal buffer to write * @param {string} buffer - The full terminal buffer to write
* @param {number} chunkSize - Size of each chunk (default 128KB for smooth 60fps) * @param {number} chunkSize - Size of each chunk (default 32KB)
* @returns {Promise<void>} - Resolves when all chunks written * @returns {Promise<{parsedAt: number, bufferLength: number, completed: boolean}>} Parse marker snapshot
*/ */
chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE, loadOwner) { chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE, loadOwner) {
// Generation counter: if a newer chunkedTerminalWrite starts (tab switch), // Generation counter: if a newer chunkedTerminalWrite starts (tab switch),
@@ -3611,9 +3613,14 @@ Object.assign(CodemanApp.prototype, {
const bufferLoadOwner = this._beginBufferLoad(loadOwner); const bufferLoadOwner = this._beginBufferLoad(loadOwner);
return new Promise((resolve) => { return new Promise((resolve) => {
const parseSnapshot = (completed = this._chunkedWriteGen === writeGen) => ({
parsedAt: performance.now(),
bufferLength: this.terminal?.buffer?.active?.length ?? 0,
completed,
});
if (!buffer || buffer.length === 0) { if (!buffer || buffer.length === 0) {
this._finishBufferLoad(bufferLoadOwner); this._finishBufferLoad(bufferLoadOwner);
resolve(); resolve(parseSnapshot());
return; return;
} }
@@ -3621,53 +3628,45 @@ Object.assign(CodemanApp.prototype, {
// (from historical SSE data that was stored with markers) // (from historical SSE data that was stored with markers)
const cleanBuffer = buffer.replace(DEC_SYNC_STRIP_RE, ''); const cleanBuffer = buffer.replace(DEC_SYNC_STRIP_RE, '');
const finish = () => {
// Only finish if we're still the active write — a newer write owns buffer load state
if (this._chunkedWriteGen === writeGen) {
this._finishBufferLoad(bufferLoadOwner);
}
resolve();
};
// For small buffers, write directly — single-frame render is fast enough // For small buffers, write directly — single-frame render is fast enough
if (cleanBuffer.length <= chunkSize) { if (cleanBuffer.length <= chunkSize) {
this.terminal.write(cleanBuffer, finish); this.terminal.write(cleanBuffer, () => resolve(parseSnapshot()));
// The write is now ordered in xterm's queue. Release live output before
// parsing completes; subsequent writes stay behind it without being lost.
this._finishBufferLoad(bufferLoadOwner);
return; return;
} }
// Large buffers: write in chunks across animation frames. // Large buffers: enqueue paced chunks, then append an empty marker whose
// Each 32KB chunk keeps per-frame WebGL render work under ~5ms, // callback fires after xterm parses every preceding chunk. The live-output
// avoiding GPU stalls without needing to toggle the renderer. // gate is released as soon as that marker is ordered, not after parsing, so
// new output queues behind history instead of being held or dropped.
let offset = 0; let offset = 0;
const _chunkStart = performance.now(); const _chunkStart = performance.now();
let _chunkCount = 0; let _chunkCount = 0;
const writeChunk = () => { const writeChunk = () => {
// Abort if a newer chunked write started (user switched tabs) // Abort if a newer chunked write started (user switched tabs)
if (this._chunkedWriteGen !== writeGen) { if (this._chunkedWriteGen !== writeGen) {
resolve(); resolve(parseSnapshot(false));
return; return;
} }
if (offset >= cleanBuffer.length) {
const _totalMs = performance.now() - _chunkStart;
console.log(
`[CRASH-DIAG] chunkedTerminalWrite complete: ${cleanBuffer.length} bytes in ${_chunkCount} chunks, ${_totalMs.toFixed(0)}ms total`
);
// Wait one more frame for xterm to finish rendering before resolving
this._safeYield(finish);
return;
}
const _ct0 = performance.now();
const chunk = cleanBuffer.slice(offset, offset + chunkSize); const chunk = cleanBuffer.slice(offset, offset + chunkSize);
this.terminal.write(chunk); offset += chunk.length;
const _cdt = performance.now() - _ct0;
_chunkCount++; _chunkCount++;
if (_cdt > 50) this.terminal.write(chunk);
console.warn( if (offset >= cleanBuffer.length) {
`[CRASH-DIAG] chunk #${_chunkCount} write took ${_cdt.toFixed(0)}ms (${chunk.length} bytes at offset ${offset})` this.terminal.write('', () => {
); const result = parseSnapshot();
offset += chunkSize; const _totalMs = result.parsedAt - _chunkStart;
console.log(
`[CRASH-DIAG] chunkedTerminalWrite complete: ${cleanBuffer.length} bytes in ${_chunkCount} chunks, ${_totalMs.toFixed(0)}ms parsed`
);
resolve(result);
});
this._finishBufferLoad(bufferLoadOwner);
return;
}
// Schedule next chunk; rAF if possible, else setTimeout/Worker // Schedule next chunk; rAF if possible, else setTimeout/Worker
// fallback so progress doesn't stall on occluded/unfocused windows. // fallback so progress doesn't stall on occluded/unfocused windows.
+18 -8
View File
@@ -12,6 +12,7 @@ import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
import { execFile } from 'node:child_process'; import { execFile } from 'node:child_process';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import { performance } from 'node:perf_hooks';
import { import {
ApiErrorCode, ApiErrorCode,
createErrorResponse, createErrorResponse,
@@ -2270,18 +2271,17 @@ export function registerSessionRoutes(
// Query params: // Query params:
// tail=<bytes> - Only return last N bytes (faster initial load) // tail=<bytes> - Only return last N bytes (faster initial load)
// full=1 - Full page reload: replay the entire tmux scrollback (COD-47) // full=1 - Explicitly request the entire tmux scrollback (COD-47)
app.get('/api/sessions/:id/terminal', async (req) => { app.get('/api/sessions/:id/terminal', async (req, reply) => {
const routeStartedAt = performance.now();
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const query = req.query as { tail?: string; full?: string }; const query = req.query as { tail?: string; full?: string };
const session = findSessionOrFail(ctx, id, req); const session = findSessionOrFail(ctx, id, req);
// `full=1` is the EXPLICIT full-reload signal (COD-47): the browser reloaded // `full=1` is the EXPLICIT full-history signal (COD-47): capture the ENTIRE
// the page and wants the whole scroll history back, so we capture the ENTIRE // tmux scrollback so history beyond the server byte buffer can be recovered.
// tmux scrollback and the user gets back history that scrolled off Codeman's // Requests WITHOUT it — shell selection/tab switches (`tail=`) and legacy
// byte buffer. Requests WITHOUT it — tab switches (`tail=`) and the legacy // no-param callers — keep the fast visible-frame capture.
// no-param callers (response-viewer fallback, clearTerminal refresh) — keep
// the fast visible-frame capture.
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0; const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
const isFullReload = query.full === '1' || query.full === 'true'; const isFullReload = query.full === '1' || query.full === 'true';
const { tmuxHistoryLimit, terminalBufferMaxBytes } = await ctx.getTerminalHistoryConfig(); const { tmuxHistoryLimit, terminalBufferMaxBytes } = await ctx.getTerminalHistoryConfig();
@@ -2294,6 +2294,7 @@ export function registerSessionRoutes(
// overlap. `captureActivePaneBuffer` is a no-op ('') under test mode and // overlap. `captureActivePaneBuffer` is a no-op ('') under test mode and
// returns null when unavailable, in which case we fall back to history. // returns null when unavailable, in which case we fall back to history.
const muxName = session.muxName; const muxName = session.muxName;
const captureStartedAt = performance.now();
const liveMuxBuffer = const liveMuxBuffer =
muxName && typeof ctx.mux.captureActivePaneBuffer === 'function' muxName && typeof ctx.mux.captureActivePaneBuffer === 'function'
? ctx.mux.captureActivePaneBuffer( ? ctx.mux.captureActivePaneBuffer(
@@ -2303,6 +2304,7 @@ export function registerSessionRoutes(
: undefined : undefined
) )
: null; : null;
const captureFinishedAt = performance.now();
const hasLiveMuxBuffer = liveMuxBuffer !== null && liveMuxBuffer.length > 0; const hasLiveMuxBuffer = liveMuxBuffer !== null && liveMuxBuffer.length > 0;
const source: 'history' | 'mux-visible' | 'mux-full-history' = hasLiveMuxBuffer const source: 'history' | 'mux-visible' | 'mux-full-history' = hasLiveMuxBuffer
? isFullReload ? isFullReload
@@ -2406,6 +2408,14 @@ export function registerSessionRoutes(
// Remove Ctrl+L and leading whitespace (cheap on tailed subset) // Remove Ctrl+L and leading whitespace (cheap on tailed subset)
cleanBuffer = cleanBuffer.replace(CTRL_L_PATTERN, '').replace(LEADING_WHITESPACE_PATTERN, ''); cleanBuffer = cleanBuffer.replace(CTRL_L_PATTERN, '').replace(LEADING_WHITESPACE_PATTERN, '');
const finishedAt = performance.now();
reply.header(
'Server-Timing',
`capture;dur=${(captureFinishedAt - captureStartedAt).toFixed(1)}, ` +
`prepare;dur=${(finishedAt - captureFinishedAt).toFixed(1)}, ` +
`total;dur=${(finishedAt - routeStartedAt).toFixed(1)}`
);
return { return {
terminalBuffer: cleanBuffer, terminalBuffer: cleanBuffer,
status: session.status, status: session.status,
+2 -1
View File
@@ -722,7 +722,8 @@ export function registerSystemRoutes(
const merged = { ...existing, ...settingsToStore }; const merged = { ...existing, ...settingsToStore };
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2)); await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
// Apply a changed tmux history-limit to all live sessions immediately. // tmux 3.7+ resizes tracked panes; older versions apply this to new panes.
// Already-evicted history cannot be recovered on either version.
if (settings.tmuxHistoryLimit !== undefined) { if (settings.tmuxHistoryLimit !== undefined) {
await ctx.mux.setHistoryLimit(resolveTerminalHistoryConfig(merged).tmuxHistoryLimit); await ctx.mux.setHistoryLimit(resolveTerminalHistoryConfig(merged).tmuxHistoryLimit);
} }
+6 -4
View File
@@ -32,7 +32,7 @@ describe('xterm snapshot/replay (codex tab-switch)', () => {
const declaration = source.indexOf('let restoredSnapshot = false;', selectStart); const declaration = source.indexOf('let restoredSnapshot = false;', selectStart);
const snapshotBranch = source.indexOf("if (snapshot && !sessionIsBusy && session?.mode !== 'shell')", selectStart); const snapshotBranch = source.indexOf("if (snapshot && !sessionIsBusy && session?.mode !== 'shell')", selectStart);
const rewriteDecision = source.indexOf( const rewriteDecision = source.indexOf(
'restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer', 'restoredSnapshot || clearedBeforeFresh || data.terminalBuffer !== cachedBuffer',
selectStart selectStart
); );
@@ -58,7 +58,9 @@ describe('xterm snapshot/replay (codex tab-switch)', () => {
// Snapshot restore must NOT short-circuit the canonical fetch. // Snapshot restore must NOT short-circuit the canonical fetch.
expect(snapshotBlock).not.toContain('this._finishBufferLoad();'); expect(snapshotBlock).not.toContain('this._finishBufferLoad();');
expect(postSnapshotRestore).toContain('restoredSnapshot'); expect(postSnapshotRestore).toContain('restoredSnapshot');
expect(postSnapshotRestore).toContain('restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer'); expect(postSnapshotRestore).toContain(
'restoredSnapshot || clearedBeforeFresh || data.terminalBuffer !== cachedBuffer'
);
}); });
it('forces replay after clearing a busy tab even when the fetched frame matches cache', () => { it('forces replay after clearing a busy tab even when the fetched frame matches cache', () => {
@@ -71,8 +73,8 @@ describe('xterm snapshot/replay (codex tab-switch)', () => {
expect(cacheRestore).toBeGreaterThan(-1); expect(cacheRestore).toBeGreaterThan(-1);
expect(busyClear).toBeGreaterThan(cacheRestore); expect(busyClear).toBeGreaterThan(cacheRestore);
expect(needsRewrite).toBeGreaterThan(busyClear); expect(needsRewrite).toBeGreaterThan(busyClear);
expect(replayBlock).toContain('clearedForBusy'); expect(replayBlock).toContain('clearedBeforeFresh');
expect(replayBlock).toContain('restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer'); expect(replayBlock).toContain('restoredSnapshot || clearedBeforeFresh || data.terminalBuffer !== cachedBuffer');
}); });
it('loads the SerializeAddon and keeps a per-session snapshot map', () => { it('loads the SerializeAddon and keeps a per-session snapshot map', () => {
+11
View File
@@ -122,6 +122,17 @@ describe('the in-terminal truncation line is gone (static guard)', () => {
expect(app).not.toContain('earlier output truncated for performance'); expect(app).not.toContain('earlier output truncated for performance');
}); });
it('loads a bounded shell tail first and keeps full history user-triggered', () => {
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
expect(app).toContain("session?.mode !== 'shell' && !this._fullHistoryLoaded.has(sessionId)");
expect(app).toContain("!restoredSnapshot && session?.mode !== 'shell'");
expect(app).toContain('`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`');
expect(app).toContain('fetch(`/api/sessions/${sessionId}/terminal?full=1`)');
expect(app).toContain("if (this.sessions.get(sessionId)?.mode !== 'shell')");
expect(app).toContain("if (session?.mode === 'shell')");
expect(app).toContain("trigger: force ? 'full-history-button' : 'full-history-scroll'");
});
it('renders the banner through textContent, never innerHTML', () => { it('renders the banner through textContent, never innerHTML', () => {
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8'); const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
const start = app.indexOf('_renderHistoryTruncationBanner() {'); const start = app.indexOf('_renderHistoryTruncationBanner() {');
+1
View File
@@ -789,6 +789,7 @@ describe('session-routes', () => {
expect(body.data.terminalBuffer).toContain(lastLine); expect(body.data.terminalBuffer).toContain(lastLine);
expect(body.data.source).toBe('mux-full-history'); expect(body.data.source).toBe('mux-full-history');
expect(typeof body.data.fullSize).toBe('number'); expect(typeof body.data.fullSize).toBe('number');
expect(res.headers['server-timing']).toMatch(/^capture;dur=\d+\.\d, prepare;dur=\d+\.\d, total;dur=\d+\.\d$/);
}); });
it('full reload (?full=1) returns the tmux capture ALONE — byte history is not duplicated', async () => { it('full reload (?full=1) returns the tmux capture ALONE — byte history is not duplicated', async () => {
+80 -3
View File
@@ -89,7 +89,7 @@ describe('terminal flush budget', () => {
expect(app.pendingWrites.join('')).toHaveLength(32 * 1024); expect(app.pendingWrites.join('')).toHaveLength(32 * 1024);
}); });
it('waits for xterm to process small buffer replays before completing buffer load', async () => { it('releases the live-output gate but waits for xterm to parse a small replay', async () => {
const { app, writes } = loadTerminalUiHarness('codex'); const { app, writes } = loadTerminalUiHarness('codex');
let writeDone: (() => void) | undefined; let writeDone: (() => void) | undefined;
let resolved = false; let resolved = false;
@@ -109,13 +109,90 @@ describe('terminal flush budget', () => {
expect(writes).toEqual(['fresh tmux pane frame']); expect(writes).toEqual(['fresh tmux pane frame']);
expect(writeDone).toBeTypeOf('function'); expect(writeDone).toBeTypeOf('function');
expect(resolved).toBe(false); expect(resolved).toBe(false);
expect(finishBufferLoad).not.toHaveBeenCalled(); expect(finishBufferLoad).toHaveBeenCalledOnce();
writeDone?.(); writeDone?.();
await promise; await promise;
expect(resolved).toBe(true); expect(resolved).toBe(true);
expect(finishBufferLoad).toHaveBeenCalledOnce(); });
it('paces a large enqueue and releases live output before the parse marker completes', async () => {
const { app, writes } = loadTerminalUiHarness('shell');
const scheduled: Array<() => void> = [];
let parseDone: (() => void) | undefined;
let resolved = false;
let result: { parsedAt: number; bufferLength: number; completed: boolean } | undefined;
app._safeYield = (callback: () => void) => scheduled.push(callback);
app.isTerminalAtBottom = () => true;
app.terminal.buffer = { active: { length: 37 } };
app.terminal.write = (data: string, callback?: () => void) => {
writes.push(data);
if (callback) parseDone = callback;
};
const promise = app.chunkedTerminalWrite('x'.repeat(3 * 32 * 1024)).then((value: typeof result) => {
result = value;
resolved = true;
});
expect(writes).toEqual([]);
expect(scheduled).toHaveLength(1);
scheduled.shift()?.();
expect(writes.map((write) => write.length)).toEqual([32 * 1024]);
expect(scheduled).toHaveLength(1);
scheduled.shift()?.();
expect(writes.map((write) => write.length)).toEqual([32 * 1024, 32 * 1024]);
expect(resolved).toBe(false);
scheduled.shift()?.();
expect(writes.map((write) => write.length)).toEqual([32 * 1024, 32 * 1024, 32 * 1024, 0]);
expect(app._isLoadingBuffer).toBe(false);
expect(resolved).toBe(false);
app.batchTerminalWrite('new output after snapshot');
expect(app._loadBufferQueue).toBe(null);
expect(app.pendingWrites).toEqual(['new output after snapshot']);
parseDone?.();
app.terminal.buffer.active.length = 42;
await promise;
expect(resolved).toBe(true);
expect(result?.bufferLength).toBe(37);
expect(result?.completed).toBe(true);
});
it('marks a parse callback stale when a newer replay supersedes it', async () => {
const { app } = loadTerminalUiHarness('shell');
let parseDone: (() => void) | undefined;
app.terminal.write = (_data: string, callback?: () => void) => {
parseDone = callback;
};
const firstReplay = app.chunkedTerminalWrite('old snapshot');
app._chunkedWriteGen += 1;
parseDone?.();
await expect(firstReplay).resolves.toMatchObject({ completed: false });
});
it('scans xterm rows from buffer.length instead of double-counting baseY', () => {
const { app } = loadTerminalUiHarness('shell');
const getLine = vi.fn((index: number) =>
index === 99 || index === 77 ? { translateToString: () => 'content' } : undefined
);
app.terminal = {
rows: 24,
buffer: { active: { baseY: 76, length: 100, getLine } },
scrollToBottom: vi.fn(),
scrollToLine: vi.fn(),
};
app.scrollToLastNonEmptyLine();
expect(getLine.mock.calls[0]?.[0]).toBe(99);
expect(app.terminal.scrollToLine).toHaveBeenCalledWith(77);
}); });
it('keeps stale buffer load owners from finishing a newer load', () => { it('keeps stale buffer load owners from finishing a newer load', () => {
+57 -1
View File
@@ -675,6 +675,7 @@ describe('TmuxManager (unit)', () => {
sessionId: 'abc12345-1234-5678-90ab-cdef12345678', sessionId: 'abc12345-1234-5678-90ab-cdef12345678',
workingDir: '/mnt/gdrive/project with spaces', workingDir: '/mnt/gdrive/project with spaces',
mode: 'shell', mode: 'shell',
historyLimit: 250_000,
}); });
expect(session.workingDir).toBe('/mnt/gdrive/project with spaces'); expect(session.workingDir).toBe('/mnt/gdrive/project with spaces');
@@ -683,7 +684,9 @@ describe('TmuxManager (unit)', () => {
const newSessionCall = mockedExecSync.mock.calls.find( const newSessionCall = mockedExecSync.mock.calls.find(
([cmd]) => typeof cmd === 'string' && cmd.includes(' new-session ') ([cmd]) => typeof cmd === 'string' && cmd.includes(' new-session ')
); );
expect(newSessionCall?.[0]).toBe(`tmux -L 'codeman' new-session -ds "codeman-abc12345" -c /tmp`); expect(newSessionCall?.[0]).toBe(
`tmux -L 'codeman' set-option -g history-limit 250000 \\; new-session -ds "codeman-abc12345" -c /tmp \\; set-option -t "codeman-abc12345" history-limit 250000`
);
expect(newSessionCall?.[1]).toEqual(expect.objectContaining({ cwd: '/tmp' })); expect(newSessionCall?.[1]).toEqual(expect.objectContaining({ cwd: '/tmp' }));
const respawnCall = mockedExecSync.mock.calls.find( const respawnCall = mockedExecSync.mock.calls.find(
@@ -696,6 +699,59 @@ describe('TmuxManager (unit)', () => {
} }
}); });
it('changes the global history default on tmux versions that cannot resize panes', async () => {
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
const nonTestManager = new NonTestTmuxManager();
try {
await nonTestManager.setHistoryLimit(200_000);
const historyCall = mockedExec.mock.calls.find(
([cmd]) => typeof cmd === 'string' && cmd.includes(' history-limit ')
);
expect(historyCall?.[0]).toBe(`tmux -L 'codeman' set-option -g history-limit 200000`);
expect(historyCall?.[0]).not.toContain(' -t ');
} finally {
nonTestManager.destroy();
}
});
it('targets only the new and tracked sessions on tmux 3.7+', async () => {
mockedExecSync.mockImplementation((cmd: string) => {
if (typeof cmd === 'string' && cmd.endsWith(' -V')) return 'tmux 3.7b\n';
if (typeof cmd === 'string' && cmd.includes('which tmux')) return '/usr/bin/tmux\n';
if (typeof cmd === 'string' && cmd.includes('display-message') && cmd.includes('#{pane_pid}')) return '4242\n';
return '';
});
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
const nonTestManager = new NonTestTmuxManager();
try {
await nonTestManager.createSession({
sessionId: 'def67890-1234-5678-90ab-cdef12345678',
workingDir: '/project',
mode: 'shell',
historyLimit: 250_000,
});
const newSessionCall = mockedExecSync.mock.calls.find(
([cmd]) => typeof cmd === 'string' && cmd.includes(' new-session ')
);
expect(newSessionCall?.[0]).toBe(
`tmux -L 'codeman' new-session -ds "codeman-def67890" -c /tmp \\; set-option -t "codeman-def67890" history-limit 250000`
);
expect(newSessionCall?.[0]).not.toContain('set-option -g');
mockedExec.mockClear();
await nonTestManager.setHistoryLimit(200_000);
const historyCall = mockedExec.mock.calls.find(
([cmd]) => typeof cmd === 'string' && cmd.includes(' history-limit ')
);
expect(historyCall?.[0]).toBe(`tmux -L 'codeman' set-option -t 'codeman-def67890' history-limit 200000`);
expect(historyCall?.[0]).not.toContain('set-option -g');
} finally {
nonTestManager.destroy();
}
});
it('respawns existing panes from /tmp and cd-bounces into the requested workspace', async () => { it('respawns existing panes from /tmp and cd-bounces into the requested workspace', async () => {
const NonTestTmuxManager = await importWithTmuxCommandsEnabled(); const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
const nonTestManager = new NonTestTmuxManager(); const nonTestManager = new NonTestTmuxManager();