Merge pull request #601: fix(terminal): load a shell from tmux's rendered history, not the byte recording

Conflicts resolved against #583 and #588:
- One history-depth field: #583's `historyLines` / `capturedHistoryLines`
  (reported as `paneHistoryLines`) is kept, and #601's lines cut now reads it
  instead of a second `capturedHistorySize`. #601's `alternateOn` /
  `capturedAlternateOn` joins it, and the one display-message format string
  ends `#{history_size} #{alternate_on}`.
- selectSession keeps #588's full=1 switch for panes with no scrollback, gated
  off remote and Docker sessions, and #601's SHELL_LOAD_QUERY for shells.
- computeHistoryTruncationNotice names a line count when paneHistoryLines is
  known (#583) and otherwise uses #601's remainder wording.
- docs: api-reference lists both historyLinesCut and paneHistoryLines; the
  notice invariant keys on history_size while the shell's automatic load is
  the one place alternate_on decides.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-13 17:59:07 +02:00
commit 36f8afdaba
15 files changed
+402 -50

No files matched your search

+38 -1
View File
@@ -80,6 +80,40 @@ describe('computeHistoryTruncationNotice (issue #258)', () => {
expect(notice.message).toContain('more may still be retained');
});
it('names no amount when tmux cut the window by lines before any bytes were counted', () => {
// A shell's `lines=`-bounded load: the server knows tmux holds more, but the
// window IS the whole payload, so fullSize equals what was kept.
const notice = computeHistoryTruncationNotice({
truncated: true,
reason: 'tail',
source: 'mux-full-history',
fullSize: 800 * 1024,
retainedBytes: 800 * 1024,
});
expect(notice.canLoadMore).toBe(true);
expect(notice.message).toContain('Earlier output may still be retained');
expect(notice.message).not.toMatch(/\d+ (B|KB) more/);
});
it('names no amount when tail also cut bytes off a window tmux cut by lines', () => {
// The window was over 1 MiB: `dropped` counts only the bytes tail cut off
// it, while tmux still holds more beyond the window, so that number would
// understate what is left.
const state = {
truncated: true,
reason: 'tail',
source: 'mux-full-history',
fullSize: 3 * 1024 * 1024,
retainedBytes: 1024 * 1024,
};
const windowOnly = computeHistoryTruncationNotice({ ...state, historyLinesCut: true });
expect(windowOnly.canLoadMore).toBe(true);
expect(windowOnly.message).toContain('Earlier output may still be retained');
expect(windowOnly.message).not.toMatch(/\d+(\.\d+)? (B|KB|MB) more/);
// Without a lines cut the byte remainder IS all that is left, and is named.
expect(computeHistoryTruncationNotice(state).message).toContain('2.0 MB more may still be retained');
});
it('promises nothing more once the FULL capture itself hit the ceiling', () => {
// This is the case the old boolean could not express: a full-history pull
// that was still capped means tmux has already given everything it has.
@@ -191,10 +225,13 @@ describe('the in-terminal truncation line is gone (static guard)', () => {
// A shell never takes the full capture on a tab switch. A TUI takes it on its
// first select per page, and on every select while its pane keeps no tmux
// scrollback (behaviour pinned in fullscreen-tab-switch-capture.browser.test.ts).
expect(app).toContain("const shellSession = session?.mode === 'shell';");
expect(app).toContain(
"session?.mode !== 'shell' && (paneKeepsNoHistory || !this._fullHistoryLoaded.has(sessionId))"
'const useFullHistory = !shellSession && (paneKeepsNoHistory || !this._fullHistoryLoaded.has(sessionId));'
);
expect(app).toContain('!session?.remote && !session?.docker && this._paneHistoryLines?.get(sessionId) === 0;');
// The shell's bounded load is tmux's rendered history, never the byte recording.
expect(app).toContain('`/api/sessions/${sessionId}/terminal?${SHELL_LOAD_QUERY}`');
expect(app).toContain("!restoredSnapshot && session?.mode !== 'shell'");
expect(app).toContain('`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`');
// Every terminal capture now goes through _fetchTerminalCapture, which adds
+131
View File
@@ -1201,6 +1201,137 @@ describe('session-routes', () => {
expect(captureSpy).toHaveBeenCalledWith(harness.ctx._session.muxName, {});
});
// A shell's tab-select load reads a `lines=` window SMALLER than xterm's
// scrollback, so a cut there is real: the client must hear that tmux holds
// more, or it treats the window as the whole history and scroll-to-top
// stops offering the rest. tmux reports its history depth on the capture.
it('reports a `lines=` cut of tmux history as a recoverable tail truncation', async () => {
harness.ctx._session.mode = 'shell';
const lineCountFor = async (lines: number, historySize: number | undefined) => {
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
(_name: string, opts?: { capturedHistoryLines?: number }) => {
if (opts && historySize !== undefined) opts.capturedHistoryLines = historySize;
return 'captured rows\r\nlast row';
}
);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal?full=1&tail=${1024 * 1024}&lines=${lines}`,
});
const data = JSON.parse(res.body).data;
return { truncated: data.truncated, reason: data.truncationReason, linesCut: data.historyLinesCut };
};
expect(await lineCountFor(10000, 25000)).toEqual({ truncated: true, reason: 'tail', linesCut: true });
expect(await lineCountFor(10000, 10000)).toEqual({ truncated: false, reason: null, linesCut: false });
expect(await lineCountFor(10000, 400)).toEqual({ truncated: false, reason: null, linesCut: false });
// No depth reported (an older tmux, a failed query): nothing is claimed.
expect(await lineCountFor(10000, undefined)).toEqual({ truncated: false, reason: null, linesCut: false });
});
// Without `lines=` the bound is the configured ceiling. On tmux before 3.7 a
// live pane keeps history deeper than a lowered ceiling, which no request
// can read, so reporting it as a recoverable cut would offer Load full
// history on a pull that can never return more.
it('reports no cut for a full capture without `lines=`, nor for `lines=` at the ceiling', async () => {
harness.ctx._session.mode = 'shell';
const limit = (await harness.ctx.getTerminalHistoryConfig()).tmuxHistoryLimit;
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
(_name: string, opts?: { capturedHistoryLines?: number }) => {
if (opts) opts.capturedHistoryLines = limit * 5;
return 'captured rows\r\nlast row';
}
);
for (const query of ['full=1', `full=1&tail=${1024 * 1024}`, `full=1&lines=${limit}`]) {
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal?${query}`,
});
const data = JSON.parse(res.body).data;
expect({ truncated: data.truncated, reason: data.truncationReason }, query).toEqual({
truncated: false,
reason: null,
});
}
});
// A nested tmux (remote SSH and Docker cases, or `tmux` run by hand in a
// shell) holds the pane in the alternate screen, where tmux keeps no
// history: the full capture is one screen. `alt=recording` (a shell's
// automatic load) then gets the visible frame over the byte recording,
// which still carries the scrollback.
describe('alt=recording on a pane in the alternate screen', () => {
const install = (alternateOn: boolean | undefined) => {
const spy = vi.fn((_name: string, opts?: { fullHistory?: boolean; capturedAlternateOn?: boolean }) => {
if (opts?.fullHistory) {
if (alternateOn !== undefined) opts.capturedAlternateOn = alternateOn;
return 'one screen of full capture';
}
return 'visible frame';
});
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = spy;
return spy;
};
const load = async (query: string) => {
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal?${query}`,
});
return JSON.parse(res.body).data;
};
beforeEach(() => {
harness.ctx._session.mode = 'shell';
harness.ctx._session.terminalBuffer = 'recorded scrollback\r\n';
});
it('falls back to the visible frame over the tailed byte recording', async () => {
const spy = install(true);
const data = await load(`full=1&tail=${1024 * 1024}&alt=recording&lines=10000`);
expect(spy).toHaveBeenCalledTimes(2);
expect(spy.mock.calls[1][1]).toEqual({});
expect(data.source).toBe('mux-visible');
expect(data.terminalBuffer).toContain('recorded scrollback');
expect(data.terminalBuffer).toContain('visible frame');
expect(data.terminalBuffer).not.toContain('one screen of full capture');
});
it('keeps the full capture when the pane is in the normal screen or tmux did not say', async () => {
for (const alternateOn of [false, undefined]) {
const spy = install(alternateOn);
const data = await load(`full=1&tail=${1024 * 1024}&alt=recording&lines=10000`);
expect(spy, String(alternateOn)).toHaveBeenCalledTimes(1);
expect(data.source).toBe('mux-full-history');
expect(data.terminalBuffer).toContain('one screen of full capture');
}
});
it('keeps the full capture without the opt-in (agents, Load full history, scroll pulls)', async () => {
const spy = install(true);
const data = await load(`full=1&tail=${1024 * 1024}&lines=10000`);
expect(spy).toHaveBeenCalledTimes(1);
expect(data.source).toBe('mux-full-history');
});
});
it('lets the byte cap outrank a `lines=` cut: the oldest bytes of the window are gone for good', async () => {
harness.ctx._session.mode = 'shell';
const { terminalBufferMaxBytes } = await harness.ctx.getTerminalHistoryConfig();
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
(_name: string, opts?: { capturedHistoryLines?: number }) => {
if (opts) opts.capturedHistoryLines = 25000;
return `${'x'.repeat(100)}\r\n`.repeat(Math.ceil(terminalBufferMaxBytes / 102) + 10);
}
);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal?full=1&lines=10000`,
});
const data = JSON.parse(res.body).data;
expect(data.truncated).toBe(true);
expect(data.truncationReason).toBe('capped');
});
it('a capture bounded by `lines=` is still a full capture: rows kept, cursor restore last', async () => {
// Only how much history tmux reads changes. The row-preserving skips key
// on isFullCapture, and the relative cursor move must still end it.
+26 -1
View File
@@ -159,13 +159,38 @@ describe('terminal flush budget', () => {
const first = app._onSessionNeedsRefresh({ id: 'session-1' });
const duplicate = app._onSessionNeedsRefresh({ id: 'session-1' });
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchMock).toHaveBeenCalledWith('/api/sessions/session-1/terminal?tail=1048576');
expect(fetchMock).toHaveBeenCalledWith(
'/api/sessions/session-1/terminal?full=1&tail=1048576&alt=recording&lines=10000'
);
releaseFetch();
await Promise.all([first, duplicate]);
expect(app._terminalRefreshOwner).toBe(null);
});
it("reloads a cleared shell from tmux's rendered history and an agent from the byte recording", async () => {
const { CodemanApp, fetchMock } = loadAppHarness();
const app = Object.create(CodemanApp.prototype) as any;
app.sessions = new Map([
['shell-1', { mode: 'shell' }],
['agent-1', { mode: 'claude' }],
]);
app._isLoadingBuffer = false;
app._resetTerminalForReplay = vi.fn();
app.sendResize = vi.fn();
fetchMock.mockResolvedValue({ json: async () => ({ data: { terminalBuffer: '' } }) });
app.activeSessionId = 'shell-1';
await app._onSessionClearTerminal({ id: 'shell-1' });
app.activeSessionId = 'agent-1';
await app._onSessionClearTerminal({ id: 'agent-1' });
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
'/api/sessions/shell-1/terminal?full=1&tail=1048576&alt=recording&lines=10000',
'/api/sessions/agent-1/terminal',
]);
});
it('drains a large final batch without waiting for unrelated terminal output', () => {
const { app, writes } = loadTerminalUiHarness('codex');
const scheduled: Array<() => void> = [];
+8 -6
View File
@@ -24,6 +24,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
const TERMINAL_CHUNK_SIZE = 32 * 1024;
const TERMINAL_TAIL_SIZE = 1024 * 1024;
// constants.js: a shell loads tmux's rendered history, bounded, never the raw byte recording.
const SHELL_LOAD_BASE_QUERY = `full=1&tail=${TERMINAL_TAIL_SIZE}&alt=recording`;
const SHELL_LOAD_QUERY = `${SHELL_LOAD_BASE_QUERY}&lines=10000`;
/** The pane's `performance.now()`, so frame arrival vs. capture time is set by hand, not raced. */
let clock = 0;
@@ -108,6 +111,8 @@ function loadTerminalTile() {
// The constants.js globals the module reads at call time.
TERMINAL_CHUNK_SIZE,
TERMINAL_TAIL_SIZE,
SHELL_LOAD_BASE_QUERY,
SHELL_LOAD_QUERY,
});
// The module's tail patches CodemanApp.prototype; nothing on it runs here.
vm.runInContext(`class CodemanApp { _onSessionDeleted() {} selectSession() {} }\n${SOURCE}`, context);
@@ -266,14 +271,14 @@ describe('TerminalTile server-refresh single-flight', () => {
expect(pane._bufferLoading).toBe(false);
});
it('a shell pane asks for the bounded tail, matching connect()', async () => {
it("a shell pane asks for a bounded window of tmux's rendered history, matching connect()", async () => {
const pane = makePane('shell');
fetchMock.mockResolvedValueOnce(jsonResponse('tail'));
pane._refreshBuffer();
await settle();
expect(fetchMock).toHaveBeenCalledWith(`/api/sessions/s1/terminal?tail=${1024 * 1024}`, expect.anything());
expect(fetchMock).toHaveBeenCalledWith(`/api/sessions/s1/terminal?${SHELL_LOAD_QUERY}`, expect.anything());
});
it('refreshes arriving mid-fetch neither reset nor fetch again, and run ONCE after the replay lands', async () => {
@@ -865,10 +870,7 @@ describe('TerminalTile scroll-to-top history pull', () => {
// current screen and replays it last.
expect(order.slice(0, 2)).toEqual(['write:before', 'write:after']);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenLastCalledWith(
`/api/sessions/s1/terminal?tail=${TERMINAL_TAIL_SIZE}`,
expect.anything()
);
expect(fetchMock).toHaveBeenLastCalledWith(`/api/sessions/s1/terminal?${SHELL_LOAD_QUERY}`, expect.anything());
expect(order.at(-1)).toBe('write:after startup');
expect(pane._liveQueue).toBeNull();
expect(pane._bufferLoading).toBe(false);
+2 -2
View File
@@ -216,14 +216,14 @@ describe('initial loads', () => {
expect(states.filter(([id]) => id === 'b').map(([, s]) => s)).toEqual(['queued', 'running', 'idle']);
});
it('loads a bounded window: full=1&tail= for a TUI, tail= for a shell', async () => {
it("loads a bounded window of tmux's rendered history, bounded by the tile's own lines for a TUI and a shell", async () => {
const { tiles } = makeGrid(['tui', 'sh'], { modes: { sh: 'shell' } });
const connecting = tiles.map((t) => t.connect());
await drain();
await Promise.all(connecting);
expect(captures.map((c) => c.url)).toEqual([
`/api/sessions/tui/terminal?full=1&tail=${TAIL}${LINES}`,
`/api/sessions/sh/terminal?tail=${TAIL}`,
`/api/sessions/sh/terminal?full=1&tail=${TAIL}&alt=recording${LINES}`,
]);
});
+45 -1
View File
@@ -160,7 +160,9 @@ describe('the capture reports how much scrollback tmux holds (#{history_size})',
it('reads it from the one cursor query the capture already makes', () => {
// No extra tmux call: every capture is a synchronous exec on the server.
expect(methodBody).toContain("'#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size}'");
expect(methodBody).toContain(
"'#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size} #{alternate_on}'"
);
expect(methodBody.match(/display-message/g)).toHaveLength(1);
expect(methodBody).toContain('opts.capturedHistoryLines = geometry.historyLines');
});
@@ -213,3 +215,45 @@ describe('why a capture has to report its height', () => {
expect(addressed.filter((row) => row > 30)).toHaveLength(20);
});
});
describe('queryPaneCursor history depth', () => {
it('reads tmux history_size as an optional fifth field', () => {
expect(queryPaneCursor(() => '4 22 133 24 25000\n')).toEqual({
cols: 133,
rows: 24,
cursorX: 4,
cursorY: 22,
historyLines: 25000,
});
});
it('keeps the geometry and omits the depth when the fifth field is missing or invalid', () => {
for (const raw of ['4 22 133 24', '4 22 133 24 abc', '4 22 133 24 -1']) {
const geometry = queryPaneCursor(() => raw);
expect(geometry, raw).toEqual({ cols: 133, rows: 24, cursorX: 4, cursorY: 22 });
}
});
it('reads tmux alternate_on as an optional sixth field', () => {
expect(queryPaneCursor(() => '4 22 133 24 29 1\n')).toEqual({
cols: 133,
rows: 24,
cursorX: 4,
cursorY: 22,
historyLines: 29,
alternateOn: true,
});
expect(queryPaneCursor(() => '4 22 133 24 29 0')?.alternateOn).toBe(false);
for (const raw of ['4 22 133 24 29', '4 22 133 24 29 2', '4 22 133 24 29 x']) {
expect(
queryPaneCursor(() => raw),
raw
).not.toHaveProperty('alternateOn');
}
});
it('asks tmux for the history depth and alternate screen in the same display-message as the cursor', () => {
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
expect(source).toContain("'#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size} #{alternate_on}'");
});
});