Compare commits

...
Author SHA1 Message Date
Codeman maintainer e2453fe97b fix(terminal): partial-history notice only when there is history to load
The "Showing the most recent 1.0 MB of this session. 4.8 MB more may still
be retained." bar covered the top of the terminal on nearly every tab switch,
and for fullscreen claude panes it could never be acted on: `truncated`
measures the server's byte stream, while Load full history asks tmux, which
holds no scrollback for a pane that lives in the alternate screen.

- The capture reads `#{history_size}` in the cursor query it already makes
  and the terminal route returns it as `paneHistoryLines`.
- A reported 0 hides the notice; a positive count replaces the byte gap in
  the message.
- The notice is lazy: it appears once a wheel/touch gesture reaches the top
  of the browser buffer (after the pull that gesture starts), leaves when
  the user scrolls back to live output or switches tabs, and its dismissal
  sticks per session until reload.
- formatHistoryBytes no longer prints "1024 KB" just under 1 MiB.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 05:56:37 +02:00
14 changed files with 561 additions and 15 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'aicodeman': patch
---
The "Showing the most recent 1.0 MB of this session" notice no longer appears on every tab switch. It shows up only when you scroll to the top of a terminal, leaves when you scroll back down, and stays closed for that tab once you dismiss it. Sessions with nothing more to load (fullscreen Claude, whose history lives in Claude itself) never show it, and when there is more, it states the scrollback line count instead of an inflated byte figure. `GET /api/v1/sessions/:id/terminal` reports the new `paneHistoryLines` field.
+7 -2
View File
@@ -457,8 +457,13 @@ client that opens many concurrent waits against one session will still hit the c
What a session's terminal shows, for a client to replay: `data.terminalBuffer`,
with `source` (`mux-visible`, `mux-full-history` or `history`), `truncated`,
`truncationReason`, `fullSize`, and `captureCols`/`captureRows` when the pane's
geometry was read. The capture runs synchronous tmux calls on the server; the
`Server-Timing` header reports `capture`, `prepare` and `total`.
geometry was read. `paneHistoryLines` (present whenever the body is a pane capture)
is the number of scrollback rows tmux holds above the visible frame, which is the
most a `full=1` request can add. `truncated` describes the byte stream instead: for a
pane with `paneHistoryLines: 0` (a fullscreen CLI in the alternate screen) the bytes a
`tail` cut dropped are earlier repaints that no request returns. The capture runs
synchronous tmux calls on the server; the `Server-Timing` header reports `capture`,
`prepare` and `total`.
| Query | Meaning |
|---|---|
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -212,7 +212,10 @@ Worth knowing:
- **Scrollback.** Agent/TUI sessions pull their entire tmux scrollback on first open.
Shell sessions open from a bounded recent tail so a large transcript cannot stall tab
switching. Scrolling to the top of a Shell pane pulls the most recent 1 MiB of its tmux
history; press **Load full history** to pull the rest explicitly. Automatic output
history; press **Load full history** to pull the rest explicitly. That notice only
appears once you scroll to the top, leaves when you scroll back down, and stays away
for that tab once you close it. Sessions whose CLI keeps its own history (fullscreen
Claude) never show it, since there is nothing more to load. Automatic output
recovery stays within the bounded browser buffer.
- **Wheel and touch scrolling** are forwarded into Claude's own transcript when a recent
Claude runs fullscreen (`CLAUDE_CODE_NO_FLICKER=1`, or `"tui": "fullscreen"` in
+9
View File
@@ -204,6 +204,15 @@ export interface PaneCaptureOptions {
* rendering it needs the real height to know the frame fits.
*/
capturedGeometry?: { cols: number; rows: number };
/**
* Filled in by the implementation with the number of rows the pane holds in
* scrollback ABOVE the visible frame (tmux `#{history_size}`), read in the
* same query as the geometry. 0 means a full-history capture can return
* nothing beyond the visible frame: a pane in the alternate screen (a
* fullscreen CLI that keeps its transcript itself) never accumulates any.
* Absent when the pane could not be queried.
*/
capturedHistoryLines?: number;
}
/**
+15 -3
View File
@@ -700,6 +700,12 @@ interface PaneCursorGeometry {
rows: number;
cursorX: number;
cursorY: number;
/**
* `#{history_size}`: rows tmux holds ABOVE the visible frame, i.e. what a
* full-history capture can add. Absent when the query did not return it.
* Optional and validated on its own, so a bad value never voids the caret.
*/
historyLines?: number;
}
/**
@@ -716,7 +722,7 @@ export function queryPaneCursor(run: () => string): PaneCursorGeometry | null {
console.error('[TmuxManager] Failed to query pane cursor after capture:', cursorErr);
return null;
}
const [cursorX, cursorY, cols, rows] = raw.split(/\s+/).map((value) => parseInt(value, 10));
const [cursorX, cursorY, cols, rows, historyLines] = raw.split(/\s+/).map((value) => parseInt(value, 10));
if (
!Number.isFinite(cursorX) ||
!Number.isFinite(cursorY) ||
@@ -729,7 +735,9 @@ export function queryPaneCursor(run: () => string): PaneCursorGeometry | null {
) {
return null;
}
return { cols, rows, cursorX, cursorY };
const geometry: PaneCursorGeometry = { cols, rows, cursorX, cursorY };
if (Number.isFinite(historyLines) && historyLines >= 0) geometry.historyLines = historyLines;
return geometry;
}
/** SGR attributes, which is all `capture-pane -e` emits. */
@@ -3890,7 +3898,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// to keep when a move follows to put the caret back above them.
const geometry = queryPaneCursor(() =>
execSync(
`${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}'`,
`${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size}'`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
)
);
@@ -3902,6 +3910,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// geometry is reported there for diagnosis rather than for repair. Only
// the caller can see both sizes, so hand it this one.
if (opts && geometry) opts.capturedGeometry = { cols: geometry.cols, rows: geometry.rows };
// Same query, no extra tmux call: how much scrollback a full-history pull
// could return. A pane in the alternate screen (fullscreen claude) holds
// none, and the partial-history notice must not promise it.
if (opts && geometry?.historyLines !== undefined) opts.capturedHistoryLines = geometry.historyLines;
if (fullHistory) {
// Without geometry there is no cursor move, so fall back to the old trim.
+38 -3
View File
@@ -8689,6 +8689,9 @@ class CodemanApp {
// Set once a full-history pull has been refused as a downgrade: the
// browser holds more than the server can return, so there is no more.
exhausted: !!payload.exhausted,
// tmux scrollback above the frame (null = not reported). 0 is a pane with
// nothing a pull could add, which hides the notice outright.
paneHistoryLines: payload.paneHistoryLines ?? null,
});
if (sessionId === this.activeSessionId) this._renderHistoryTruncationBanner();
}
@@ -8696,9 +8699,27 @@ class CodemanApp {
/** Drop banner state for a session that is going away. */
_clearHistoryTruncation(sessionId) {
this._historyTruncation?.delete(sessionId);
this._historyNoticeDismissed?.delete(sessionId);
if (sessionId === this.activeSessionId) this._renderHistoryTruncationBanner();
}
/**
* Bring the partial-history notice up for `sessionId`, or retire it (null).
*
* The notice is LAZY: a tail replay is truncated on nearly every tab switch,
* and a bar over the top rows on every switch described history the user had
* not reached for. It now waits for the scroll gesture that reaches the top of
* the browser's buffer (the moment the missing part matters, and the same
* gesture that already re-pulls history), and goes away again once the user
* scrolls back down to live output. A tab switch retires it (selectSession).
*/
_setHistoryNoticeRevealed(sessionId) {
const next = sessionId || null;
if ((this._historyNoticeRevealedFor ?? null) === next) return;
this._historyNoticeRevealedFor = next;
this._renderHistoryTruncationBanner();
}
/**
* Paint the partial-history banner for the active session.
*
@@ -8708,13 +8729,22 @@ class CodemanApp {
* - recoverable → offer to load the rest
* - exhausted → say so plainly, offer nothing
* - at the limit → the full capture ITSELF hit the byte ceiling
*
* Shown only while revealed (`_setHistoryNoticeRevealed`: the user scrolled
* to the top of this tab's buffer) and never again for a session whose notice
* the user dismissed on this page.
*/
_renderHistoryTruncationBanner() {
const bar = document.getElementById('historyTruncationBar');
if (!bar) return;
const state = this.activeSessionId ? this._historyTruncation?.get(this.activeSessionId) : null;
const sessionId = this.activeSessionId;
const state = sessionId ? this._historyTruncation?.get(sessionId) : null;
const notice = computeHistoryTruncationNotice(state || {});
if (!notice.visible) {
if (
!notice.visible ||
this._historyNoticeRevealedFor !== sessionId ||
this._historyNoticeDismissed?.has(sessionId)
) {
bar.hidden = true;
return;
}
@@ -8747,6 +8777,9 @@ class CodemanApp {
dismiss.setAttribute('aria-label', 'Dismiss history notice');
dismiss.textContent = '×';
dismiss.onclick = () => {
// Sticky for this session until the page reloads: a dismissed notice used
// to come straight back on the next tab switch.
(this._historyNoticeDismissed ||= new Set()).add(sessionId);
bar.hidden = true;
};
bar.appendChild(dismiss);
@@ -8860,7 +8893,9 @@ class CodemanApp {
this._activateFileBrowserSession?.(sessionId);
// Repaint the partial-history banner for the tab being switched TO. The
// replay paths refresh it when their fetch lands; without this the previous
// session's notice stays on screen until then (#258).
// session's notice stays on screen until then (#258). The switch lands at
// live output, so the notice waits for a scroll to the top again.
this._historyNoticeRevealedFor = null;
this._renderHistoryTruncationBanner();
try { localStorage.setItem('codeman-active-session', sessionId); } catch {}
// Narrow SSE filter to the active session — server stops streaming
+27 -3
View File
@@ -1744,7 +1744,10 @@ function escapeHtml(text) {
function formatHistoryBytes(bytes) {
const n = typeof bytes === 'number' && isFinite(bytes) && bytes > 0 ? bytes : 0;
if (n < 1024) return 'less than 1 KB';
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
// Switch on the ROUNDED value: a 1 MiB tail cut back to a line boundary is
// just under 1 MiB and used to print as "1024 KB".
const kb = Math.round(n / 1024);
if (kb < 1024) return `${kb} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
@@ -1758,12 +1761,27 @@ function formatHistoryBytes(bytes) {
* - atCeiling: the FULL capture itself hit the byte ceiling
* - exhausted: a full pull was refused as a downgrade, so this is all there is
*
* ⚠️ `truncated` measures the server's BYTE stream, not history a pull can
* return. `paneHistoryLines` (tmux `#{history_size}`) is the latter: 0 means the
* pane keeps no scrollback at all (a fullscreen CLI in the alternate screen,
* whose transcript lives in the CLI and is scrolled there), so the bytes a tail
* cut dropped are old repaint frames that `full=1` cannot bring back. Measured
* on live fullscreen claude panes: "4.8 MB more" was ~33 copies of one frame,
* and the button only ever ended in the downgrade refusal. Nothing to offer,
* nothing to say. Absent means unknown and keeps the byte-based behaviour.
*
* @param {{truncated?: boolean, reason?: string|null, source?: string|null,
* fullSize?: number, retainedBytes?: number, exhausted?: boolean}} state
* fullSize?: number, retainedBytes?: number, exhausted?: boolean,
* paneHistoryLines?: number|null}} state
* @returns {{visible: boolean, message: string, canLoadMore: boolean}}
*/
function computeHistoryTruncationNotice(state = {}) {
if (!state.truncated) return { visible: false, message: '', canLoadMore: false };
const historyLines =
typeof state.paneHistoryLines === 'number' && Number.isFinite(state.paneHistoryLines)
? Math.max(0, state.paneHistoryLines)
: null;
if (historyLines === 0) return { visible: false, message: '', canLoadMore: false };
const retained = Math.max(0, state.retainedBytes || 0);
const dropped = Math.max(0, (state.fullSize || 0) - retained);
@@ -1786,9 +1804,15 @@ function computeHistoryTruncationNotice(state = {}) {
canLoadMore: false,
};
}
// Name what a pull can actually return when the server said: the byte gap
// counts repaints and redraw bloat, and overstates it many times over.
const more =
historyLines !== null
? `${historyLines.toLocaleString('en-US')} ${historyLines === 1 ? 'line of scrollback is' : 'lines of scrollback are'} retained.`
: `${formatHistoryBytes(dropped)} more may still be retained.`;
return {
visible: true,
message: `Showing the most recent ${shown} of this session. ${formatHistoryBytes(dropped)} more may still be retained.`,
message: `Showing the most recent ${shown} of this session. ${more}`,
canLoadMore: true,
};
}
+16 -2
View File
@@ -3627,10 +3627,24 @@ Object.assign(CodemanApp.prototype, {
* the unbounded 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.
*
* It is also what brings up the partial-history notice, and what retires it
* once a downward scroll is back at live output (_setHistoryNoticeRevealed).
* The reveal waits for the pull this gesture started, so the notice describes
* what the pull left rather than flashing the state it is about to replace.
*/
_maybeLoadMoreHistoryOnScroll(lines) {
if (lines >= 0) return;
if (this.terminal?.buffer?.active?.viewportY === 0) this._maybeRefetchFullHistory?.();
if (lines > 0) {
if (this.isTerminalAtBottom()) this._setHistoryNoticeRevealed?.(null);
return;
}
if (lines === 0 || this.terminal?.buffer?.active?.viewportY !== 0) return;
const sessionId = this.activeSessionId;
Promise.resolve(this._maybeRefetchFullHistory?.())
.catch(() => {})
.then(() => {
if (sessionId && this.activeSessionId === sessionId) this._setHistoryNoticeRevealed?.(sessionId);
});
},
/**
+6
View File
@@ -3225,6 +3225,12 @@ export function registerSessionRoutes(
// damage that does not exist.
captureCols: hasLiveMuxBuffer ? captureOpts.capturedGeometry?.cols : undefined,
captureRows: hasLiveMuxBuffer ? captureOpts.capturedGeometry?.rows : undefined,
// Rows tmux holds above the visible frame, which is the most a `full=1`
// pull can add. `truncated` measures the BYTE stream, and for a pane that
// keeps no scrollback (a fullscreen CLI in the alternate screen) the bytes
// a tail cut drops are old repaints that no request can bring back, so a
// client must not offer to load them. Absent when the pane was not read.
paneHistoryLines: hasLiveMuxBuffer ? captureOpts.capturedHistoryLines : undefined,
};
});
+278
View File
@@ -0,0 +1,278 @@
/**
* @fileoverview The partial-history notice waits until the user reaches for history.
*
* Every tab switch after the first replays a 1 MiB TAIL of the session's byte
* stream, which is truncated for any session that has run for a while, so the
* notice ("Showing the most recent 1.0 MB of this session. 4.8 MB more may still
* be retained.") covered the top rows on nearly every switch. Its × only lasted
* until the next switch. Now:
* - it appears only once a scroll gesture reaches the top of the browser's
* buffer, after the history pull that gesture starts has settled;
* - a scroll back down to live output retires it, and so does a tab switch;
* - a dismissal sticks for that session until the page reloads.
*
* Runs the REAL methods (app.js banner + state, terminal-ui.js scroll hook,
* constants.js notice decision) in a `vm` against a stub DOM, the same way
* shell-scroll-history-pull.test.ts does (no jsdom on this box).
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const APP = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
function methodSource(source: string, method: string): string {
const start = source.search(new RegExp(`^ {2}(?:async )?${method}\\(`, 'm'));
expect(start, `${method} not found`).toBeGreaterThan(-1);
const next = /^ {2}(?:async )?[A-Za-z_$][\w$]*\(/m.exec(source.slice(start + 1));
return next ? source.slice(start, start + 1 + next.index) : source.slice(start);
}
interface FakeEl {
tagName: string;
hidden: boolean;
className: string;
type: string;
disabled: boolean;
children: FakeEl[];
attrs: Record<string, string>;
onclick: null | (() => void);
textContent: string;
appendChild(child: FakeEl): void;
setAttribute(name: string, value: string): void;
}
function fakeEl(tagName: string): FakeEl {
let text = '';
const el: FakeEl = {
tagName,
hidden: false,
className: '',
type: '',
disabled: false,
children: [],
attrs: {},
onclick: null,
get textContent() {
return text + el.children.map((c) => c.textContent).join('');
},
set textContent(value: string) {
text = value;
el.children = [];
},
appendChild(child) {
el.children.push(child);
},
setAttribute(name, value) {
el.attrs[name] = value;
},
};
return el;
}
/** Real terminal-ui.js mixin, for `_maybeLoadMoreHistoryOnScroll` / `isTerminalAtBottom`. */
function loadTerminalMixin(): Record<string, (...args: unknown[]) => unknown> {
const source = readFileSync(resolve(PUBLIC, 'terminal-ui.js'), 'utf8');
const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, (...args: unknown[]) => unknown> };
const context = vm.createContext({
console,
performance,
setTimeout,
clearTimeout,
setInterval: vi.fn(),
clearInterval: vi.fn(),
requestAnimationFrame: vi.fn(),
CodemanApp: FakeCodemanApp,
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
document: { addEventListener: vi.fn() },
});
vm.runInContext(source, context);
return FakeCodemanApp.prototype;
}
function makeApp() {
const bar = fakeEl('div');
bar.hidden = true;
const document = {
getElementById: (id: string) => (id === 'historyTruncationBar' ? bar : null),
createElement: (tag: string) => fakeEl(tag),
};
const methods = [
'_setHistoryTruncation',
'_clearHistoryTruncation',
'_setHistoryNoticeRevealed',
'_renderHistoryTruncationBanner',
]
.map((m) => methodSource(APP, m))
.join(',\n');
const context = vm.createContext({ document, console, window: {}, navigator: { userAgent: 'test' } });
const appMethods = vm.runInContext(
`${readFileSync(resolve(PUBLIC, 'constants.js'), 'utf8')}
;({ ${methods} })`,
context,
{ filename: 'app-methods.js' }
) as Record<string, (...args: unknown[]) => unknown>;
const mixin = loadTerminalMixin();
const buffer = { viewportY: 500, baseY: 500 };
let resolvePull: (() => void) | null = null;
const app = {
activeSessionId: 's1' as string | null,
terminal: { buffer: { active: buffer } },
// Each gesture's pull is held open until the test settles it.
_maybeRefetchFullHistory: vi.fn(
() =>
new Promise<void>((res) => {
resolvePull = res;
})
),
...appMethods,
_maybeLoadMoreHistoryOnScroll: mixin._maybeLoadMoreHistoryOnScroll,
isTerminalAtBottom: mixin.isTerminalAtBottom,
} as Record<string, any>;
const settle = async () => {
resolvePull?.();
resolvePull = null;
for (let i = 0; i < 5; i++) await Promise.resolve();
};
const scrollTo = async (viewportY: number) => {
const lines = viewportY - buffer.viewportY;
buffer.viewportY = viewportY;
app._maybeLoadMoreHistoryOnScroll(lines);
await settle();
};
return { app, bar, buffer, scrollTo, settle };
}
// What a tab switch's tail replay reports for a session with real scrollback.
const TAIL = {
truncated: true,
truncationReason: 'tail',
source: 'mux-visible',
fullSize: 5 * 1024 * 1024,
retainedBytes: 1024 * 1024,
paneHistoryLines: 40000,
};
const loadButton = (bar: FakeEl) => bar.children.find((c) => c.className === 'history-trunc-load');
const dismissButton = (bar: FakeEl) => bar.children.find((c) => c.className === 'history-trunc-dismiss');
describe('partial-history notice: lazy reveal', () => {
it('stays hidden after a truncated tab-switch replay', () => {
const { app, bar } = makeApp();
app._setHistoryTruncation('s1', TAIL);
expect(bar.hidden).toBe(true);
});
it('appears once a scroll reaches the top, after the pull that gesture started', async () => {
const { app, bar, buffer, settle } = makeApp();
app._setHistoryTruncation('s1', TAIL);
buffer.viewportY = 0;
app._maybeLoadMoreHistoryOnScroll(-500);
expect(app._maybeRefetchFullHistory).toHaveBeenCalledTimes(1);
// Still pulling: no notice describing the state the pull is about to replace.
expect(bar.hidden).toBe(true);
await settle();
expect(bar.hidden).toBe(false);
expect(bar.textContent).toContain('40,000 lines of scrollback are retained.');
expect(loadButton(bar)?.textContent).toBe('Load full history');
});
it('shows what the pull left: nothing, when the pull brought everything back', async () => {
const { app, bar, buffer } = makeApp();
app._setHistoryTruncation('s1', TAIL);
app._maybeRefetchFullHistory.mockImplementation(async () => {
app._setHistoryTruncation('s1', { truncated: false, source: 'mux-full-history', paneHistoryLines: 40000 });
});
buffer.viewportY = 0;
app._maybeLoadMoreHistoryOnScroll(-500);
for (let i = 0; i < 5; i++) await Promise.resolve();
expect(app._historyNoticeRevealedFor).toBe('s1');
expect(bar.hidden).toBe(true);
});
it('does not appear on the way up, only at the top', async () => {
const { app, bar, scrollTo } = makeApp();
app._setHistoryTruncation('s1', TAIL);
await scrollTo(200);
expect(app._maybeRefetchFullHistory).not.toHaveBeenCalled();
expect(bar.hidden).toBe(true);
});
it('goes away once the user scrolls back down to live output', async () => {
const { app, bar, scrollTo } = makeApp();
app._setHistoryTruncation('s1', TAIL);
await scrollTo(0);
expect(bar.hidden).toBe(false);
await scrollTo(300); // still reading history
expect(bar.hidden).toBe(false);
await scrollTo(500); // back at the bottom
expect(bar.hidden).toBe(true);
});
it('never appears for a pane with no scrollback (fullscreen CLI), even at the top', async () => {
const { app, bar, scrollTo } = makeApp();
app._setHistoryTruncation('s1', { ...TAIL, paneHistoryLines: 0 });
await scrollTo(0);
expect(app._historyNoticeRevealedFor).toBe('s1');
expect(bar.hidden).toBe(true);
});
it('is not revealed for a tab the user switched to while the pull ran', async () => {
const { app, bar, buffer, settle } = makeApp();
app._setHistoryTruncation('s1', TAIL);
app._setHistoryTruncation('s2', TAIL);
buffer.viewportY = 0;
app._maybeLoadMoreHistoryOnScroll(-500);
app.activeSessionId = 's2';
await settle();
expect(app._historyNoticeRevealedFor ?? null).toBe(null);
expect(bar.hidden).toBe(true);
});
it('keeps a dismissal for that session, but only that session', async () => {
const { app, bar, scrollTo } = makeApp();
app._setHistoryTruncation('s1', TAIL);
await scrollTo(0);
dismissButton(bar)!.onclick!();
expect(bar.hidden).toBe(true);
// A new replay and another trip to the top do not bring it back.
await scrollTo(500);
app._setHistoryTruncation('s1', TAIL);
await scrollTo(0);
expect(bar.hidden).toBe(true);
// Another session still gets its notice.
app.activeSessionId = 's2';
app._setHistoryTruncation('s2', TAIL);
await scrollTo(500);
await scrollTo(0);
expect(bar.hidden).toBe(false);
});
it('forgets the dismissal with the session', async () => {
const { app, bar, scrollTo } = makeApp();
app._setHistoryTruncation('s1', TAIL);
await scrollTo(0);
dismissButton(bar)!.onclick!();
app._clearHistoryTruncation('s1');
expect(app._historyNoticeDismissed.has('s1')).toBe(false);
});
});
describe('partial-history notice: a tab switch retires it (static guard)', () => {
it('selectSession clears the reveal before repainting the banner', () => {
const body = methodSource(APP, 'selectSession');
const reset = body.indexOf('this._historyNoticeRevealedFor = null;');
const render = body.indexOf('this._renderHistoryTruncationBanner();');
expect(reset).toBeGreaterThan(-1);
expect(render).toBeGreaterThan(reset);
});
});
+64
View File
@@ -45,6 +45,11 @@ describe('formatHistoryBytes', () => {
expect(formatHistoryBytes(3 * 1024 * 1024)).toBe('3.0 MB');
});
it('never prints "1024 KB" for a tail cut back to a line boundary just under 1 MiB', () => {
expect(formatHistoryBytes(1048351)).toBe('1.0 MB');
expect(formatHistoryBytes(1023 * 1024)).toBe('1023 KB');
});
it('survives junk input rather than printing NaN into the UI', () => {
expect(formatHistoryBytes(-5)).toBe('less than 1 KB');
expect(formatHistoryBytes(NaN as unknown as number)).toBe('less than 1 KB');
@@ -114,6 +119,65 @@ describe('computeHistoryTruncationNotice (issue #258)', () => {
});
});
describe('computeHistoryTruncationNotice: what a pull can really return (paneHistoryLines)', () => {
const { computeHistoryTruncationNotice } = loadHelpers();
// The tab-switch tail of a fullscreen claude pane, as measured on prod: the
// server cut a 5.8 MB byte stream to 1 MB, and tmux held 0 scrollback rows.
const fullscreenTail = {
truncated: true,
reason: 'tail',
source: 'mux-visible',
fullSize: 6158853,
retainedBytes: 1048351,
};
it('says nothing for a pane that keeps no scrollback, however much the byte stream lost', () => {
// The dropped bytes were old repaints of one frame, and `full=1` returns
// only the visible frame for such a pane, so the button could only ever end
// in the downgrade refusal. That is the banner that showed on every switch.
const notice = computeHistoryTruncationNotice({ ...fullscreenTail, paneHistoryLines: 0 });
expect(notice).toEqual({ visible: false, message: '', canLoadMore: false });
});
it('stays silent for such a pane in the exhausted and at-ceiling states too', () => {
expect(computeHistoryTruncationNotice({ ...fullscreenTail, paneHistoryLines: 0, exhausted: true }).visible).toBe(
false
);
expect(
computeHistoryTruncationNotice({
...fullscreenTail,
source: 'mux-full-history',
reason: 'capped',
paneHistoryLines: 0,
}).visible
).toBe(false);
});
it('names the scrollback lines a pull can load instead of the byte gap', () => {
const notice = computeHistoryTruncationNotice({ ...fullscreenTail, source: 'history', paneHistoryLines: 48210 });
expect(notice.visible).toBe(true);
expect(notice.canLoadMore).toBe(true);
expect(notice.message).toBe(
'Showing the most recent 1.0 MB of this session. 48,210 lines of scrollback are retained.'
);
expect(notice.message).not.toContain('4.9 MB');
});
it('uses the singular for one line', () => {
const notice = computeHistoryTruncationNotice({ ...fullscreenTail, paneHistoryLines: 1 });
expect(notice.message).toContain('1 line of scrollback is retained.');
});
it('keeps the byte wording when the server did not report the pane (older server, byte-history fallback)', () => {
for (const paneHistoryLines of [undefined, null, NaN]) {
const notice = computeHistoryTruncationNotice({ ...fullscreenTail, paneHistoryLines });
expect(notice.visible).toBe(true);
expect(notice.canLoadMore).toBe(true);
expect(notice.message).toContain('more may still be retained');
}
});
});
describe('the in-terminal truncation line is gone (static guard)', () => {
it('no longer writes the notice into terminal output', () => {
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
+47
View File
@@ -946,6 +946,53 @@ describe('session-routes', () => {
expect(body.data.captureRows).toBeUndefined();
});
it('reports how many scrollback rows tmux holds for the captured pane', async () => {
// `truncated` measures the byte stream; this is what a `full=1` pull can
// return. 0 (a fullscreen CLI in the alternate screen) is the case where
// the client must not offer to load the rest.
harness.ctx._session.terminalBuffer = 'x'.repeat(4096);
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
(
_name: string,
opts?: { capturedGeometry?: { cols: number; rows: number }; capturedHistoryLines?: number }
) => {
if (opts) {
opts.capturedGeometry = { cols: 100, rows: 50 };
opts.capturedHistoryLines = 0;
}
return 'visible frame';
}
);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal?tail=1024`,
});
const body = JSON.parse(res.body);
expect(body.data.truncated).toBe(true);
expect(body.data.paneHistoryLines).toBe(0);
});
it('omits paneHistoryLines when the body is the byte history, not a capture', async () => {
harness.ctx._session.terminalBuffer = 'byte history only';
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
(_name: string, opts?: { capturedHistoryLines?: number }) => {
if (opts) opts.capturedHistoryLines = 12;
return null;
}
);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
});
const body = JSON.parse(res.body);
expect(body.data.source).toBe('history');
expect(body.data.paneHistoryLines).toBeUndefined();
});
// ── COD-47: full tmux scrollback replay on full page reload ──
it('full reload (?full=1) requests full tmux history and replays boundary markers', async () => {
// A realistic scrollback-length capture: ~5000 lines, well past one screen.
+43 -1
View File
@@ -11,7 +11,7 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { formatCursorRestore, formatPaneSnapshot, hasVisibleContent } from '../src/tmux-manager.js';
import { formatCursorRestore, formatPaneSnapshot, hasVisibleContent, queryPaneCursor } from '../src/tmux-manager.js';
describe('tmux full-history pane capture (COD-47)', () => {
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
@@ -153,6 +153,48 @@ describe('the geometry a capture reports back', () => {
});
});
describe('the capture reports how much scrollback tmux holds (#{history_size})', () => {
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
const methodStart = source.indexOf('capturePaneBuffer(muxName: string');
const methodBody = source.slice(methodStart, source.indexOf('captureActivePaneBuffer(muxName: string', methodStart));
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.match(/display-message/g)).toHaveLength(1);
expect(methodBody).toContain('opts.capturedHistoryLines = geometry.historyLines');
});
it('parses the fifth field as historyLines', () => {
expect(queryPaneCursor(() => '3 5 120 40 812\n')).toEqual({
cursorX: 3,
cursorY: 5,
cols: 120,
rows: 40,
historyLines: 812,
});
// A pane in the alternate screen (fullscreen claude) holds none.
expect(queryPaneCursor(() => '0 31 187 32 0')?.historyLines).toBe(0);
});
it('never lets a missing or bad history field void the caret', () => {
for (const raw of ['3 5 120 40', '3 5 120 40 ', '3 5 120 40 x', '3 5 120 40 -1']) {
const geometry = queryPaneCursor(() => raw);
expect(geometry).toEqual({ cursorX: 3, cursorY: 5, cols: 120, rows: 40 });
expect(geometry && 'historyLines' in geometry).toBe(false);
}
});
it('still returns null when the geometry itself is bad', () => {
expect(queryPaneCursor(() => 'x 5 120 40 812')).toBeNull();
expect(
queryPaneCursor(() => {
throw new Error('no pane');
})
).toBeNull();
});
});
describe('why a capture has to report its height', () => {
it('a snapshot addresses rows the receiving terminal may not have', () => {
// formatPaneSnapshot positions every row absolutely. A terminal shorter