mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 00:49:41 +02:00
fix(terminal): merge-time fixes for the silent-failure paths (#431)
- While another device holds the pane width (_paneWidthRefused), a resize now asks for the container's width without applying it locally (_geometryForResizeRequest: rows follow the container, columns stay at the PTY's). Fitting first re-wrapped the whole buffer to the container and back on every 30s mobile retry, and throttledResize ran the scrollback clear for a resize that brings no redraw. selectSession clears the flag, since it belongs to the previous pane. New unit tests run the real mixin against a fake terminal and fail without the fix. - Session seeds _ptyCols/_ptyRows at spawn (_notePtySpawnGeometry), so a reattached pane reports its tmux window's real size through ptyGeometry. - Session.resize's declined-branch comment names ptyGeometry, not the deleted ptyCols/ptyRows getters. - Delete the dead terminalGeometryAgrees() and its window export. - test/xterm-private-api.test.ts header: it pins the exact locked version, so any bump fails, not only a major. - The main-terminal fit sweep also matches fitAddon?.fit?.(), and CLAUDE.md names the modules it actually covers. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+21
-6
@@ -1973,6 +1973,7 @@ export class Session extends EventEmitter {
|
|||||||
env: buildMuxAttachEnv(cliExportsTruecolor(this.mode)),
|
env: buildMuxAttachEnv(cliExportsTruecolor(this.mode)),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
this._notePtySpawnGeometry(ptyCols, ptyRows);
|
||||||
} catch (spawnErr) {
|
} catch (spawnErr) {
|
||||||
console.error(`[Session] Failed to spawn PTY for ${options.spawnErrLabel}:`, spawnErr);
|
console.error(`[Session] Failed to spawn PTY for ${options.spawnErrLabel}:`, spawnErr);
|
||||||
this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
|
this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
|
||||||
@@ -2684,6 +2685,7 @@ export class Session extends EventEmitter {
|
|||||||
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
|
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
this._notePtySpawnGeometry(120, 40);
|
||||||
} catch (spawnErr) {
|
} catch (spawnErr) {
|
||||||
console.error('[Session] Failed to spawn Claude PTY:', spawnErr);
|
console.error('[Session] Failed to spawn Claude PTY:', spawnErr);
|
||||||
this._status = 'stopped';
|
this._status = 'stopped';
|
||||||
@@ -3306,6 +3308,7 @@ export class Session extends EventEmitter {
|
|||||||
env: buildShellEnv(this.id),
|
env: buildShellEnv(this.id),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
this._notePtySpawnGeometry(120, 40);
|
||||||
} catch (spawnErr) {
|
} catch (spawnErr) {
|
||||||
console.error('[Session] Failed to spawn shell PTY:', spawnErr);
|
console.error('[Session] Failed to spawn shell PTY:', spawnErr);
|
||||||
this._status = 'stopped';
|
this._status = 'stopped';
|
||||||
@@ -3415,6 +3418,7 @@ export class Session extends EventEmitter {
|
|||||||
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
|
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
this._notePtySpawnGeometry(120, 40);
|
||||||
} catch (spawnErr) {
|
} catch (spawnErr) {
|
||||||
console.error('[Session] Failed to spawn Claude PTY for runPrompt:', spawnErr);
|
console.error('[Session] Failed to spawn Claude PTY for runPrompt:', spawnErr);
|
||||||
this.emit(
|
this.emit(
|
||||||
@@ -4101,6 +4105,17 @@ export class Session extends EventEmitter {
|
|||||||
private _ptyCols = 120;
|
private _ptyCols = 120;
|
||||||
private _ptyRows = 40;
|
private _ptyRows = 40;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record the geometry a PTY was just spawned at. A reattached pane keeps the
|
||||||
|
* tmux window's size, not the constructor's 120x40, and without this
|
||||||
|
* `ptyGeometry` reported the old numbers for a live pane and the dedupe in
|
||||||
|
* `resize()` skipped a real resize that happened to match them.
|
||||||
|
*/
|
||||||
|
private _notePtySpawnGeometry(cols: number, rows: number): void {
|
||||||
|
this._ptyCols = cols;
|
||||||
|
this._ptyRows = rows;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The geometry the CLI is actually drawing for, or null when nothing is
|
* The geometry the CLI is actually drawing for, or null when nothing is
|
||||||
* drawing.
|
* drawing.
|
||||||
@@ -4111,11 +4126,11 @@ export class Session extends EventEmitter {
|
|||||||
* than wrong-sized output, because Claude Code's repaints are computed from
|
* than wrong-sized output, because Claude Code's repaints are computed from
|
||||||
* the width it was told (issue #464). Both transports report this back.
|
* the width it was told (issue #464). Both transports report this back.
|
||||||
*
|
*
|
||||||
* ⚠️ NULL WITHOUT A PANE, never the field values. `resize()` writes
|
* ⚠️ NULL WITHOUT A PANE, never the field values. The fields are seeded at
|
||||||
* `_ptyCols`/`_ptyRows` only when `ptyProcess` is set, and nothing seeds them
|
* spawn (`_notePtySpawnGeometry`) and moved by `resize()`, but a session with
|
||||||
* from the spawn geometry, so a session with a dead pane — or one created
|
* a dead pane (or one created through the API and never started) still
|
||||||
* through the API and never started — still holds the constructor defaults
|
* holds the constructor defaults of 120x40, or the size of a pane that is
|
||||||
* of 120x40. Reporting those made a client adopt a size no process had ever
|
* gone. Reporting those made a client adopt a size no process had ever
|
||||||
* been told, and on anything narrower than 120 columns it claimed another
|
* been told, and on anything narrower than 120 columns it claimed another
|
||||||
* device owned the pane when none existed. `reconcilePtyGeometry` treats a
|
* device owned the pane when none existed. `reconcilePtyGeometry` treats a
|
||||||
* report with no finite numbers as no evidence, which is the truth here.
|
* report with no finite numbers as no evidence, which is the truth here.
|
||||||
@@ -4211,7 +4226,7 @@ export class Session extends EventEmitter {
|
|||||||
if (Date.now() - this._lastDesktopActivityAt < Session.DESKTOP_CLAIM_IDLE_MS) {
|
if (Date.now() - this._lastDesktopActivityAt < Session.DESKTOP_CLAIM_IDLE_MS) {
|
||||||
// Declined. The caller is told nothing here on purpose — the decision
|
// Declined. The caller is told nothing here on purpose — the decision
|
||||||
// belongs to the session, not the socket — but the caller MUST report
|
// belongs to the session, not the socket — but the caller MUST report
|
||||||
// `ptyCols`/`ptyRows` back afterwards so the asking client can adopt
|
// `ptyGeometry` back afterwards so the asking client can adopt
|
||||||
// the shape it did not get. Both transports do; see issue #464.
|
// the shape it did not get. Both transports do; see issue #464.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6652,6 +6652,10 @@ class CodemanApp {
|
|||||||
// Fit terminal to container BEFORE writing any buffer data.
|
// Fit terminal to container BEFORE writing any buffer data.
|
||||||
// If the browser was resized while viewing another session, the terminal
|
// If the browser was resized while viewing another session, the terminal
|
||||||
// canvas may be at stale dimensions — content would render at wrong width.
|
// canvas may be at stale dimensions — content would render at wrong width.
|
||||||
|
// A width refusal belonged to the PREVIOUS session's pane, and while it
|
||||||
|
// stands sendResize keeps the columns it last adopted; this pane's own
|
||||||
|
// report re-establishes it if another device holds this one too.
|
||||||
|
this._paneWidthRefused = false;
|
||||||
this.syncTerminalGeometry();
|
this.syncTerminalGeometry();
|
||||||
|
|
||||||
// Also push the new dimensions to the PTY. Without this, codex/codeman
|
// Also push the new dimensions to the PTY. Without this, codex/codeman
|
||||||
|
|||||||
@@ -1770,11 +1770,6 @@ function clampTerminalDimensions(proposed) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether two geometries are the same screen. Either being absent is a mismatch. */
|
|
||||||
function terminalGeometryAgrees(a, b) {
|
|
||||||
return !!a && !!b && a.cols === b.cols && a.rows === b.rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What to do when the server reports the PTY's real geometry.
|
* What to do when the server reports the PTY's real geometry.
|
||||||
*
|
*
|
||||||
@@ -1829,7 +1824,6 @@ if (typeof window !== 'undefined') {
|
|||||||
};
|
};
|
||||||
window.CodemanTerminalGeometry = {
|
window.CodemanTerminalGeometry = {
|
||||||
clampTerminalDimensions,
|
clampTerminalDimensions,
|
||||||
terminalGeometryAgrees,
|
|
||||||
reconcilePtyGeometry,
|
reconcilePtyGeometry,
|
||||||
TERMINAL_MIN_COLS,
|
TERMINAL_MIN_COLS,
|
||||||
TERMINAL_MIN_ROWS,
|
TERMINAL_MIN_ROWS,
|
||||||
|
|||||||
@@ -1093,7 +1093,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// a shape the PTY is never told about, and Claude Code computes its
|
// a shape the PTY is never told about, and Claude Code computes its
|
||||||
// repaints from the shape it was told. Withhold both, or neither —
|
// repaints from the shape it was told. Withhold both, or neither —
|
||||||
// a reflow nothing is rendering for buys nothing and costs correctness.
|
// a reflow nothing is rendering for buys nothing and costs correctness.
|
||||||
const dims = this.activeSessionId && !keyboardUp && !detachedElsewhere ? this.syncTerminalGeometry() : null;
|
const dims =
|
||||||
|
this.activeSessionId && !keyboardUp && !detachedElsewhere ? this._geometryForResizeRequest() : null;
|
||||||
// ⚠️ A null measurement is NOT a reason to report the floor. It used to
|
// ⚠️ A null measurement is NOT a reason to report the floor. It used to
|
||||||
// fall back to a bare 40x10, which tells the PTY a shape nothing measured
|
// fall back to a bare 40x10, which tells the PTY a shape nothing measured
|
||||||
// and xterm does not hold — the write-only guess this whole change exists
|
// and xterm does not hold — the write-only guess this whole change exists
|
||||||
@@ -1112,9 +1113,12 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// IMPORTANT: Only clear when we're actually sending SIGWINCH (dims changed).
|
// IMPORTANT: Only clear when we're actually sending SIGWINCH (dims changed).
|
||||||
// Clearing without a subsequent Ink redraw leaves the terminal blank.
|
// Clearing without a subsequent Ink redraw leaves the terminal blank.
|
||||||
const activeResizeSession = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
|
const activeResizeSession = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
|
||||||
|
// Not while another device holds the width: the columns were not
|
||||||
|
// reflowed here, and a refused resize brings no redraw after it.
|
||||||
if (
|
if (
|
||||||
activeResizeSession &&
|
activeResizeSession &&
|
||||||
activeResizeSession.mode !== 'shell' &&
|
activeResizeSession.mode !== 'shell' &&
|
||||||
|
!this._paneWidthRefused &&
|
||||||
this.terminal &&
|
this.terminal &&
|
||||||
this.isTerminalAtBottom()
|
this.isTerminalAtBottom()
|
||||||
) {
|
) {
|
||||||
@@ -5615,6 +5619,33 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return dims;
|
return dims;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The geometry to ASK the server for, applied locally only as far as the PTY
|
||||||
|
* can follow it.
|
||||||
|
*
|
||||||
|
* Ordinarily that is all of it: `syncTerminalGeometry()`. While another
|
||||||
|
* device holds the width (`_paneWidthRefused`, set by `_onPtyGeometryReport`)
|
||||||
|
* it is not. Fitting then re-wraps xterm to the container's columns, the
|
||||||
|
* request is refused, the report puts the PTY's columns back, and the whole
|
||||||
|
* buffer re-wraps twice per ask, with the viewport pointing at a different
|
||||||
|
* part of the scrollback in between. The mobile retry asks every 30 seconds,
|
||||||
|
* so that happened on a timer for as long as the refusal lasted. So the
|
||||||
|
* columns stay at the PTY's (the #464 invariant: the browser never draws at
|
||||||
|
* a width the PTY does not have), the rows follow the container (they are
|
||||||
|
* never adopted, see reconcilePtyGeometry), and the container's columns go
|
||||||
|
* out as the request. An accepted request is adopted by the report.
|
||||||
|
*
|
||||||
|
* @returns {{cols: number, rows: number}|null} the geometry to request
|
||||||
|
*/
|
||||||
|
_geometryForResizeRequest() {
|
||||||
|
if (!this._paneWidthRefused) return this.syncTerminalGeometry();
|
||||||
|
const wanted = this.getTerminalDimensions();
|
||||||
|
if (!wanted || !this.terminal) return null;
|
||||||
|
if (!this._resizeTerminalTo({ cols: this.terminal.cols, rows: wanted.rows })) return null;
|
||||||
|
this._scheduleOverflowAffordanceSync();
|
||||||
|
return wanted;
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-measure after something changed the CELL size, and tell the server.
|
* Re-measure after something changed the CELL size, and tell the server.
|
||||||
*
|
*
|
||||||
@@ -5677,8 +5708,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// a pane it does not own is not its to refit either.
|
// a pane it does not own is not its to refit either.
|
||||||
if (!this.isSoloWindow && this.detachedSessions?.has(sessionId)) return false;
|
if (!this.isSoloWindow && this.detachedSessions?.has(sessionId)) return false;
|
||||||
// Fit, floor, and apply in one step so the numbers below are the numbers
|
// Fit, floor, and apply in one step so the numbers below are the numbers
|
||||||
// xterm is actually holding.
|
// xterm is actually holding (or, while another device holds the width,
|
||||||
const dims = this.syncTerminalGeometry();
|
// the numbers this container would hold if the PTY followed).
|
||||||
|
const dims = this._geometryForResizeRequest();
|
||||||
if (!dims) return false;
|
if (!dims) return false;
|
||||||
// Did the dimensions actually change since the last resize we sent? Callers
|
// Did the dimensions actually change since the last resize we sent? Callers
|
||||||
// use this to skip work (e.g. the post-resize TUI-redraw settle) when no
|
// use this to skip work (e.g. the post-resize TUI-redraw settle) when no
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ function makeApp(overrides: Record<string, unknown> = {}) {
|
|||||||
// The real chain: sendResize fits, floors and applies through one function
|
// The real chain: sendResize fits, floors and applies through one function
|
||||||
// now, so the harness must let it (#464).
|
// now, so the harness must let it (#464).
|
||||||
syncTerminalGeometry: mixin.syncTerminalGeometry,
|
syncTerminalGeometry: mixin.syncTerminalGeometry,
|
||||||
|
_geometryForResizeRequest: mixin._geometryForResizeRequest,
|
||||||
_resizeTerminalTo: mixin._resizeTerminalTo,
|
_resizeTerminalTo: mixin._resizeTerminalTo,
|
||||||
// Real, so a geometry change really does re-check whether the terminal now
|
// Real, so a geometry change really does re-check whether the terminal now
|
||||||
// overflows its container (#464 item 4) — the fake DOM has no container, so
|
// overflows its container (#464 item 4) — the fake DOM has no container, so
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import vm from 'node:vm';
|
import vm from 'node:vm';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import xtermHeadless from '@xterm/headless';
|
import xtermHeadless from '@xterm/headless';
|
||||||
|
|
||||||
const { Terminal } = xtermHeadless as unknown as {
|
const { Terminal } = xtermHeadless as unknown as {
|
||||||
@@ -47,7 +47,6 @@ function loadGeometry() {
|
|||||||
context.window as {
|
context.window as {
|
||||||
CodemanTerminalGeometry: {
|
CodemanTerminalGeometry: {
|
||||||
clampTerminalDimensions: (p: Partial<Dims> | null | undefined) => Dims | null;
|
clampTerminalDimensions: (p: Partial<Dims> | null | undefined) => Dims | null;
|
||||||
terminalGeometryAgrees: (a: Dims | null, b: Dims | null) => boolean;
|
|
||||||
reconcilePtyGeometry: (local: Dims | null, pty: Partial<Dims> | null) => { adopt: boolean; oversized: boolean };
|
reconcilePtyGeometry: (local: Dims | null, pty: Partial<Dims> | null) => { adopt: boolean; oversized: boolean };
|
||||||
TERMINAL_MIN_COLS: number;
|
TERMINAL_MIN_COLS: number;
|
||||||
TERMINAL_MIN_ROWS: number;
|
TERMINAL_MIN_ROWS: number;
|
||||||
@@ -226,12 +225,15 @@ describe('exactly one function may change the terminal size', () => {
|
|||||||
expect(body).toContain('this._resizeTerminalTo(dims)');
|
expect(body).toContain('this._resizeTerminalTo(dims)');
|
||||||
});
|
});
|
||||||
|
|
||||||
// A whole-repo sweep rather than a spot check, so a NEW call site trips it
|
// A sweep of every module that touches the main terminal rather than a spot
|
||||||
// rather than quietly reopening #464. Scoped to the MAIN terminal: the split
|
// check, so a NEW call site there trips it rather than quietly reopening
|
||||||
|
// #464. A module outside the list below is not covered. Scoped to the MAIN
|
||||||
|
// terminal: the split
|
||||||
// pane, the teammate windows and the log viewer are separate xterm instances
|
// pane, the teammate windows and the log viewer are separate xterm instances
|
||||||
// with their own PTYs (or none), and each owns its own sizing.
|
// with their own PTYs (or none), and each owns its own sizing.
|
||||||
it('no other call site fits the main terminal behind its back', () => {
|
it('no other call site fits the main terminal behind its back', () => {
|
||||||
const MAIN_TERMINAL_FIT = /^(?!.*(?:_splitPane|entry\.fitAddon)).*fitAddon[?.]*\.fit\(\)/;
|
// `fit(` optionally called through `?.`, so `fitAddon?.fit?.()` counts too.
|
||||||
|
const MAIN_TERMINAL_FIT = /^(?!.*(?:_splitPane|entry\.fitAddon)).*fitAddon[?.]*\.fit(?:\?\.)?\(\)/;
|
||||||
const offenders: string[] = [];
|
const offenders: string[] = [];
|
||||||
for (const rel of [
|
for (const rel of [
|
||||||
'src/web/public/terminal-ui.js',
|
'src/web/public/terminal-ui.js',
|
||||||
@@ -293,7 +295,7 @@ describe('exactly one function may change the terminal size', () => {
|
|||||||
it('sendResize yields a detached session BEFORE touching geometry, not after', () => {
|
it('sendResize yields a detached session BEFORE touching geometry, not after', () => {
|
||||||
const body = bodyOf(terminalUi, 'async sendResize(sessionId, options = {}) {');
|
const body = bodyOf(terminalUi, 'async sendResize(sessionId, options = {}) {');
|
||||||
const yieldAt = body.indexOf('detachedSessions?.has(sessionId)) return false');
|
const yieldAt = body.indexOf('detachedSessions?.has(sessionId)) return false');
|
||||||
const fitAt = body.indexOf('this.syncTerminalGeometry()');
|
const fitAt = body.indexOf('this._geometryForResizeRequest()');
|
||||||
expect(yieldAt, 'the detached-session yield is gone').toBeGreaterThan(-1);
|
expect(yieldAt, 'the detached-session yield is gone').toBeGreaterThan(-1);
|
||||||
expect(fitAt, 'sendResize no longer syncs geometry').toBeGreaterThan(-1);
|
expect(fitAt, 'sendResize no longer syncs geometry').toBeGreaterThan(-1);
|
||||||
expect(
|
expect(
|
||||||
@@ -308,7 +310,7 @@ describe('exactly one function may change the terminal size', () => {
|
|||||||
expect(start).toBeGreaterThan(-1);
|
expect(start).toBeGreaterThan(-1);
|
||||||
const block = terminalUi.slice(start, terminalUi.indexOf("window.addEventListener('resize', throttledResize)"));
|
const block = terminalUi.slice(start, terminalUi.indexOf("window.addEventListener('resize', throttledResize)"));
|
||||||
const guardAt = block.indexOf('!keyboardUp && !detachedElsewhere');
|
const guardAt = block.indexOf('!keyboardUp && !detachedElsewhere');
|
||||||
const syncAt = block.indexOf('this.syncTerminalGeometry()');
|
const syncAt = block.indexOf('this._geometryForResizeRequest()');
|
||||||
expect(guardAt).toBeGreaterThan(-1);
|
expect(guardAt).toBeGreaterThan(-1);
|
||||||
expect(syncAt, 'the geometry sync must sit INSIDE the guard').toBeGreaterThan(guardAt);
|
expect(syncAt, 'the geometry sync must sit INSIDE the guard').toBeGreaterThan(guardAt);
|
||||||
});
|
});
|
||||||
@@ -463,3 +465,102 @@ describe('the server reports the geometry the PTY actually holds', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
// A refused width must not be re-applied locally on every ask. The real mixin
|
||||||
|
// methods, run against a fake terminal whose FitAddon behaves like xterm's.
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('while another device holds the width', () => {
|
||||||
|
const SESSION = 'session-A';
|
||||||
|
|
||||||
|
function makeApp() {
|
||||||
|
const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, unknown> };
|
||||||
|
const context = vm.createContext({
|
||||||
|
console,
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
setInterval: vi.fn(),
|
||||||
|
clearInterval: vi.fn(),
|
||||||
|
CodemanApp: FakeCodemanApp,
|
||||||
|
window: { addEventListener: vi.fn(), removeEventListener: vi.fn(), innerWidth: 400 },
|
||||||
|
document: { addEventListener: vi.fn(), getElementById: () => null },
|
||||||
|
});
|
||||||
|
vm.runInContext(read('src/web/public/constants.js'), context, { filename: 'constants.js' });
|
||||||
|
vm.runInContext(read('src/web/public/terminal-ui.js'), context, { filename: 'terminal-ui.js' });
|
||||||
|
const mixin = FakeCodemanApp.prototype as Record<string, (...a: unknown[]) => unknown>;
|
||||||
|
// The phone's container: 57x13. Every resize xterm performs is recorded,
|
||||||
|
// because a resize is a re-wrap of the whole buffer.
|
||||||
|
const resizes: Array<[number, number]> = [];
|
||||||
|
const terminal = {
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
resize(cols: number, rows: number) {
|
||||||
|
resizes.push([cols, rows]);
|
||||||
|
this.cols = cols;
|
||||||
|
this.rows = rows;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const proposal = { cols: 57, rows: 13 };
|
||||||
|
const sent: Array<{ c: number; r: number }> = [];
|
||||||
|
const app = Object.assign(Object.create(mixin), {
|
||||||
|
terminal,
|
||||||
|
fitAddon: {
|
||||||
|
proposeDimensions: () => ({ ...proposal }),
|
||||||
|
// xterm's FitAddon resizes to the raw proposal.
|
||||||
|
fit: () => terminal.resize(proposal.cols, proposal.rows),
|
||||||
|
},
|
||||||
|
activeSessionId: SESSION,
|
||||||
|
detachedSessions: new Set<string>(),
|
||||||
|
isSoloWindow: false,
|
||||||
|
_lastResizeDims: null,
|
||||||
|
_wsReady: true,
|
||||||
|
_wsSessionId: SESSION,
|
||||||
|
_ws: { send: (frame: string) => sent.push(JSON.parse(frame)) },
|
||||||
|
_notePaneOwnedElsewhere: vi.fn(),
|
||||||
|
}) as Record<string, unknown> & {
|
||||||
|
sendResize: (id: string) => Promise<boolean>;
|
||||||
|
_onPtyGeometryReport: (id: string, cols: number, rows: number) => void;
|
||||||
|
_paneWidthRefused?: boolean;
|
||||||
|
};
|
||||||
|
return { app, terminal, resizes, sent, proposal };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('asks for its own width again without re-wrapping to it until the PTY follows', async () => {
|
||||||
|
const { app, terminal, resizes, sent } = makeApp();
|
||||||
|
await app.sendResize(SESSION);
|
||||||
|
expect(sent.at(-1)).toMatchObject({ c: 57, r: 13 });
|
||||||
|
// Refused: the desktop keeps the pane at 198 columns.
|
||||||
|
app._onPtyGeometryReport(SESSION, 198, 43);
|
||||||
|
expect(terminal.cols).toBe(198);
|
||||||
|
expect(app._paneWidthRefused).toBe(true);
|
||||||
|
|
||||||
|
// The retry timer asks again. Nothing about the screen changed, so xterm
|
||||||
|
// must not be re-wrapped to 57 and back (it used to be, every 30 seconds).
|
||||||
|
resizes.length = 0;
|
||||||
|
await app.sendResize(SESSION);
|
||||||
|
app._onPtyGeometryReport(SESSION, 198, 43);
|
||||||
|
expect(resizes).toEqual([]);
|
||||||
|
expect(terminal.cols).toBe(198);
|
||||||
|
// It still ASKS for this screen's width, which is how it recovers.
|
||||||
|
expect(sent.at(-1)).toMatchObject({ c: 57, r: 13 });
|
||||||
|
|
||||||
|
// The desktop went idle and the request took: the report is adopted.
|
||||||
|
await app.sendResize(SESSION);
|
||||||
|
app._onPtyGeometryReport(SESSION, 57, 13);
|
||||||
|
expect(terminal.cols).toBe(57);
|
||||||
|
expect(app._paneWidthRefused).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still follows the container\u2019s rows while the width is held elsewhere', async () => {
|
||||||
|
const { app, terminal, resizes, proposal } = makeApp();
|
||||||
|
await app.sendResize(SESSION);
|
||||||
|
app._onPtyGeometryReport(SESSION, 198, 43);
|
||||||
|
resizes.length = 0;
|
||||||
|
proposal.rows = 20; // keyboard dismissed
|
||||||
|
await app.sendResize(SESSION);
|
||||||
|
// Rows only: the columns stay at the width the PTY has.
|
||||||
|
expect(resizes).toEqual([[198, 20]]);
|
||||||
|
expect(terminal.cols).toBe(198);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -17,9 +17,9 @@
|
|||||||
// a headless Terminal reports `_renderService: undefined`, so a test here would
|
// a headless Terminal reports `_renderService: undefined`, so a test here would
|
||||||
// pass whether or not the field still exists, which is worse than no test.
|
// pass whether or not the field still exists, which is worse than no test.
|
||||||
//
|
//
|
||||||
// So this guards the next best thing: the dependency range those field names
|
// So this guards the next best thing: the exact xterm version those field names
|
||||||
// were verified against. A major bump fails here, loudly, and sends someone to
|
// were verified against, as resolved in the lockfile. ANY bump fails here,
|
||||||
// re-verify `_kickRenderer` by hand in a browser. The failure mode being
|
// loudly, and sends someone to re-verify `_kickRenderer` by hand in a browser. The failure mode being
|
||||||
// defended against is silent — every access in `_kickRenderer` is
|
// defended against is silent — every access in `_kickRenderer` is
|
||||||
// optional-chained, so a renamed field degrades it to a permanent no-op with no
|
// optional-chained, so a renamed field degrades it to a permanent no-op with no
|
||||||
// error, no log, and a terminal that simply freezes again.
|
// error, no log, and a terminal that simply freezes again.
|
||||||
|
|||||||
Reference in New Issue
Block a user