Merge pull request #331 from dignfei/fix/shell-history-performance

fix(terminal): bound shell history replay
This commit is contained in:
Ark0N
2026-08-21 20:23:17 +02:00
committed by GitHub
16 changed files with 414 additions and 138 deletions
+132 -37
View File
@@ -536,10 +536,10 @@ 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
// 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).
// Non-shell sessions whose full tmux scrollback has already been replayed this
// page load (COD-47). Shells deliberately start from a bounded tail because
// their scrollback can be very large; full history stays available on demand.
// Tracked PER SESSION rather than as a single "first load" flag (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>
@@ -5268,6 +5268,22 @@ class CodemanApp {
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
* while already at the top of what the browser has.
@@ -5296,6 +5312,11 @@ class CodemanApp {
const sessionId = this.activeSessionId;
if (!sessionId || this._fullHistoryRepullInFlight || this._isLoadingBuffer) return;
if (this.detachedSessions?.has(sessionId)) return;
const session = this.sessions.get(sessionId);
// A shell's full capture can be many megabytes. Replaying it from an
// ordinary scroll gesture blocks xterm's main thread, so keep that cost
// behind the explicit "Load full history" button.
if (!force && session?.mode === 'shell') 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.
@@ -5307,13 +5328,32 @@ class CodemanApp {
this._fullHistoryRepullAt.set(sessionId, now);
this._fullHistoryRepullInFlight = true;
try {
const requestStartedAt = performance.now();
const res = await fetch(`/api/sessions/${sessionId}/terminal?full=1`);
const headersReceivedAt = performance.now();
const payload = (await res.json())?.data ?? {};
const bodyParsedAt = performance.now();
const buffer = payload.terminalBuffer;
const timing = {
trigger: force ? 'full-history-button' : 'full-history-scroll',
mode: session?.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
// history into the terminal the user is now looking at.
if (!buffer || this.activeSessionId !== sessionId) return;
if (this._replayWouldShrinkBuffer(buffer)) {
timing.refused = true;
timing.totalMs = performance.now() - requestStartedAt;
this._recordTerminalLoadTiming(timing);
(this._fullHistoryRepullUseless ||= new Set()).add(sessionId);
this._logScrollRouting?.('repull-refused-downgrade');
// The browser already holds more than tmux can give back, so there is
@@ -5324,17 +5364,32 @@ class CodemanApp {
this._setHistoryTruncation(sessionId, payload);
this._fullHistoryRepullUseless?.delete(sessionId);
const rowsBefore = this.terminal.buffer.active.length;
const replayStartedAt = performance.now();
this._resetTerminalForReplay();
await this.chunkedTerminalWrite(buffer, TERMINAL_CHUNK_SIZE, sessionId);
if (this.activeSessionId !== sessionId) return;
this.terminalBufferCache.set(sessionId, buffer);
const {
parsedAt,
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
// 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;
const delta = parsedBufferLength - rowsBefore;
if (delta > 0) this.terminal.scrollToLine(delta);
else this.terminal.scrollToTop();
timing.totalMs = performance.now() - requestStartedAt;
this._recordTerminalLoadTiming(timing);
} catch {
// Transient (offline, 5xx) — the next scroll-up past the cooldown retries.
} finally {
@@ -5614,6 +5669,7 @@ class CodemanApp {
// 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.
let bufferWasEmpty = false;
let cacheResetAndParseMs = 0;
try {
// Fit terminal to container BEFORE writing any buffer data.
// If the browser was resized while viewing another session, the terminal
@@ -5691,23 +5747,30 @@ class CodemanApp {
// blank and rewrites with fresh data. Skip the cache and write the fresh
// buffer once for a single clean transition.
const cachedBuffer = this.terminalBufferCache.get(sessionId);
let clearedForBusy = false;
if (cachedBuffer && !sessionIsBusy && !restoredSnapshot) {
let clearedBeforeFresh = false;
if (cachedBuffer && !sessionIsBusy && !restoredSnapshot && session?.mode !== 'shell') {
_crashDiag.log(`CACHE_WRITE: ${(cachedBuffer.length/1024).toFixed(0)}KB`);
this._setTerminalLoadState(sessionId, selectGen, 'replaying');
const cacheReplayStartedAt = performance.now();
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)) {
this._clearTerminalLoadState(sessionId, selectGen);
return;
}
this.terminal.scrollToBottom();
_crashDiag.log('CACHE_DONE');
} else if (sessionIsBusy) {
// Clear stale content immediately — fresh buffer is being fetched
} else if (sessionIsBusy || session?.mode === 'shell') {
// 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();
clearedForBusy = true;
_crashDiag.log('CACHE_SKIP_BUSY');
clearedBeforeFresh = true;
_crashDiag.log(session?.mode === 'shell' ? 'CACHE_SKIP_SHELL' : 'CACHE_SKIP_BUSY');
}
// Give TUI sessions a short chance to redraw after resize before the
@@ -5725,26 +5788,29 @@ class CodemanApp {
this._setTerminalLoadState(sessionId, selectGen, 'fetching');
_crashDiag.log('FETCH_START');
// 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);
// TUI sessions still get one canonical full replay per page (COD-47/#205).
// A shell can retain hundreds of thousands of plain scrollback lines, so
// automatically replaying all of them makes tab selection scale with the
// entire session. Load its bounded 1MB tail first; the existing truncation
// banner action fetches ?full=1 when the user explicitly asks for it.
const useFullHistory = session?.mode !== 'shell' && !this._fullHistoryLoaded.has(sessionId);
if (useFullHistory) this._fullHistoryLoaded.add(sessionId);
const fetchStartedAt = performance.now();
const res = await fetch(
useFullHistory
? `/api/sessions/${sessionId}/terminal?full=1`
: `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`
);
const headersReceivedAt = performance.now();
if (this._isStaleSelect(selectGen)) {
this._clearTerminalLoadState(sessionId, selectGen);
return;
}
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}`);
let freshResetAndParseMs = 0;
if (data.terminalBuffer) {
// Skip rewrite if fresh buffer matches cache — avoids visible clear+rewrite flash.
// On slow connections (mobile 5G), the gap between clear() and chunkedWrite() is
@@ -5753,10 +5819,11 @@ class CodemanApp {
// something other than the cache, so the fetched buffer must be
// replayed even when it byte-matches the cache.
const needsRewrite =
restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer;
restoredSnapshot || clearedBeforeFresh || data.terminalBuffer !== cachedBuffer;
if (needsRewrite) {
_crashDiag.log(`REWRITE: ${(data.terminalBuffer.length/1024).toFixed(0)}KB`);
this._setTerminalLoadState(sessionId, selectGen, 'replaying');
const replayStartedAt = performance.now();
this._resetTerminalForReplay();
// Truncation is reported OUT OF BAND (#258). This used to write a grey
// "... earlier output truncated ..." line into the
@@ -5764,7 +5831,12 @@ class CodemanApp {
// cannot be actioned, and is indistinguishable from real CLI output.
this._setHistoryTruncation(sessionId, data);
// 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)) {
this._clearTerminalLoadState(sessionId, selectGen);
return;
@@ -5773,22 +5845,42 @@ class CodemanApp {
this.terminal.scrollToBottom();
}
// 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);
// Shell selection always uses a fresh bounded tail, so retaining its
// payload only wastes memory and can evict useful TUI caches.
if (session?.mode === 'shell') {
this.terminalBufferCache.delete(sessionId);
} else {
// 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) {
// No fresh buffer and no cache — clear any stale content
this._resetTerminalForReplay();
} else if (!cachedBuffer || clearedBeforeFresh) {
// Nothing was painted. If this path was not already cleared above,
// clear stale content now; either way queued live output must be flushed.
if (!clearedBeforeFresh) this._resetTerminalForReplay();
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
// _finishBufferLoad internally (discarding queued events to prevent duplicate
// content); if we skipped the write (cache hit or empty), call it here.
// _finishBufferLoad after ordering the fetched snapshot in xterm; if we skipped
// the write (cache hit or empty), call it here.
// 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.
if (this._isLoadingBuffer) {
@@ -5917,9 +6009,12 @@ class CodemanApp {
if (typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible) {
KeyboardHandler.onKeyboardShow();
}
const selectDoneMs = performance.now() - _selStart;
terminalLoadTiming.selectDoneMs = selectDoneMs;
this._recordTerminalLoadTiming(terminalLoadTiming);
this._clearTerminalLoadState(sessionId, selectGen);
_crashDiag.log(`SELECT_DONE: ${(performance.now() - _selStart).toFixed(0)}ms`);
console.log(`[CRASH-DIAG] selectSession DONE: ${sessionId.slice(0,8)} in ${(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 ${selectDoneMs.toFixed(0)}ms`);
} catch (err) {
if (this._isLoadingBuffer) this._finishBufferLoad(bufferLoadOwner);
this._restoringFlushedState = false;
+40 -41
View File
@@ -3046,12 +3046,12 @@ 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.
* buffer while scrolling up gives the app a chance to pull the rest of tmux's
* scrollback (issue #205, see _maybeRefetchFullHistory). Shell sessions decline
* automatic pulls because their captures can be large; their banner button is
* the explicit path. 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.
*/
_maybeLoadMoreHistoryOnScroll(lines) {
if (lines >= 0) return;
@@ -3605,7 +3605,9 @@ Object.assign(CodemanApp.prototype, {
}
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;
for (let lineIndex = totalLines - 1; lineIndex >= 0; lineIndex--) {
@@ -3635,8 +3637,8 @@ Object.assign(CodemanApp.prototype, {
* Uses _safeYield to spread work across frames; falls back to setTimeout
* and a tick-Worker so progress continues on occluded / idle-throttled tabs.
* @param {string} buffer - The full terminal buffer to write
* @param {number} chunkSize - Size of each chunk (default 128KB for smooth 60fps)
* @returns {Promise<void>} - Resolves when all chunks written
* @param {number} chunkSize - Size of each chunk (default 32KB)
* @returns {Promise<{parsedAt: number, bufferLength: number, completed: boolean}>} Parse marker snapshot
*/
chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE, loadOwner) {
// Generation counter: if a newer chunkedTerminalWrite starts (tab switch),
@@ -3645,9 +3647,14 @@ Object.assign(CodemanApp.prototype, {
const bufferLoadOwner = this._beginBufferLoad(loadOwner);
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) {
this._finishBufferLoad(bufferLoadOwner);
resolve();
resolve(parseSnapshot());
return;
}
@@ -3655,53 +3662,45 @@ Object.assign(CodemanApp.prototype, {
// (from historical SSE data that was stored with markers)
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
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;
}
// Large buffers: write in chunks across animation frames.
// Each 32KB chunk keeps per-frame WebGL render work under ~5ms,
// avoiding GPU stalls without needing to toggle the renderer.
// Large buffers: enqueue paced chunks, then append an empty marker whose
// callback fires after xterm parses every preceding chunk. The live-output
// 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;
const _chunkStart = performance.now();
let _chunkCount = 0;
const writeChunk = () => {
// Abort if a newer chunked write started (user switched tabs)
if (this._chunkedWriteGen !== writeGen) {
resolve();
resolve(parseSnapshot(false));
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);
this.terminal.write(chunk);
const _cdt = performance.now() - _ct0;
offset += chunk.length;
_chunkCount++;
if (_cdt > 50)
console.warn(
`[CRASH-DIAG] chunk #${_chunkCount} write took ${_cdt.toFixed(0)}ms (${chunk.length} bytes at offset ${offset})`
);
offset += chunkSize;
this.terminal.write(chunk);
if (offset >= cleanBuffer.length) {
this.terminal.write('', () => {
const result = parseSnapshot();
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
// 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 fs from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
import { performance } from 'node:perf_hooks';
import {
ApiErrorCode,
createErrorResponse,
@@ -2279,18 +2280,17 @@ export function registerSessionRoutes(
// Query params:
// tail=<bytes> - Only return last N bytes (faster initial load)
// full=1 - Full page reload: replay the entire tmux scrollback (COD-47)
app.get('/api/sessions/:id/terminal', async (req) => {
// full=1 - Explicitly request the entire tmux scrollback (COD-47)
app.get('/api/sessions/:id/terminal', async (req, reply) => {
const routeStartedAt = performance.now();
const { id } = req.params as { id: string };
const query = req.query as { tail?: string; full?: string };
const session = findSessionOrFail(ctx, id, req);
// `full=1` is the EXPLICIT full-reload signal (COD-47): the browser reloaded
// the page and wants the whole scroll history back, so we capture the ENTIRE
// tmux scrollback and the user gets back history that scrolled off Codeman's
// byte buffer. Requests WITHOUT it — tab switches (`tail=`) and the legacy
// no-param callers (response-viewer fallback, clearTerminal refresh) — keep
// the fast visible-frame capture.
// `full=1` is the EXPLICIT full-history signal (COD-47): capture the ENTIRE
// tmux scrollback so history beyond the server byte buffer can be recovered.
// Requests WITHOUT it — shell selection/tab switches (`tail=`) and legacy
// no-param callers — keep the fast visible-frame capture.
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
const isFullReload = query.full === '1' || query.full === 'true';
const { tmuxHistoryLimit, terminalBufferMaxBytes } = await ctx.getTerminalHistoryConfig();
@@ -2303,6 +2303,7 @@ export function registerSessionRoutes(
// overlap. `captureActivePaneBuffer` is a no-op ('') under test mode and
// returns null when unavailable, in which case we fall back to history.
const muxName = session.muxName;
const captureStartedAt = performance.now();
const liveMuxBuffer =
muxName && typeof ctx.mux.captureActivePaneBuffer === 'function'
? ctx.mux.captureActivePaneBuffer(
@@ -2312,6 +2313,7 @@ export function registerSessionRoutes(
: undefined
)
: null;
const captureFinishedAt = performance.now();
const hasLiveMuxBuffer = liveMuxBuffer !== null && liveMuxBuffer.length > 0;
const source: 'history' | 'mux-visible' | 'mux-full-history' = hasLiveMuxBuffer
? isFullReload
@@ -2415,6 +2417,14 @@ export function registerSessionRoutes(
// Remove Ctrl+L and leading whitespace (cheap on tailed subset)
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 {
terminalBuffer: cleanBuffer,
status: session.status,
+2 -1
View File
@@ -728,7 +728,8 @@ export function registerSystemRoutes(
const merged = { ...existing, ...settingsToStore };
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) {
await ctx.mux.setHistoryLimit(resolveTerminalHistoryConfig(merged).tmuxHistoryLimit);
}