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:
@@ -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 () => {
|
||||
|
||||
@@ -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(');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user