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:
@@ -45,6 +45,9 @@ export interface TerminalStreamEvent {
|
||||
sequence?: number;
|
||||
data?: string;
|
||||
replayData?: string;
|
||||
/** PTY size the snapshot history was drawn for; only `snapshot` events carry it. */
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
status?: 'running' | 'exited' | 'error';
|
||||
exitCode?: number;
|
||||
signal?: number | null;
|
||||
|
||||
@@ -22,6 +22,8 @@ type WireMessage = {
|
||||
v?: number;
|
||||
d?: string;
|
||||
r?: string;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
history?: string;
|
||||
status?: TerminalStreamEvent['status'];
|
||||
exitCode?: number;
|
||||
@@ -120,6 +122,36 @@ describe('terminal transport', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('carries the PTY size through snapshots, projection replays, and accepted resizes', async () => {
|
||||
const socket = new FakeSocket();
|
||||
const transport = new TerminalTransport({ refreshAuth: async () => '', openSocket: () => socket });
|
||||
const sizes: Array<[number | undefined, number | undefined]> = [];
|
||||
transport.subscribe('term-1', { onEvent: (event) => { if (event.type === 'snapshot') sizes.push([event.cols, event.rows]); } });
|
||||
await tick();
|
||||
socket.open();
|
||||
await tick();
|
||||
|
||||
socket.emit({ t: 'snapshot', v: 3, s: 'term-1', q: 1, history: 'prompt', status: 'running', cols: 94, rows: 56 });
|
||||
await tick();
|
||||
expect(sizes).toEqual([[94, 56]]);
|
||||
|
||||
const lateSizes: Array<[number | undefined, number | undefined]> = [];
|
||||
transport.subscribe('term-1', { onEvent: (event) => { if (event.type === 'snapshot') lateSizes.push([event.cols, event.rows]); } });
|
||||
expect(lateSizes).toEqual([[94, 56]]);
|
||||
|
||||
transport.noteResize('term-1', 80, 24);
|
||||
const afterResize: Array<[number | undefined, number | undefined]> = [];
|
||||
transport.subscribe('term-1', { onEvent: (event) => { if (event.type === 'snapshot') afterResize.push([event.cols, event.rows]); } });
|
||||
expect(afterResize).toEqual([[80, 24]]);
|
||||
|
||||
socket.emit({ t: 'snapshot', v: 3, s: 'term-2', q: 0, history: '', status: 'running' });
|
||||
const legacy: Array<[number | undefined, number | undefined]> = [];
|
||||
transport.subscribe('term-2', { onEvent: (event) => { if (event.type === 'snapshot') legacy.push([event.cols, event.rows]); } });
|
||||
await tick();
|
||||
expect(legacy).toEqual([]);
|
||||
transport.dispose();
|
||||
});
|
||||
|
||||
test('hydrates simultaneous subscribers and rejects duplicate sequences', async () => {
|
||||
const socket = new FakeSocket();
|
||||
const transport = new TerminalTransport({ refreshAuth: async () => '', openSocket: () => socket });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CreateTerminalOptions, TerminalError, TerminalHandlers, TerminalServerSession, TerminalSession, TerminalSessionPurpose, TerminalShellOption, TerminalStreamEvent } from './api/types';
|
||||
import type { TerminalChunkSize } from '@/stores/useTerminalStore';
|
||||
import { openRuntimeWebSocket } from './relay/runtime-socket';
|
||||
import type { RelayTunnelSocketMessageEvent, RelayTunnelWebSocket } from './relay/tunnel-client';
|
||||
import { runtimeFetch } from './runtime-fetch';
|
||||
@@ -15,6 +16,9 @@ type Subscriber = { handlers: TerminalHandlers; lastSequence: number };
|
||||
type TerminalProjection = {
|
||||
sequence: number;
|
||||
history: string;
|
||||
/** Current PTY size: what the server reported at attach, updated by every accepted resize. */
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
status: TerminalStreamEvent['status'];
|
||||
mode?: TerminalSession['mode'];
|
||||
purpose?: TerminalSessionPurpose;
|
||||
@@ -84,6 +88,7 @@ const terminalMessageSchema = z.discriminatedUnion('t', [
|
||||
z.object({
|
||||
t: z.literal('snapshot'), s: z.string(), q: z.number().int().nonnegative().default(0),
|
||||
history: z.string().default(''), status: terminalStatusSchema,
|
||||
cols: z.number().int().positive().optional(), rows: z.number().int().positive().optional(),
|
||||
exitCode: z.number().nullish().transform(value => value ?? undefined), signal: z.number().nullable().optional(),
|
||||
runtime: terminalRuntimeSchema.optional(), ptyBackend: z.string().optional(), ...terminalMessageMetadata,
|
||||
}),
|
||||
@@ -124,6 +129,10 @@ export class TerminalRequestError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** The PTY size a snapshot's history was drawn for, when the server reported one. */
|
||||
export const terminalSnapshotSize = (event: Pick<TerminalStreamEvent, 'cols' | 'rows'>): TerminalChunkSize | undefined =>
|
||||
event.cols !== undefined && event.rows !== undefined ? { cols: event.cols, rows: event.rows } : undefined;
|
||||
|
||||
export const isTerminalCwdMissingError = (error: unknown): boolean =>
|
||||
error instanceof TerminalRequestError && error.code === TERMINAL_CWD_MISSING_CODE;
|
||||
|
||||
@@ -187,7 +196,7 @@ export class TerminalTransport {
|
||||
const projection = this.projections.get(sessionId);
|
||||
if (projection) {
|
||||
subscriber.lastSequence = projection.sequence;
|
||||
handlers.onEvent({ type: 'snapshot', sequence: projection.sequence, data: projection.history, status: projection.status, mode: projection.mode, purpose: projection.purpose, exitCode: projection.exitCode, signal: projection.signal, runtime: projection.runtime, ptyBackend: projection.ptyBackend });
|
||||
handlers.onEvent({ type: 'snapshot', sequence: projection.sequence, data: projection.history, cols: projection.cols, rows: projection.rows, status: projection.status, mode: projection.mode, purpose: projection.purpose, exitCode: projection.exitCode, signal: projection.signal, runtime: projection.runtime, ptyBackend: projection.ptyBackend });
|
||||
}
|
||||
const socketWasOpen = this.socket?.readyState === SOCKET_OPEN;
|
||||
this.ensureConnected().then(() => {
|
||||
@@ -251,6 +260,17 @@ export class TerminalTransport {
|
||||
this.projections.delete(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a resize the server accepted, so a projection snapshot replayed to
|
||||
* a later subscriber (tab switch, remount) still names the size the
|
||||
* terminal's current screen is drawn for.
|
||||
*/
|
||||
noteResize(sessionId: string, cols: number, rows: number): void {
|
||||
const projection = this.projections.get(sessionId);
|
||||
if (!projection) return;
|
||||
this.projections.set(sessionId, { ...projection, cols, rows });
|
||||
}
|
||||
|
||||
private async ensureConnected(): Promise<void> {
|
||||
if (this.disposed) throw new Error('Terminal runtime changed');
|
||||
if (this.socket?.readyState === SOCKET_OPEN) return;
|
||||
@@ -358,6 +378,8 @@ export class TerminalTransport {
|
||||
const projection: TerminalProjection = {
|
||||
sequence: message.q ?? 0,
|
||||
history: message.history ?? '',
|
||||
cols: message.cols,
|
||||
rows: message.rows,
|
||||
status: message.status,
|
||||
mode: message.mode,
|
||||
purpose: message.purpose,
|
||||
@@ -369,7 +391,7 @@ export class TerminalTransport {
|
||||
this.projections.set(message.s, projection);
|
||||
for (const sub of subscribers) {
|
||||
sub.lastSequence = projection.sequence;
|
||||
sub.handlers.onEvent({ type: 'snapshot', sequence: projection.sequence, data: projection.history, status: projection.status, mode: projection.mode, purpose: projection.purpose, exitCode: projection.exitCode, signal: projection.signal, runtime: projection.runtime, ptyBackend: projection.ptyBackend });
|
||||
sub.handlers.onEvent({ type: 'snapshot', sequence: projection.sequence, data: projection.history, cols: projection.cols, rows: projection.rows, status: projection.status, mode: projection.mode, purpose: projection.purpose, exitCode: projection.exitCode, signal: projection.signal, runtime: projection.runtime, ptyBackend: projection.ptyBackend });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -492,7 +514,10 @@ async function command(path: string, method: string, body?: unknown): Promise<Re
|
||||
if (!response.ok) throw await responseError(response, 'Terminal command failed');
|
||||
return response;
|
||||
}
|
||||
export async function resizeTerminal(sessionId: string, cols: number, rows: number): Promise<void> { await command(`/api/terminal/${sessionId}/resize`, 'POST', { cols, rows }); }
|
||||
export async function resizeTerminal(sessionId: string, cols: number, rows: number): Promise<void> {
|
||||
await command(`/api/terminal/${sessionId}/resize`, 'POST', { cols, rows });
|
||||
transport.noteResize(sessionId, cols, rows);
|
||||
}
|
||||
export async function updateTerminalAppearance(sessionId: string, appearance: Pick<CreateTerminalOptions, 'themeMode' | 'terminalBackground' | 'terminalForeground'>): Promise<void> { await command(`/api/terminal/${sessionId}/appearance`, 'POST', appearance); }
|
||||
export async function closeTerminal(sessionId: string): Promise<void> { await command(`/api/terminal/${sessionId}`, 'DELETE'); transport.forget(sessionId); }
|
||||
export async function restartTerminalSession(currentSessionId: string, options: CreateTerminalOptions): Promise<TerminalSession> { return (await command(`/api/terminal/${currentSessionId}/restart`, 'POST', options)).json() as Promise<TerminalSession>; }
|
||||
|
||||
Reference in New Issue
Block a user