mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
fix(terminal): replay a pane capture at the geometry it was taken at
A visible-frame capture repaints each row at an absolute position, counting up to the pane's height. A terminal shorter than that clamps every address past its own height onto its last line. The overflow rows then overwrite one another, and the rows underneath are lost. Replaying a real 50-row capture into a 30-row terminal rendered 28 lines of a 45-line command and drew the frame twice. Nothing in the response said what height the frame was built for, so the client could not detect this. A capture now reports the geometry it was really taken at through `capturedGeometry` on `PaneCaptureOptions`, and the terminal response carries it as `captureCols` and `captureRows`. When the captured pane is taller than the terminal, or the size that produced the capture did not survive the load, `selectSession` replays once at the size that stuck. `resizeRetry` caps that at one attempt, so two competing fits cannot trade replays forever. The retry re-arms the full-history flag only when the pass that ran had consumed it. A tab switch takes the bounded tail, so its retry takes the tail too: clearing the flag unconditionally would upgrade that switch into a fresh scrollback capture the user never asked for, which the route's own comments put at tens of megabytes. What this repairs is a capture that won a race against the resize meant to precede it. It does not repair a capture whose pane was too tall because `Session.resize` declined the resize outright, which it does for a small viewport while a desktop viewport's size claim is live. The retry re-sends the same declined resize and captures the same pane, and `resizeRetry` then stops it. Repairing that means changing who owns the pane size, which is a policy question this does not touch. The reported geometry still helps there, because the client can see the mismatch at all rather than being blind to it. Follows #395, #396 and #397, which fixed the other ways the replayed frame and the terminal could disagree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3cdb4bf42e
commit
3edf9aae2f
@@ -11,7 +11,7 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatCursorRestore, hasVisibleContent } from '../src/tmux-manager.js';
|
||||
import { formatCursorRestore, formatPaneSnapshot, hasVisibleContent } 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');
|
||||
@@ -121,3 +121,53 @@ describe('hasVisibleContent', () => {
|
||||
expect(hasVisibleContent('\x1b[m \x1b[0m\n\x1b[m x \x1b[0m')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the geometry a capture reports back', () => {
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
|
||||
const methodStart = source.indexOf('capturePaneBuffer(muxName: string');
|
||||
const methodEnd = source.indexOf('captureActivePaneBuffer(muxName: string', methodStart);
|
||||
const methodBody = source.slice(methodStart, methodEnd);
|
||||
|
||||
it('writes the pane size onto the caller options before either replay path returns', () => {
|
||||
// IS_TEST_MODE no-ops execSync, so assert from source (same approach as the
|
||||
// capture-flag tests above). The write must precede the fullHistory branch:
|
||||
// both paths return from inside it, and a caller that got no geometry
|
||||
// cannot tell a mismatched frame from a matching one.
|
||||
const write = methodBody.indexOf('opts.capturedGeometry = { cols: geometry.cols, rows: geometry.rows }');
|
||||
// Anchor on the REPLAY branch, not the earlier `if (fullHistory)` that only
|
||||
// sizes the exec buffer.
|
||||
const replayBranch = methodBody.indexOf('if (!geometry) return normalizeScrollbackEol(');
|
||||
const visibleReturn = methodBody.indexOf('if (geometry) return formatPaneSnapshot(');
|
||||
expect(write).toBeGreaterThan(-1);
|
||||
expect(replayBranch).toBeGreaterThan(-1);
|
||||
expect(visibleReturn).toBeGreaterThan(-1);
|
||||
expect(write).toBeLessThan(replayBranch);
|
||||
expect(write).toBeLessThan(visibleReturn);
|
||||
});
|
||||
|
||||
it('reports nothing when the cursor query gave no geometry', () => {
|
||||
// `queryPaneCursor` returns null on a failed or nonsensical query, and the
|
||||
// snapshot repaint is skipped in that case. Reporting a size anyway would
|
||||
// describe a frame that was never positioned.
|
||||
expect(methodBody).toContain('if (opts && geometry)');
|
||||
});
|
||||
});
|
||||
|
||||
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
|
||||
// than the pane clamps each address past its own height onto its last
|
||||
// line, so the overflow rows overwrite one another and the rows underneath
|
||||
// are lost. Nothing in the escape sequence tells the client this happened —
|
||||
// hence captureRows on the response.
|
||||
const lines = Array.from({ length: 50 }, (_, i) => `row-${i + 1}`);
|
||||
// cursorX 5 keeps the trailing cursor-restore move (`\x1b[50;6H`) out of the
|
||||
// `;1H` row-paint match below, so the count is row paints alone.
|
||||
const snapshot = formatPaneSnapshot(lines, { cols: 100, rows: 50, cursorX: 5, cursorY: 49 });
|
||||
const addressed = [...snapshot.matchAll(/\x1b\[(\d+);1H/g)].map((m) => Number(m[1]));
|
||||
|
||||
expect(Math.max(...addressed)).toBe(50);
|
||||
// A 30-row terminal cannot honour 20 of those addresses.
|
||||
expect(addressed.filter((row) => row > 30)).toHaveLength(20);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user