fix(terminal): replay snapshot history at the PTY size it was drawn for
Opening the terminal panel sometimes showed stray fragments on the prompt row: zsh's end-of-line mark and pieces of the prompt path. The shell had laid its output out for one PTY width, but the client replayed that history into an emulator of another width (an early size estimate, a remount, or a renderer rebuild after fonts loaded). ghostty-web's reflow then left fragments the shell's SIGWINCH redraw never clears. The server now reports the PTY cols/rows in every snapshot, the transport carries them through projections and accepted resizes, and the viewport replays a sized snapshot chunk at that size before returning to the fitted size. The container-based size estimate only seeds newly spawned shells and is no longer sent to a running PTY. Tests cover the sized replay, the store chunk size, the transport projection, and the server snapshot; verified in a production build by reloading with the panel open and switching tabs at a changed width.
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
`/api/terminal/ws` is the only terminal data transport. It uses v3 binary JSON control frames and is opened through `openRuntimeWebSocket`, preserving direct, Electron proxy, URL-token authentication, and private-relay routing.
|
||||
|
||||
- `attach` registers a connection for one terminal. One socket may attach to many terminals.
|
||||
- Every attach and reconnect begins with an authoritative `snapshot` containing bounded history and the current sequence.
|
||||
- Every attach and reconnect begins with an authoritative `snapshot` containing bounded history, the current sequence, and the PTY `cols`/`rows` the history was drawn for. The client replays the history at that size before fitting its viewport; replaying shell output at another width leaves stray fragments that the shell's own SIGWINCH redraw never clears.
|
||||
- A current socket that closes or errors before its initial `open` invalidates its URL-scoped auth token before retrying, so retries mint a fresh token instead of backing off against a rejected upgrade. Hidden or offline clients wait 60 seconds and wake promptly on visibility/online recovery.
|
||||
- `output`, `exit`, and `restarted` carry monotonically increasing per-terminal sequences. Output carries raw live bytes plus replay-safe bytes with terminal query exchanges removed.
|
||||
- Attach registers before capturing the snapshot, buffers concurrent events, drops events represented by the snapshot sequence, then enters live delivery.
|
||||
|
||||
@@ -184,6 +184,10 @@ export function createTerminalRuntime({
|
||||
|
||||
const snapshot = (session) => ({
|
||||
t: 'snapshot', v: 3, s: session.id, q: session.sequence, history: session.history,
|
||||
// The PTY size the history was drawn for: a client replays history at this
|
||||
// size before fitting its own viewport, so shell output wrapped for one
|
||||
// width never gets re-laid-out at another.
|
||||
cols: session.cols, rows: session.rows,
|
||||
status: session.status, exitCode: session.exitCode, signal: session.signal,
|
||||
mode: session.mode ?? INTERACTIVE_TERMINAL_MODE, purpose: getSessionPurpose(session),
|
||||
runtime, ptyBackend: session.backend,
|
||||
|
||||
@@ -682,8 +682,8 @@ describe('terminal runtime', () => {
|
||||
sockets.push(first.socket);
|
||||
first.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'term-live' }));
|
||||
first.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'term-second' }));
|
||||
expect(await first.next('snapshot', 'term-live')).toMatchObject({ s: 'term-live', q: 0, history: '', status: 'running' });
|
||||
expect(await first.next('snapshot', 'term-second')).toMatchObject({ s: 'term-second', q: 0, history: '', status: 'running' });
|
||||
expect(await first.next('snapshot', 'term-live')).toMatchObject({ s: 'term-live', q: 0, history: '', status: 'running', cols: 80, rows: 24 });
|
||||
expect(await first.next('snapshot', 'term-second')).toMatchObject({ s: 'term-second', q: 0, history: '', status: 'running', cols: 80, rows: 24 });
|
||||
first.socket.send(createTerminalWsControlFrame({ t: 'write', v: 3, s: 'term-live', d: 'echo ok\r' }));
|
||||
first.socket.send(createTerminalWsControlFrame({ t: 'write', v: 3, s: 'term-second', d: 'pwd\r' }));
|
||||
first.socket.send(createTerminalWsControlFrame({ t: 'write', v: 3, s: 'term-live', d: 'echo next\r' }));
|
||||
@@ -707,10 +707,17 @@ describe('terminal runtime', () => {
|
||||
expect(secondClosed.status).toBe(200);
|
||||
first.socket.close();
|
||||
|
||||
// A reconnecting client replays history at the size the PTY currently has.
|
||||
const resized = await fetch(`${base}/api/terminal/term-live/resize`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ cols: 120, rows: 40 }),
|
||||
});
|
||||
expect(resized.status).toBe(200);
|
||||
|
||||
const second = await openTerminalSocket(socketUrl);
|
||||
sockets.push(second.socket);
|
||||
second.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'term-live' }));
|
||||
expect(await second.next('snapshot')).toMatchObject({ s: 'term-live', q: 2, history: 'ok\r\n', status: 'running' });
|
||||
expect(await second.next('snapshot')).toMatchObject({ s: 'term-live', q: 2, history: 'ok\r\n', status: 'running', cols: 120, rows: 40 });
|
||||
processes[0].emitExit(7);
|
||||
expect(await second.next('exit')).toMatchObject({ s: 'term-live', q: 3, exitCode: 7 });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user