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) {