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:
Bohdan Triapitsyn
2026-09-06 22:57:43 +03:00
parent d8215ef5b3
commit c2f36fb5e7
14 changed files with 227 additions and 70 deletions
@@ -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);
});
@@ -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<TerminalViewProps> = ({ 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<TerminalViewProps> = ({ 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<TerminalViewProps> = ({ visible, directory }
chunks={bufferChunks}
onInput={handleViewportInput}
onResize={handleViewportResize}
onProvisionalSize={handleProvisionalSize}
theme={xtermTheme}
monoFont={monoFont}
fontFamily={resolvedFontStack}