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:
@@ -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 });
|
||||
|
||||
@@ -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 = `[7m%[0m${' '.repeat(93)}\r \r[J~ ❯ `;
|
||||
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 ❯ ' }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<TerminalController, Props>(({
|
||||
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<HTMLDivElement>(null);
|
||||
@@ -117,6 +124,7 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
|
||||
const fitRef = React.useRef<FitAddon | null>(null);
|
||||
const inputRef = React.useRef(onInput);
|
||||
const resizeRef = React.useRef(onResize);
|
||||
const provisionalSizeCallbackRef = React.useRef(onProvisionalSize);
|
||||
const lastSizeRef = React.useRef<TerminalSize | null>(null);
|
||||
const provisionalSizeRef = React.useRef<TerminalSize | null>(null);
|
||||
const lastChunkRef = React.useRef<number | null>(null);
|
||||
@@ -133,6 +141,7 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
|
||||
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<TerminalController, Props>(({
|
||||
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<TerminalController, Props>(({
|
||||
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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user