From c2f36fb5e71531fc308d84e48e0bcf789ad4dde6 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 6 Sep 2026 02:14:18 +0300 Subject: [PATCH] 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. --- .../layout/ProjectActionsButton.tsx | 5 +- .../terminal/TerminalViewport.test.tsx | 57 ++++++++++++++++++- .../components/terminal/TerminalViewport.tsx | 50 ++++++++++++++-- .../components/views/TerminalView.test.tsx | 3 +- .../ui/src/components/views/TerminalView.tsx | 13 ++++- packages/ui/src/lib/api/types.ts | 3 + packages/ui/src/lib/terminalApi.test.ts | 32 +++++++++++ packages/ui/src/lib/terminalApi.ts | 31 +++++++++- .../ui/src/stores/useTerminalStore.test.ts | 14 +++++ packages/ui/src/stores/useTerminalStore.ts | 22 +++++-- .../web/server/lib/terminal/DOCUMENTATION.md | 2 +- packages/web/server/lib/terminal/runtime.js | 4 ++ .../web/server/lib/terminal/runtime.test.js | 13 ++++- patches/ghostty-web+0.4.0.patch | 48 ---------------- 14 files changed, 227 insertions(+), 70 deletions(-) delete mode 100644 patches/ghostty-web+0.4.0.patch diff --git a/packages/ui/src/components/layout/ProjectActionsButton.tsx b/packages/ui/src/components/layout/ProjectActionsButton.tsx index fd611469..c9422b5e 100644 --- a/packages/ui/src/components/layout/ProjectActionsButton.tsx +++ b/packages/ui/src/components/layout/ProjectActionsButton.tsx @@ -17,6 +17,7 @@ import { useDeviceInfo } from '@/lib/device'; import { isDesktopShell } from '@/lib/desktop'; import { useUIStore } from '@/stores/useUIStore'; import { useTerminalStore } from '@/stores/useTerminalStore'; +import { terminalSnapshotSize } from '@/lib/terminalApi'; import { extractAnnouncedUrls, extractProjectActionUrl } from '@/lib/terminalPreview'; import { setAnnouncedDevServers } from '@/lib/browser/announcedServers'; import { useThemeSystem } from '@/contexts/useThemeSystem'; @@ -641,7 +642,7 @@ export const ProjectActionsButton = ({ onEvent: (event) => { if (!matchesActionExecution(tabDirectory, tab.id, currentExecutionId)) return; if (event.type === 'snapshot') { - useTerminalStore.getState().replaceBuffer(tabDirectory, tab.id, event.data ?? '', event.sequence ?? 0); + useTerminalStore.getState().replaceBuffer(tabDirectory, tab.id, event.data ?? '', event.sequence ?? 0, terminalSnapshotSize(event)); if (event.status === 'running') { useTerminalStore.getState().setTabLifecycle(tabDirectory, tab.id, 'running', { expectedExecutionId: currentExecutionId }); } @@ -851,7 +852,7 @@ export const ProjectActionsButton = ({ if (!matchesActionExecution(executionDirectory, tabId, adoptedExecutionId)) return; if (event.purpose?.type === 'project-action' && event.purpose.executionId !== adoptedExecutionId) return; if (event.type === 'snapshot') { - useTerminalStore.getState().replaceBuffer(executionDirectory, tabId, event.data ?? '', event.sequence ?? 0); + useTerminalStore.getState().replaceBuffer(executionDirectory, tabId, event.data ?? '', event.sequence ?? 0, terminalSnapshotSize(event)); useTerminalStore.getState().setConnecting(executionDirectory, tabId, false, { expectedExecutionId: adoptedExecutionId }); if (event.purpose?.type === 'project-action') { useTerminalStore.getState().setTabPurpose(executionDirectory, tabId, { type: 'project-action', actionId: event.purpose.actionId, executionId: event.purpose.executionId }); diff --git a/packages/ui/src/components/terminal/TerminalViewport.test.tsx b/packages/ui/src/components/terminal/TerminalViewport.test.tsx index b7f83b7c..7ee8e582 100644 --- a/packages/ui/src/components/terminal/TerminalViewport.test.tsx +++ b/packages/ui/src/components/terminal/TerminalViewport.test.tsx @@ -5,15 +5,21 @@ import { Window } from 'happy-dom'; import { useTerminalStore, type TerminalChunk } from '@/stores/useTerminalStore'; -const terminalEvents: Array<{ type: 'write'; data: string } | { type: 'reset' }> = []; +type TerminalEvent = + | { type: 'write'; data: string } + | { type: 'reset' } + | { type: 'resize'; cols: number; rows: number }; +const terminalEvents: TerminalEvent[] = []; class GhosttyTerminalDouble { public options: { cursorBlink: boolean }; public cols = 80; public rows = 24; - constructor(options: { cursorBlink?: boolean }) { + constructor(options: { cursorBlink?: boolean; cols?: number; rows?: number }) { this.options = { cursorBlink: options.cursorBlink ?? false }; + this.cols = options.cols ?? 80; + this.rows = options.rows ?? 24; } loadAddon() {} @@ -25,6 +31,11 @@ class GhosttyTerminalDouble { terminalEvents.push({ type: 'write', data }); callback?.(); } + resize(cols: number, rows: number) { + this.cols = cols; + this.rows = rows; + terminalEvents.push({ type: 'resize', cols, rows }); + } reset() { terminalEvents.push({ type: 'reset' }); } @@ -252,4 +263,46 @@ describe('TerminalViewport chunk replay integration', () => { expect(terminalEvents.filter((event) => event.type === 'write' && event.data === replacementReplayPayload)).toHaveLength(1); expect(terminalEvents.filter((event) => event.type === 'write' && event.data === 'tail-live\n')).toHaveLength(1); }); + + test('would fail if snapshot history drawn for another PTY size were replayed at the fitted size', async () => { + // A zsh prompt drawn for a 94-column PTY: the `%` end-of-line mark plus + // padding fills exactly one 94-column row. Written into an 80-column + // emulator it wraps and the mark survives as a stray fragment. + const history = `%${' '.repeat(93)}\r \r~ ❯ `; + const chunks: TerminalChunk[] = [ + { id: 1, data: history, byteLength: history.length, size: { cols: 94, rows: 56 } }, + { id: 2, data: 'live\n', byteLength: 5 }, + ]; + + await renderViewport(root, chunks); + await flushGhosttyLoad(); + + // Default-background resets inside the history are rewritten before the + // write, so identify the history write by the prompt it carries. + const relevant = terminalEvents + .filter((event) => event.type === 'resize' || (event.type === 'write' && (event.data.includes('~ ❯') || event.data === 'live\n'))) + .map((event) => (event.type === 'write' && event.data.includes('~ ❯') ? { type: 'write', data: 'history' } : event)); + expect(relevant).toEqual([ + { type: 'resize', cols: 94, rows: 56 }, + { type: 'write', data: 'history' }, + { type: 'resize', cols: 80, rows: 24 }, + { type: 'write', data: 'live\n' }, + ]); + + terminalEvents.length = 0; + await renderViewport(root, [...chunks, { id: 3, data: 'more\n', byteLength: 5 }]); + expect(terminalEvents).toEqual([{ type: 'write', data: 'more\n' }]); + }); + + test('would fail if a snapshot drawn at the fitted size still bounced the emulator through a resize', async () => { + const chunks: TerminalChunk[] = [ + { id: 1, data: 'prompt ❯ ', byteLength: 11, size: { cols: 80, rows: 24 } }, + ]; + + await renderViewport(root, chunks); + await flushGhosttyLoad(); + + expect(terminalEvents.filter((event) => event.type === 'resize')).toHaveLength(0); + expect(replayWriteEvents(['prompt ❯ '])).toEqual([{ type: 'write', data: 'prompt ❯ ' }]); + }); }); diff --git a/packages/ui/src/components/terminal/TerminalViewport.tsx b/packages/ui/src/components/terminal/TerminalViewport.tsx index f5342a3f..436f141c 100644 --- a/packages/ui/src/components/terminal/TerminalViewport.tsx +++ b/packages/ui/src/components/terminal/TerminalViewport.tsx @@ -97,7 +97,14 @@ type Props = { sessionKey: string; chunks: TerminalChunk[]; onInput: (data: string) => void; + /** Fitted size: the emulator has this size, so the PTY should follow. */ onResize: (cols: number, rows: number) => void; + /** + * Size estimated from the container before Ghostty has measured anything. + * Good enough to spawn a shell early, not authoritative: an existing PTY + * must not be resized to it. Falls back to `onResize` when omitted. + */ + onProvisionalSize?: (cols: number, rows: number) => void; theme: TerminalTheme; monoFont: MonoFontOption; fontFamily: string; @@ -109,7 +116,7 @@ type Props = { }; const TerminalViewport = React.forwardRef(({ - sessionKey, chunks, onInput, onResize, theme, monoFont, fontFamily, fontSize, className, + sessionKey, chunks, onInput, onResize, onProvisionalSize, theme, monoFont, fontFamily, fontSize, className, enableTouchScroll = false, autoFocus = true, isVisible = true, }, ref) => { const containerRef = React.useRef(null); @@ -117,6 +124,7 @@ const TerminalViewport = React.forwardRef(({ const fitRef = React.useRef(null); const inputRef = React.useRef(onInput); const resizeRef = React.useRef(onResize); + const provisionalSizeCallbackRef = React.useRef(onProvisionalSize); const lastSizeRef = React.useRef(null); const provisionalSizeRef = React.useRef(null); const lastChunkRef = React.useRef(null); @@ -133,6 +141,7 @@ const TerminalViewport = React.forwardRef(({ const [rendererGeneration, setRendererGeneration] = React.useState(0); inputRef.current = onInput; resizeRef.current = onResize; + provisionalSizeCallbackRef.current = onProvisionalSize; visibleRef.current = isVisible; safeResetRef.current = getGhosttySafeResetSequence(theme.background); @@ -141,7 +150,7 @@ const TerminalViewport = React.forwardRef(({ if (!container) return; const size = getProvisionalTerminalSize(container, fontFamily, fontSize); provisionalSizeRef.current = size; - if (size) resizeRef.current(size.cols, size.rows); + if (size) (provisionalSizeCallbackRef.current ?? resizeRef.current)(size.cols, size.rows); }, [fontFamily, fontSize]); const fit = React.useCallback(() => { @@ -327,18 +336,51 @@ const TerminalViewport = React.forwardRef(({ terminal.options.cursorBlink = isVisible && document.hasFocus() && container.contains(document.activeElement); }, [isVisible, ready]); + /** + * Snapshot history was laid out by the shell for the PTY size recorded on the + * chunk. Writing it into an emulator of another width wraps or joins lines the + * shell never wrapped, and the shell's later SIGWINCH redraw only repaints + * from its own cursor row down, so the stray fragments stay on screen. Replay + * such a chunk at its own size and let the emulator reflow back to the fitted + * size; a subsequent PTY resize (when the sizes differ) makes the shell redraw + * on top of a consistent screen. + * + * Only valid while nothing is queued: the write must not overtake bytes that + * are still waiting for the emulator. + */ + const writeReplayAtDrawnSize = React.useCallback((terminal: GhosttyTerminal, chunk: TerminalChunk): boolean => { + if (!chunk.size || writingRef.current || writeQueueRef.current) return false; + const rewritten = rewriteGhosttyDefaultBackgroundResets( + chunk.replayData ?? chunk.data, + outputRewriteCarryRef.current, + safeResetRef.current, + ); + outputRewriteCarryRef.current = rewritten.carry; + if (!rewritten.data) return true; + const fitted = { cols: terminal.cols, rows: terminal.rows }; + const resizeForReplay = chunk.size.cols !== fitted.cols || chunk.size.rows !== fitted.rows; + if (resizeForReplay) terminal.resize(chunk.size.cols, chunk.size.rows); + try { + terminal.write(rewritten.data); + } finally { + if (resizeForReplay) terminal.resize(fitted.cols, fitted.rows); + } + return true; + }, []); + React.useEffect(() => { const terminal = terminalRef.current; if (!terminal) return; const { reset, replay, pending } = selectTerminalChunkReplay(chunks, lastChunkRef.current); if (reset) recreateRenderer(); if (pending.length === 0) return; - writeQueueRef.current += pending + const queued = replay && writeReplayAtDrawnSize(terminal, pending[0]) ? pending.slice(1) : pending; + writeQueueRef.current += queued .map((chunk) => replay ? (chunk.replayData ?? chunk.data) : chunk.data) .join(''); lastChunkRef.current = chunks.at(-1)?.id ?? null; flush(); - }, [chunks, flush, ready, recreateRenderer]); + }, [chunks, flush, ready, recreateRenderer, writeReplayAtDrawnSize]); React.useEffect(() => { if (!autoFocus || !isVisible) return; diff --git a/packages/ui/src/components/views/TerminalView.test.tsx b/packages/ui/src/components/views/TerminalView.test.tsx index c340f7e7..54416586 100644 --- a/packages/ui/src/components/views/TerminalView.test.tsx +++ b/packages/ui/src/components/views/TerminalView.test.tsx @@ -376,7 +376,7 @@ describe('TerminalView project action tab indicator', () => { }); connectBehavior = (_sessionId, handlers) => { void Promise.resolve().then(() => { - handlers.onEvent({ type: 'snapshot', data: snapshotData, sequence: 7, status: 'running' }); + handlers.onEvent({ type: 'snapshot', data: snapshotData, sequence: 7, status: 'running', cols: 94, rows: 56 }); }); return { close: () => undefined }; }; @@ -391,6 +391,7 @@ describe('TerminalView project action tab indicator', () => { expect(createSessionCalls.length).toBe(0); expect(readBufferContent('/repo', actionTab.id)).toBe(snapshotData); expect(useTerminalStore.getState().getBuffer('/repo', actionTab.id).lastSequence).toBe(7); + expect(useTerminalStore.getState().getBuffer('/repo', actionTab.id).chunks[0]?.size).toEqual({ cols: 94, rows: 56 }); expect(replaceCount).toBe(1); }); diff --git a/packages/ui/src/components/views/TerminalView.tsx b/packages/ui/src/components/views/TerminalView.tsx index 889303fe..056d3731 100644 --- a/packages/ui/src/components/views/TerminalView.tsx +++ b/packages/ui/src/components/views/TerminalView.tsx @@ -17,7 +17,7 @@ import { Icon } from "@/components/icon/Icon"; import type { IconName } from '@/components/icon/icons'; import { useDeviceInfo } from '@/lib/device'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; -import { isTerminalCwdMissingError } from '@/lib/terminalApi'; +import { isTerminalCwdMissingError, terminalSnapshotSize } from '@/lib/terminalApi'; import { extractTerminalPreviewUrl, isTerminalPreviewUrlAvailable } from '@/lib/terminalPreview'; import { useI18n } from '@/lib/i18n'; import { PROJECT_ACTION_ICONS } from '@/lib/projectActions'; @@ -321,7 +321,7 @@ export const TerminalView: React.FC = ({ visible, directory } setIsReconnectPending(false); focusTerminalWhenWindowActive(); - replaceBuffer(directory, tabId, event.data ?? '', event.sequence ?? 0); + replaceBuffer(directory, tabId, event.data ?? '', event.sequence ?? 0, terminalSnapshotSize(event)); scanTerminalPreviewOutput(directory, tabId, event.data ?? ''); if (event.status === 'exited') setTabLifecycle(directory, tabId, 'exited'); break; @@ -763,6 +763,14 @@ export const TerminalView: React.FC = ({ visible, directory } [activeModifier, focusTerminalController, isReconnectPending, setActiveModifier, t, terminal] ); + // The estimate only seeds the size a brand-new shell is spawned with. A + // running PTY keeps its size until Ghostty has fitted the viewport for + // real; resizing it to an estimate makes the shell redraw for a width the + // emulator never shows. + const handleProvisionalSize = React.useCallback((cols: number, rows: number) => { + lastViewportSizeRef.current = { cols, rows }; + }, []); + const handleViewportResize = React.useCallback( (cols: number, rows: number) => { const previous = lastViewportSizeRef.current; @@ -1145,6 +1153,7 @@ export const TerminalView: React.FC = ({ visible, directory } chunks={bufferChunks} onInput={handleViewportInput} onResize={handleViewportResize} + onProvisionalSize={handleProvisionalSize} theme={xtermTheme} monoFont={monoFont} fontFamily={resolvedFontStack} diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 016ceb58..dc0a40a9 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -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; diff --git a/packages/ui/src/lib/terminalApi.test.ts b/packages/ui/src/lib/terminalApi.test.ts index 01141946..166b6b24 100644 --- a/packages/ui/src/lib/terminalApi.test.ts +++ b/packages/ui/src/lib/terminalApi.test.ts @@ -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 }); diff --git a/packages/ui/src/lib/terminalApi.ts b/packages/ui/src/lib/terminalApi.ts index 2ae2036e..5622fbe5 100644 --- a/packages/ui/src/lib/terminalApi.ts +++ b/packages/ui/src/lib/terminalApi.ts @@ -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): 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 { 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 { await command(`/api/terminal/${sessionId}/resize`, 'POST', { cols, rows }); } +export async function resizeTerminal(sessionId: string, cols: number, rows: number): Promise { + await command(`/api/terminal/${sessionId}/resize`, 'POST', { cols, rows }); + transport.noteResize(sessionId, cols, rows); +} export async function updateTerminalAppearance(sessionId: string, appearance: Pick): Promise { await command(`/api/terminal/${sessionId}/appearance`, 'POST', appearance); } export async function closeTerminal(sessionId: string): Promise { await command(`/api/terminal/${sessionId}`, 'DELETE'); transport.forget(sessionId); } export async function restartTerminalSession(currentSessionId: string, options: CreateTerminalOptions): Promise { return (await command(`/api/terminal/${currentSessionId}/restart`, 'POST', options)).json() as Promise; } diff --git a/packages/ui/src/stores/useTerminalStore.test.ts b/packages/ui/src/stores/useTerminalStore.test.ts index bcbaf37e..df7398cb 100644 --- a/packages/ui/src/stores/useTerminalStore.test.ts +++ b/packages/ui/src/stores/useTerminalStore.test.ts @@ -423,6 +423,20 @@ describe('terminal state reconciliation', () => { expect(buffer(tabId).chunks).toBe(previous); }); + test('records the PTY size a snapshot was drawn for and treats a size change as a new snapshot', () => { + const tabId = setup(); + useTerminalStore.getState().replaceBuffer('/repo', tabId, 'prompt', 8, { cols: 94, rows: 56 }); + expect(buffer(tabId).chunks[0].size).toEqual({ cols: 94, rows: 56 }); + const previous = buffer(tabId).chunks; + useTerminalStore.getState().replaceBuffer('/repo', tabId, 'prompt', 8, { cols: 94, rows: 56 }); + expect(buffer(tabId).chunks).toBe(previous); + useTerminalStore.getState().replaceBuffer('/repo', tabId, 'prompt', 8, { cols: 80, rows: 24 }); + expect(buffer(tabId).chunks).not.toBe(previous); + expect(buffer(tabId).chunks[0].size).toEqual({ cols: 80, rows: 24 }); + useTerminalStore.getState().appendToBuffer('/repo', tabId, ' live', 9); + expect(buffer(tabId).chunks[1].size).toBe(undefined); + }); + test('caps multibyte scrollback by UTF-8 bytes', () => { const tabId = setup(); useTerminalStore.getState().appendToBuffer('/repo', tabId, '界'.repeat(200_000), 1); diff --git a/packages/ui/src/stores/useTerminalStore.ts b/packages/ui/src/stores/useTerminalStore.ts index 689ae912..d0723b0d 100644 --- a/packages/ui/src/stores/useTerminalStore.ts +++ b/packages/ui/src/stores/useTerminalStore.ts @@ -7,11 +7,20 @@ import { getSafeSessionStorage } from '@/stores/utils/safeStorage'; import type { TerminalServerSession } from '@/lib/api/types'; import { normalizeTerminalDirectory } from '@/lib/pathNormalization'; +export type TerminalChunkSize = { cols: number; rows: number }; + export interface TerminalChunk { id: number; data: string; replayData?: string; byteLength: number; + /** + * PTY size this chunk was drawn for. Only snapshot history carries it: the + * viewport replays such a chunk at this size and then re-fits, because + * shell output laid out for one width turns into stray fragments when it is + * written into an emulator of another width. + */ + size?: TerminalChunkSize; } /** @@ -99,7 +108,7 @@ interface TerminalStore { setTabSessionId: (directory: string, tabId: string, sessionId: string | null, options?: { expectedExecutionId?: string | null }) => void; setTabLifecycle: (directory: string, tabId: string, lifecycle: TerminalTabLifecycle, options?: { expectedExecutionId?: string | null }) => void; setConnecting: (directory: string, tabId: string, isConnecting: boolean, options?: { expectedExecutionId?: string | null }) => void; - replaceBuffer: (directory: string, tabId: string, content: string, sequence: number) => void; + replaceBuffer: (directory: string, tabId: string, content: string, sequence: number, size?: TerminalChunkSize) => void; appendToBuffer: (directory: string, tabId: string, chunk: string, sequence?: number, replayData?: string) => void; setTabPreviewUrl: (directory: string, tabId: string, url: string | null, options?: { locked?: boolean; autoOpened?: boolean; expectedExecutionId?: string | null }) => void; markPreviewAutoOpened: (directory: string, tabId: string) => void; @@ -976,7 +985,7 @@ export const useTerminalStore = create()( }); }, - replaceBuffer: (directory: string, tabId: string, content: string, sequence: number) => { + replaceBuffer: (directory: string, tabId: string, content: string, sequence: number, size?: TerminalChunkSize) => { const key = normalizeDirectory(directory); set((state) => { const existing = state.sessions.get(key); @@ -985,17 +994,22 @@ export const useTerminalStore = create()( const buffer = state.buffers.get(entryKey) ?? EMPTY_TERMINAL_BUFFER; if (buffer.lastSequence > sequence) return state; const retained = trimToBufferLimit(content); + const previousSize = buffer.chunks[0]?.size; if ( buffer.lastSequence === sequence && buffer.byteLength === retained.byteLength && - buffer.chunks.map((chunk) => chunk.data).join('') === retained.text + buffer.chunks.map((chunk) => chunk.data).join('') === retained.text && + previousSize?.cols === size?.cols && + previousSize?.rows === size?.rows ) { return state; } const chunkId = state.nextChunkId; const buffers = new Map(state.buffers); buffers.set(entryKey, { - chunks: retained.text ? [{ id: chunkId, data: retained.text, byteLength: retained.byteLength }] : [], + chunks: retained.text + ? [{ id: chunkId, data: retained.text, byteLength: retained.byteLength, ...(size ? { size } : {}) }] + : [], byteLength: retained.byteLength, lastSequence: sequence, }); diff --git a/packages/web/server/lib/terminal/DOCUMENTATION.md b/packages/web/server/lib/terminal/DOCUMENTATION.md index aabf863a..4ecd3b24 100644 --- a/packages/web/server/lib/terminal/DOCUMENTATION.md +++ b/packages/web/server/lib/terminal/DOCUMENTATION.md @@ -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. diff --git a/packages/web/server/lib/terminal/runtime.js b/packages/web/server/lib/terminal/runtime.js index 6f37b6bc..026e58ba 100644 --- a/packages/web/server/lib/terminal/runtime.js +++ b/packages/web/server/lib/terminal/runtime.js @@ -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, diff --git a/packages/web/server/lib/terminal/runtime.test.js b/packages/web/server/lib/terminal/runtime.test.js index 8793d59e..456ff61c 100644 --- a/packages/web/server/lib/terminal/runtime.test.js +++ b/packages/web/server/lib/terminal/runtime.test.js @@ -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 }); diff --git a/patches/ghostty-web+0.4.0.patch b/patches/ghostty-web+0.4.0.patch deleted file mode 100644 index 7c6becb4..00000000 --- a/patches/ghostty-web+0.4.0.patch +++ /dev/null @@ -1,48 +0,0 @@ -diff --git a/node_modules/ghostty-web/dist/ghostty-web.js b/node_modules/ghostty-web/dist/ghostty-web.js -index 0000000000000000000000000000000000000000..1111111111111111111111111111111111111111 100644 ---- a/node_modules/ghostty-web/dist/ghostty-web.js -+++ b/node_modules/ghostty-web/dist/ghostty-web.js -@@ -1538,1 +1538,1 @@ -- if (A.grapheme_len > 0 && ((k = this.currentBuffer) != null && k.getGraphemeString) ? N = this.currentBuffer.getGraphemeString(g, B) : N = String.fromCodePoint(A.codepoint || 32), this.ctx.fillText(N, w, s), A.flags & e.FAINT && (this.ctx.globalAlpha = 1), A.flags & e.UNDERLINE) { -+ if (A.grapheme_len > 0 && ((k = this.currentBuffer) != null && k.getGraphemeString) ? N = this.currentBuffer.getGraphemeString(g, B) : N = A.codepoint == null || A.codepoint <= 0 || A.codepoint > 1114111 || A.codepoint >= 55296 && A.codepoint <= 57343 ? " " : String.fromCodePoint(A.codepoint), this.renderBlockChar(A.codepoint || 32, E, C, I) || this.ctx.fillText(N, w, s), A.flags & e.FAINT && (this.ctx.globalAlpha = 1), A.flags & e.UNDERLINE) { -@@ -1557,5 +1557,40 @@ - } -+ renderBlockChar(A, B, g, E) { -+ const C = this.metrics.height, I = E / 2, D = C / 2; -+ switch (A) { -+ case 9600: this.ctx.fillRect(B, g, E, D); return !0; -+ case 9601: this.ctx.fillRect(B, g + C * 7 / 8, E, C / 8); return !0; -+ case 9602: this.ctx.fillRect(B, g + C * 3 / 4, E, C / 4); return !0; -+ case 9603: this.ctx.fillRect(B, g + C * 5 / 8, E, C * 3 / 8); return !0; -+ case 9604: this.ctx.fillRect(B, g + D, E, D); return !0; -+ case 9605: this.ctx.fillRect(B, g + C * 3 / 8, E, C * 5 / 8); return !0; -+ case 9606: this.ctx.fillRect(B, g + C / 4, E, C * 3 / 4); return !0; -+ case 9607: this.ctx.fillRect(B, g + C / 8, E, C * 7 / 8); return !0; -+ case 9608: this.ctx.fillRect(B, g, E, C); return !0; -+ case 9609: this.ctx.fillRect(B, g, E * 7 / 8, C); return !0; -+ case 9610: this.ctx.fillRect(B, g, E * 3 / 4, C); return !0; -+ case 9611: this.ctx.fillRect(B, g, E * 5 / 8, C); return !0; -+ case 9612: this.ctx.fillRect(B, g, I, C); return !0; -+ case 9613: this.ctx.fillRect(B, g, E * 3 / 8, C); return !0; -+ case 9614: this.ctx.fillRect(B, g, E / 4, C); return !0; -+ case 9615: this.ctx.fillRect(B, g, E / 8, C); return !0; -+ case 9616: this.ctx.fillRect(B + I, g, I, C); return !0; -+ case 9620: this.ctx.fillRect(B, g, E, C / 8); return !0; -+ case 9621: this.ctx.fillRect(B + E * 7 / 8, g, E / 8, C); return !0; -+ case 9622: this.ctx.fillRect(B, g + D, I, D); return !0; -+ case 9623: this.ctx.fillRect(B + I, g + D, I, D); return !0; -+ case 9624: this.ctx.fillRect(B, g, I, D); return !0; -+ case 9625: this.ctx.fillRect(B, g, I, C); this.ctx.fillRect(B + I, g + D, I, D); return !0; -+ case 9626: this.ctx.fillRect(B, g, I, D); this.ctx.fillRect(B + I, g + D, I, D); return !0; -+ case 9627: this.ctx.fillRect(B, g, E, D); this.ctx.fillRect(B, g + D, I, D); return !0; -+ case 9628: this.ctx.fillRect(B, g, E, D); this.ctx.fillRect(B + I, g + D, I, D); return !0; -+ case 9629: this.ctx.fillRect(B + I, g, I, D); return !0; -+ case 9630: this.ctx.fillRect(B + I, g, I, D); this.ctx.fillRect(B, g + D, I, D); return !0; -+ case 9631: this.ctx.fillRect(B + I, g, I, C); this.ctx.fillRect(B, g + D, I, D); return !0; -+ default: return !1; -+ } -+ } - /** - * Render cursor - */ - renderCursor(A, B) {