mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 09:19:42 +02:00
Switching to a session left the caret one row below the composer's input line, on the box border, and every cursor-relative update the CLI sent afterwards was measured from the wrong row. Any fresh output repaired it, because the CLI then repainted the whole frame. Two things were wrong with the full-history replay, and they compound. The capture never restored the cursor. The visible-frame path ends with an absolute cursor move back to the pane's position; the linear path returned its text and left the caret wherever the last character landed, which for an agent CLI is the bottom-most row carrying text — the status line. The rows it addressed did not line up with the pane's rows either. Four transforms ran over the capture and each can delete a line: the trailing blank rows were stripped, redraw-bloat stripping ran, the trim that cuts everything above the Claude banner ran, and leading whitespace was removed. All four are right for a byte stream of successive frames. A capture is the rendered pane, one line per screen row, so each deletion shifted the frame out from under the restored cursor. The full-history path now appends the pane's own cursor position and keeps every row, so row N of the reply is row N of the pane. The visible-frame and tail paths are untouched. Restoring the cursor is what makes row alignment load-bearing here, and neither CLAUDE.md nor the architecture invariants said so — which is how four line-deleting transforms accumulated on the path. Both now record it. Verified against a live 315x59 pane: the reply carries 59 rows, its row 55 is the composer's input line matching tmux, and it ends with the cursor move that lands there.
81 lines
3.9 KiB
TypeScript
81 lines
3.9 KiB
TypeScript
/**
|
|
* COD-47: full tmux scrollback replay on reload.
|
|
*
|
|
* Under VITEST, TmuxManager no-ops execSync (IS_TEST_MODE), so we can't drive
|
|
* real tmux. Instead we assert the capture-arg construction directly from
|
|
* source (same approach as tmux-capture-color.test.ts): a full-history capture
|
|
* must use `capture-pane -p -e -J -S -<N>` (bounded to the configured history
|
|
* limit, with an explicit exec maxBuffer) and skip the single-screen snapshot
|
|
* repaint, while the visible capture keeps `capture-pane -p -e`.
|
|
*/
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
describe('tmux full-history pane capture (COD-47)', () => {
|
|
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
|
|
const methodStart = source.indexOf('capturePaneBuffer(muxName: string');
|
|
const methodBody = source.slice(methodStart, methodStart + 6500);
|
|
|
|
it('capturePaneBuffer accepts pane-capture options with a fullHistory flag', () => {
|
|
expect(methodStart).toBeGreaterThan(-1);
|
|
// The method signature must carry the opts channel...
|
|
expect(source.slice(methodStart, methodStart + 160)).toContain('PaneCaptureOptions');
|
|
// ...and the body must branch on opts.fullHistory.
|
|
expect(methodBody).toContain('opts?.fullHistory === true');
|
|
});
|
|
|
|
it('full-history mode captures scrollback bounded to the configured history limit (-J -S -<N>)', () => {
|
|
// `-S -<N>` (not unbounded `-S -`) keeps tmux from serializing more
|
|
// scrollback than the configured history limit retains; `-J` re-joins
|
|
// lines hard-wrapped at the capture-time pane width.
|
|
expect(source).toContain('capture-pane -p -e -J -S -${historyLines}');
|
|
});
|
|
|
|
it('full-history exec sets an explicit maxBuffer (default 1MB would ENOBUFS multi-MB dumps)', () => {
|
|
expect(methodBody).toContain('maxBuffer');
|
|
expect(methodBody).toContain('FULL_HISTORY_CAPTURE_SLACK_BYTES');
|
|
});
|
|
|
|
it('still offers the visible single-screen capture for fast tab switches', () => {
|
|
expect(source).toContain("'capture-pane -p -e'");
|
|
});
|
|
|
|
it('returns full-history capture as raw scrollback (skips the single-screen repaint)', () => {
|
|
// When fullHistory, return the normalized buffer BEFORE the
|
|
// formatPaneSnapshot repaint (which is single-screen and would clip a
|
|
// multi-screen history).
|
|
const normalize = methodBody.indexOf('normalizeScrollbackEol(buffer)');
|
|
const snapshot = methodBody.indexOf('formatPaneSnapshot(');
|
|
expect(normalize).toBeGreaterThan(-1);
|
|
expect(snapshot).toBeGreaterThan(-1);
|
|
expect(normalize).toBeLessThan(snapshot);
|
|
});
|
|
|
|
it('appends the pane cursor to the full-history capture', () => {
|
|
// A linear replay leaves the caret wherever the last character landed — the
|
|
// status line, for an agent CLI — and every cursor-relative update the CLI
|
|
// sends afterwards is then measured from the wrong row.
|
|
const restore = methodBody.indexOf('return `${normalized}');
|
|
const snapshot = methodBody.indexOf('formatPaneSnapshot(');
|
|
expect(restore).toBeGreaterThan(-1);
|
|
expect(restore).toBeLessThan(snapshot);
|
|
expect(methodBody).toContain('cursorY + 1};${cursorX + 1}H');
|
|
});
|
|
|
|
it('keeps the trailing rows of a full-history capture', () => {
|
|
// The visible path drops trailing blank rows because it repaints each row
|
|
// absolutely afterwards. Dropping them on the linear path would move the
|
|
// frame up and leave the restored cursor pointing at the wrong line.
|
|
expect(methodBody).toContain("fullHistory ? rawCapture.replace(/\\n$/, '')");
|
|
});
|
|
|
|
it('captureActivePaneBuffer forwards the capture options', () => {
|
|
const sig = source.indexOf('captureActivePaneBuffer(muxName: string');
|
|
expect(sig).toBeGreaterThan(-1);
|
|
const body = source.slice(sig, sig + 800);
|
|
expect(body).toContain('opts');
|
|
expect(body).toContain('this.capturePaneBuffer(muxName, target, opts)');
|
|
});
|
|
});
|