feat(terminal): replace ghostty-web with an in-repo libghostty-vt adapter

The terminal ran on the ghostty-web npm package plus a hand-written patch,
and every rendering bug (recycled rows, duplicated reflow fragments, prompt
artifacts) had to be worked around from outside. The emulator now is the
official libghostty-vt C ABI compiled to WebAssembly, driven by a browser
adapter ported from T3 Code (MIT, notice in LICENSE-T3CODE) and owned in
packages/ui/src/lib/ghostty. The artifact is reproducible with
scripts/build-libghostty-wasm.sh, including a workaround for Zig 0.15.2 on
macOS 27 SDKs.

On top of the port: one WASM instance per page with every tab kept mounted
and hidden tabs paused; history replayed at the PTY size it was drawn for;
shells spawned only after the first fitted grid so zsh never prints the
PROMPT_SP marker; box drawing, block elements and Powerline arrows drawn
procedurally to the exact cell so TUI borders and block logos have no gaps
between rows; a software-rasterized canvas so Gecko renders every tab's text
with the same smoothing; the symbols-only Nerd Font bundled instead of a CDN
fetch; touch selection and scrolling driven through the surface API; a copy
button in the tab strip for touch hosts; localized aria labels.

Testing: bun tests run the real WASM (reflow, palette, replay isolation,
recycled rows, box glyph geometry); viewport and view tests use a surface
double; verified in Chromium and Zen (windowed and headless) for crisp text,
new tabs, panel reopen, resize and box glyph rendering; package type-check,
oxlint/eslint on new files, web build.
This commit is contained in:
Bohdan Triapitsyn
2026-09-07 12:18:22 +03:00
parent 3132d1361a
commit 39fa8c1917
61 changed files with 6678 additions and 990 deletions
@@ -98,7 +98,7 @@ mock.module('@/stores/useUIStore', () => ({ useUIStore: useUiStoreMock }));
mock.module('@/stores/useInlineCommentDraftStore', () => ({ useInlineCommentDraftStore: () => ({ addDraft: () => undefined }) }));
mock.module('@/components/terminal/TerminalViewport', () => ({
TerminalViewport: React.forwardRef(function TerminalViewportMock(
{ sessionKey, chunks, isVisible }: { sessionKey: string; chunks: unknown[]; isVisible: boolean },
{ sessionKey, chunks, isVisible, onResize }: { sessionKey: string; chunks: unknown[]; isVisible: boolean; onResize: (cols: number, rows: number) => void },
ref: React.ForwardedRef<{ focus: () => void; fit: () => void; getSelection: () => null }>,
) {
React.useImperativeHandle(ref, () => ({
@@ -106,6 +106,11 @@ mock.module('@/components/terminal/TerminalViewport', () => ({
fit: () => undefined,
getSelection: () => null,
}), []);
// A real surface reports its fitted grid once it is visible; a visible tab
// spawns its shell only after that report.
React.useEffect(() => {
if (isVisible) onResize(100, 30);
}, [isVisible, onResize]);
return React.createElement('div', {
'data-terminal-viewport': 'true',
@@ -303,7 +308,7 @@ describe('TerminalView project action tab indicator', () => {
expect(ensureDirectoryCalls).not.toContain('/missing-repo');
expect(createSessionCalls.length).toBe(0);
expect(host.querySelector('[data-tabs-strip="terminal"]')).toBeNull();
expect(host.querySelector('[data-terminal-viewport="true"]')?.getAttribute('data-chunk-count')).toBe('0');
expect(host.querySelector('[data-terminal-viewport="true"]')).toBeNull();
});
test('includes the terminal directory in the viewport identity key', async () => {
+206 -91
View File
@@ -1,7 +1,7 @@
import React from 'react';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { ACTIVE_PROJECT_ACTION_LIFECYCLES, EMPTY_TERMINAL_BUFFER, useTerminalStore } from '@/stores/useTerminalStore';
import { ACTIVE_PROJECT_ACTION_LIFECYCLES, useTerminalStore } from '@/stores/useTerminalStore';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { type TerminalStreamEvent } from '@/lib/api/types';
import { useThemeSystem } from '@/contexts/useThemeSystem';
@@ -9,9 +9,13 @@ import { useFontPreferences } from '@/hooks/useFontPreferences';
import { CODE_FONT_OPTION_MAP, DEFAULT_MONO_FONT } from '@/lib/fontOptions';
import { convertThemeToXterm } from '@/lib/terminalTheme';
import { TerminalViewport, type TerminalController } from '@/components/terminal/TerminalViewport';
import type { MonoFontOption } from '@/lib/fontOptions';
import type { TerminalTheme } from '@/lib/terminalTheme';
import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/useUIStore';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui';
import { copyTextToClipboard } from '@/lib/clipboard';
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
import { Icon } from "@/components/icon/Icon";
import type { IconName } from '@/components/icon/icons';
@@ -32,6 +36,57 @@ type TerminalViewProps = {
};
const FALLBACK_TERMINAL_SIZE = { cols: 80, rows: 24 } as const;
type TerminalTabViewportProps = {
directory: string;
tabId: string;
isActive: boolean;
isTerminalVisible: boolean;
registerController: (tabId: string, controller: TerminalController | null) => void;
onInput: (data: string) => void;
onResize: (cols: number, rows: number) => void;
onProvisionalSize: (cols: number, rows: number) => void;
theme: TerminalTheme;
monoFont: MonoFontOption;
fontFamily: string;
fontSize: number;
enableTouchScroll: boolean;
};
/**
* One mounted emulator per tab. Inactive tabs stay mounted but hidden so
* switching back shows the last drawn screen at once instead of rebuilding
* the WASM terminal, re-measuring fonts and replaying history from scratch.
* Only the active tab holds a stream; its buffer refresh replays in place.
*/
const TerminalTabViewport: React.FC<TerminalTabViewportProps> = ({
directory, tabId, isActive, isTerminalVisible, registerController,
onInput, onResize, onProvisionalSize, theme, monoFont, fontFamily, fontSize, enableTouchScroll,
}) => {
// Scrollback is a leaf subscription: streaming output must not rerender the tab strip.
const chunks = useTerminalStore((s) => s.getBuffer(directory, tabId).chunks);
const viewportKey = `${directory}::${tabId}`;
return (
<div className={cn('h-full w-full', !isActive && 'hidden')}>
<TerminalViewport
ref={(controller) => registerController(tabId, controller)}
sessionKey={viewportKey}
chunks={chunks}
onInput={onInput}
onResize={onResize}
onProvisionalSize={onProvisionalSize}
theme={theme}
monoFont={monoFont}
fontFamily={fontFamily}
fontSize={fontSize}
enableTouchScroll={enableTouchScroll}
autoFocus={isTerminalVisible && isActive}
isVisible={isTerminalVisible && isActive}
/>
</div>
);
};
const resolveTabIconName = (iconKey: string | null): IconName => {
const matchedIcon = PROJECT_ACTION_ICONS.find((entry) => entry.key === iconKey);
return matchedIcon?.Icon ?? 'terminal';
@@ -126,10 +181,6 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }
const terminalSessionId = activeTab?.terminalSessionId ?? null;
const terminalLifecycle = activeTab?.lifecycle ?? 'idle';
const isActionTab = activeTab?.purpose.type === 'project-action';
// Scrollback is a leaf subscription: streaming output must not rerender the tab strip.
const bufferChunks = useTerminalStore((s) => (
terminalDirectory && activeTabId ? s.getBuffer(terminalDirectory, activeTabId).chunks : EMPTY_TERMINAL_BUFFER.chunks
));
const isConnecting = activeTab?.isConnecting ?? false;
const previewUrl = activeTab?.previewUrl ?? null;
@@ -145,7 +196,14 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }
const terminalIdRef = React.useRef<string | null>(terminalSessionId);
const directoryRef = React.useRef<string | null>(terminalDirectory);
const terminalControllerRef = React.useRef<TerminalController | null>(null);
const tabControllersRef = React.useRef(new Map<string, TerminalController>());
const lastViewportSizeRef = React.useRef<{ cols: number; rows: number } | null>(null);
// The grid Ghostty actually fitted for a tab. A visible tab spawns its shell
// at this size and not before: a shell started wider than the real grid
// prints its first prompt for that width, and after the corrective resize
// zsh only repaints the prompt row, leaving the `%` end-of-line mark above it.
const fittedViewportRef = React.useRef<{ tabId: string; cols: number; rows: number } | null>(null);
const isTerminalVisibleRef = React.useRef(false);
const pendingTerminalCreatesRef = React.useRef(new Set<string>());
const previewScanTailRef = React.useRef('');
const pendingPreviewProbeUrlsRef = React.useRef<Set<string>>(new Set());
@@ -175,6 +233,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }
}, [useTouchTerminalInput]);
const isTerminalVisible = visible ?? false;
isTerminalVisibleRef.current = isTerminalVisible;
const [hasOpenedTerminalViewport, setHasOpenedTerminalViewport] = React.useState(isTerminalVisible);
React.useEffect(() => {
@@ -197,6 +256,16 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }
resetTerminalPreviewScan();
}, [activeTabId, resetTerminalPreviewScan]);
React.useLayoutEffect(() => {
terminalControllerRef.current = activeTabId ? (tabControllersRef.current.get(activeTabId) ?? null) : null;
}, [activeTabId]);
const registerTabController = React.useCallback((tabId: string, controller: TerminalController | null) => {
if (controller) tabControllersRef.current.set(tabId, controller);
else tabControllersRef.current.delete(tabId);
if (tabId === activeTabIdRef.current) terminalControllerRef.current = controller;
}, []);
React.useEffect(() => {
directoryRef.current = terminalDirectory;
}, [terminalDirectory]);
@@ -418,6 +487,79 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }
]
);
// Spawns the PTY for a tab. Pending creates are single-flight per tab;
// the session effect and the first fitted-grid report both funnel here.
const createTerminalSession = React.useCallback(
async (directory: string, tabId: string, initialSize: { cols: number; rows: number }) => {
const createKey = `${directory}\u0000${tabId}`;
if (pendingTerminalCreatesRef.current.has(createKey)) {
return;
}
pendingTerminalCreatesRef.current.add(createKey);
setConnectionError(null);
setIsFatalError(false);
setIsReconnectPending(false);
setConnecting(directory, tabId, true);
try {
const session = await terminal.createSession({
cwd: directory,
sessionId: tabId,
cols: initialSize.cols,
rows: initialSize.rows,
shell: terminalShell,
loginShell: terminalLoginShell,
...terminalAppearanceRef.current,
});
const stillActive =
directoryRef.current === directory &&
activeTabIdRef.current === tabId;
const owningTab = useTerminalStore.getState().getDirectoryState(directory)?.tabs.find((entry) => entry.id === tabId);
if (!owningTab) {
try {
await terminal.close(session.sessionId);
} catch { /* ignored */ }
return;
}
setTabSessionId(directory, tabId, session.sessionId);
if (!stillActive) return;
const viewportSize = lastViewportSizeRef.current;
if (
viewportSize &&
(viewportSize.cols !== initialSize.cols || viewportSize.rows !== initialSize.rows)
) {
void terminal.resize({ sessionId: session.sessionId, ...viewportSize }).catch(() => {});
}
// Storing the session ID reruns the session effect. Let that
// effect own stream startup.
return;
} catch (error) {
const owningTab = useTerminalStore.getState().getDirectoryState(directory)?.tabs.find((entry) => entry.id === tabId);
if (!owningTab || owningTab.terminalSessionId) return;
setConnecting(directory, tabId, false);
// Use current store ownership so a rejected create cannot
// leave a tab spinning that no longer owns the request.
if (directoryRef.current !== directory || activeTabIdRef.current !== tabId) return;
setConnectionError(
error instanceof Error
? error.message
: t('terminalView.error.startSessionFailed')
);
setIsFatalError(true);
setIsReconnectPending(false);
return;
} finally {
pendingTerminalCreatesRef.current.delete(createKey);
}
},
[setConnecting, setTabSessionId, t, terminal, terminalLoginShell, terminalShell]
);
React.useEffect(() => {
let cancelled = false;
@@ -475,80 +617,16 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }
return;
}
const createKey = `${directory}\u0000${tabId}`;
if (pendingTerminalCreatesRef.current.has(createKey)) {
return;
}
// Launch the shell while Ghostty is still loading and fitting.
// The backend accepts 80x24, then receives the measured size as
// soon as the viewport is ready.
const initialSize = lastViewportSizeRef.current ?? FALLBACK_TERMINAL_SIZE;
pendingTerminalCreatesRef.current.add(createKey);
setConnectionError(null);
setIsFatalError(false);
setIsReconnectPending(false);
setConnecting(directory, tabId, true);
try {
const session = await terminal.createSession({
cwd: directory,
sessionId: tabId,
cols: initialSize.cols,
rows: initialSize.rows,
shell: terminalShell,
loginShell: terminalLoginShell,
...terminalAppearanceRef.current,
});
const stillActive =
!cancelled &&
directoryRef.current === directory &&
activeTabIdRef.current === tabId;
const owningTab = useTerminalStore.getState().getDirectoryState(directory)?.tabs.find((entry) => entry.id === tabId);
if (!owningTab) {
try {
await terminal.close(session.sessionId);
} catch { /* ignored */ }
return;
}
setTabSessionId(directory, tabId, session.sessionId);
if (!stillActive) return;
const viewportSize = lastViewportSizeRef.current;
if (
viewportSize &&
(viewportSize.cols !== initialSize.cols || viewportSize.rows !== initialSize.rows)
) {
void terminal.resize({ sessionId: session.sessionId, ...viewportSize }).catch(() => {});
}
// Storing the session ID reruns this effect. Let that next
// effect own stream startup: starting here would be torn
// down immediately by this effect's cleanup.
return;
} catch (error) {
const owningTab = useTerminalStore.getState().getDirectoryState(directory)?.tabs.find((entry) => entry.id === tabId);
if (!owningTab || owningTab.terminalSessionId) return;
setConnecting(directory, tabId, false);
// Strict Mode replaces the first effect while its create
// request is pending. `cancelled` therefore does not mean
// this tab stopped owning the request; use current store
// ownership so a rejected create cannot leave it spinning.
if (directoryRef.current !== directory || activeTabIdRef.current !== tabId) return;
setConnectionError(
error instanceof Error
? error.message
: t('terminalView.error.startSessionFailed')
);
setIsFatalError(true);
setIsReconnectPending(false);
return;
} finally {
pendingTerminalCreatesRef.current.delete(createKey);
}
// A visible tab waits for Ghostty's fitted grid; the resize
// handler spawns it the moment that grid arrives. A hidden tab
// cannot be fitted, so it launches at the container estimate or
// 80x24 and resizes once shown.
const fitted = fittedViewportRef.current;
const fittedSize = fitted && fitted.tabId === tabId ? { cols: fitted.cols, rows: fitted.rows } : null;
if (isTerminalVisibleRef.current && !fittedSize) return;
const initialSize = fittedSize ?? lastViewportSizeRef.current ?? FALLBACK_TERMINAL_SIZE;
void createTerminalSession(directory, tabId, initialSize);
return;
}
if (!terminalId || cancelled) return;
@@ -573,6 +651,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }
terminalLifecycle,
activeTabId,
hasOpenedTerminalViewport,
createTerminalSession,
enableTabs,
terminalHydrated,
ensureDirectory,
@@ -687,6 +766,17 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }
});
}, [activeTab, addContextDraft, contextDirectory, currentSessionId, newSessionDraft?.open]);
// Touch hosts have no keyboard shortcut for copy, so the toolbar offers the
// same action the desktop gets from Cmd/Ctrl+C on a selection.
const handleCopySelection = React.useCallback(() => {
const selection = terminalControllerRef.current?.getSelection();
if (!selection?.text) return;
void copyTextToClipboard(selection.text).then((result) => {
if (result.ok) toast.success(t('terminalView.toast.selectionCopied'));
else toast.error(t('terminalView.toast.copyFailed'));
});
}, [t]);
const handleSelectTab = React.useCallback(
(tabId: string) => {
if (!terminalDirectory) return;
@@ -766,14 +856,25 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }
if (!previous || previous.cols !== cols || previous.rows !== rows) {
lastViewportSizeRef.current = { cols, rows };
}
const tabId = activeTabIdRef.current;
const directory = directoryRef.current;
if (tabId) fittedViewportRef.current = { tabId, cols, rows };
if (!isTerminalVisible) {
return;
}
// The fitted grid is what a visible tab was waiting for to spawn.
const tab = tabId && directory
? useTerminalStore.getState().getDirectoryState(directory)?.tabs.find((entry) => entry.id === tabId)
: undefined;
if (tab && directory && tabId && !tab.terminalSessionId && tab.lifecycle !== 'exited' && tab.purpose.type !== 'project-action') {
void createTerminalSession(directory, tabId, { cols, rows });
return;
}
const terminalId = terminalIdRef.current;
if (!terminalId) return;
void terminal.resize({ sessionId: terminalId, cols, rows }).catch(() => {});
},
[isTerminalVisible, terminal]
[createTerminalSession, isTerminalVisible, terminal]
);
const handleModifierToggle = React.useCallback(
@@ -865,6 +966,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }
// here tore down and rebuilt the Ghostty terminal (WASM VT + canvas + font
// atlas) a second time the moment `createSession` resolved, doubling the cost
// of every terminal open. Session changes are handled by the chunk replay path.
// Every tab keeps its viewport mounted; this key names the active one.
const terminalViewportKey = `${terminalDirectory ?? 'no-dir'}::${activeTabId ?? 'no-tab'}`;
React.useEffect(() => {
@@ -933,6 +1035,10 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }
const quickKeysDisabled = !terminalSessionId || isConnecting || isRestarting || isReconnectPending;
const shouldRenderViewport = hasOpenedTerminalViewport;
// Without tabs (VS Code) only the first tab exists; with tabs every open tab stays mounted.
const mountedTabIds = enableTabs
? (directoryTerminalState?.tabs ?? []).map((tab) => tab.id)
: (activeTabId ? [activeTabId] : []);
const quickKeySize: 'lg' | 'xs' = isTouchTerminal ? 'lg' : 'xs';
const quickKeyIconClass = isTouchTerminal ? 'w-10 p-0' : 'w-9 p-0';
const preserveTerminalFocus = (event: React.PointerEvent<HTMLButtonElement>) => {
@@ -1094,6 +1200,17 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }
>
<Icon name="attachment-2" className="h-4 w-4" />
</Button>
<Button
type="button"
size="xs"
variant="ghost"
className="h-7 w-7 p-0"
onClick={handleCopySelection}
title={t('terminalView.actions.copySelection')}
aria-label={t('terminalView.actions.copySelection')}
>
<Icon name="file-copy" className="h-4 w-4" />
</Button>
{previewUrl ? (
<Button
type="button"
@@ -1132,14 +1249,14 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }
style={{ backgroundColor: xtermTheme.background }}
>
<div className="h-full w-full box-border pl-4 pr-1.5 pt-3 pb-4">
{shouldRenderViewport ? (
<TerminalViewport
key={terminalViewportKey}
ref={(controller) => {
terminalControllerRef.current = controller;
}}
sessionKey={terminalViewportKey}
chunks={bufferChunks}
{shouldRenderViewport ? mountedTabIds.map((tabId) => (
<TerminalTabViewport
key={`${terminalDirectory}::${tabId}`}
directory={terminalDirectory}
tabId={tabId}
isActive={tabId === activeTabId}
isTerminalVisible={isTerminalVisible}
registerController={registerTabController}
onInput={handleViewportInput}
onResize={handleViewportResize}
onProvisionalSize={handleProvisionalSize}
@@ -1148,10 +1265,8 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }
fontFamily={resolvedFontStack}
fontSize={terminalFontSize}
enableTouchScroll={useTouchTerminalInput}
autoFocus={isTerminalVisible}
isVisible={isTerminalVisible}
/>
) : null}
)) : null}
</div>
{!isReconnectPending && connectionError && (
<div className="absolute inset-x-0 bottom-0 bg-[var(--status-error-background)] px-3 py-2 text-xs text-[var(--status-error-foreground)] flex items-center justify-between gap-2">
@@ -38,29 +38,33 @@ describe('terminal viewport remount guard', () => {
expect(viewportKeyDeclaration).not.toContain('terminalSessionId');
});
test('replay discontinuities reset the terminal in place instead of remounting it', () => {
const start = terminalViewportSource.indexOf('const recreateRenderer = React.useCallback(');
expect(start).toBeGreaterThan(-1);
const body = terminalViewportSource.slice(start, terminalViewportSource.indexOf('}, []);', start));
expect(body).toContain('terminal.reset()');
// The generation bump stays only as the fallback when no terminal exists yet.
expect(body.indexOf('if (!terminal)')).toBeLessThan(body.indexOf('terminal.reset()'));
test('replay discontinuities reset the surface in place instead of remounting it', () => {
expect(terminalViewportSource).toContain('surface.resetAndWrite(');
expect(terminalViewportSource).not.toContain('setRendererGeneration');
});
test('every open tab keeps its viewport mounted and only the active one is visible', () => {
expect(terminalViewSource).toContain('mountedTabIds.map((tabId) =>');
expect(terminalViewSource).toContain("!isActive && 'hidden'");
expect(terminalViewSource).toContain('isVisible={isTerminalVisible && isActive}');
});
test('scrollback is read from the buffer slice, not from the tab', () => {
expect(terminalViewSource).toContain('getBuffer(');
expect(terminalViewSource).toContain('s.getBuffer(directory, tabId).chunks');
expect(terminalViewSource).not.toContain('activeTab?.bufferChunks');
});
test('starts the PTY before Ghostty reports its first viewport size', () => {
test('spawns a visible tab at the fitted grid and a hidden one at the estimate', () => {
expect(terminalViewSource).toContain('const FALLBACK_TERMINAL_SIZE = { cols: 80, rows: 24 } as const;');
expect(terminalViewSource).toContain('const initialSize = lastViewportSizeRef.current ?? FALLBACK_TERMINAL_SIZE;');
expect(terminalViewSource).toContain('const initialSize = fittedSize ?? lastViewportSizeRef.current ?? FALLBACK_TERMINAL_SIZE;');
// A visible tab waits for the fitted grid so zsh never prints its first prompt for a wider PTY.
expect(terminalViewSource).toContain('if (isTerminalVisibleRef.current && !fittedSize) return;');
expect(terminalViewSource).not.toContain('if (!size && isTerminalVisibleRef.current)');
expect(terminalViewSource).toContain('cols: initialSize.cols');
expect(terminalViewSource).toContain('rows: initialSize.rows');
expect(terminalViewSource).toContain('void terminal.resize({ sessionId: session.sessionId, ...viewportSize })');
expect(terminalViewSource).toContain('if (!isTerminalVisible) {');
expect(terminalViewSource).not.toContain('isTerminalVisibleRef');
expect(terminalViewSource).not.toContain('if (!size && isTerminalVisibleRef.current)');
});
test('deduplicates create attempts while the viewport layout settles', () => {
@@ -69,19 +73,19 @@ describe('terminal viewport remount guard', () => {
});
test('lets the session-ID effect own stream startup after creating a tab', () => {
const createStart = terminalViewSource.indexOf('if (!terminalId) {');
const createEnd = terminalViewSource.indexOf('if (!terminalId || cancelled) return;', createStart);
const createStart = terminalViewSource.indexOf('const createTerminalSession = React.useCallback(');
const createEnd = terminalViewSource.indexOf('React.useEffect(() => {', createStart);
expect(createStart).toBeGreaterThan(-1);
expect(createEnd).toBeGreaterThan(createStart);
const createBlock = terminalViewSource.slice(createStart, createEnd);
expect(createBlock).toContain('setTabSessionId(directory, tabId, session.sessionId);');
expect(createBlock).toContain('Let that next');
expect(createBlock).toContain('Let that');
expect(createBlock).not.toContain('startStream(');
});
test('clears a current tab from connecting when a strict-mode create rejects', () => {
const createStart = terminalViewSource.indexOf('if (!terminalId) {');
const createStart = terminalViewSource.indexOf('const createTerminalSession = React.useCallback(');
const catchStart = terminalViewSource.indexOf('} catch (error) {', createStart);
const catchEnd = terminalViewSource.indexOf('} finally {', catchStart);
expect(catchStart).toBeGreaterThan(createStart);
@@ -97,16 +101,14 @@ describe('terminal viewport remount guard', () => {
test('derives the initial PTY size before Ghostty mounts', () => {
expect(terminalViewportSource).toContain('const getProvisionalTerminalSize');
expect(terminalViewportSource).toContain('React.useLayoutEffect(() => {');
expect(terminalViewportSource).toContain('resizeRef.current(size.cols, size.rows)');
expect(terminalViewportSource).toContain('...(provisionalSizeRef.current ?? {})');
expect(terminalViewportSource).toContain('(provisionalSizeCallbackRef.current ?? resizeRef.current)(size.cols, size.rows)');
});
test('rebuilds the canvas renderer when terminal fonts finish loading after the startup bound', () => {
expect(terminalViewportSource).toContain('loadMonoFont(font)');
expect(terminalViewportSource).toContain('Promise.all([loadMonoFont(font), loadNerdFonts()])');
expect(terminalViewportSource).toContain('Promise.all([loadGhostty(), fonts.loadedBeforeTimeout])');
expect(terminalViewportSource).toContain('if (!fontsLoaded)');
expect(terminalViewportSource).toContain('void fonts.loaded.then(() => {');
expect(terminalViewportSource).toContain('setRendererGeneration((value) => value + 1)');
test('waits for the selected mono face with a bound before the surface measures the grid', () => {
expect(terminalViewportSource).toContain('const TERMINAL_FONT_WAIT_MS = 2000;');
expect(terminalViewportSource).toContain('await waitForMonoFont(initialMonoFont);');
// A surface lives for the whole mount; fonts and theme are applied in place.
expect(terminalViewportSource).toContain('surfaceRef.current?.setFont(');
expect(terminalViewportSource).toContain('surfaceRef.current?.setTheme(');
});
});