diff --git a/.opencode/screenshots/terminal-final.png b/.opencode/screenshots/terminal-final.png new file mode 100644 index 00000000..ab8d0cbb Binary files /dev/null and b/.opencode/screenshots/terminal-final.png differ diff --git a/.opencode/screenshots/terminal-parallel-start.png b/.opencode/screenshots/terminal-parallel-start.png new file mode 100644 index 00000000..84c137ba Binary files /dev/null and b/.opencode/screenshots/terminal-parallel-start.png differ diff --git a/.opencode/screenshots/terminal-reset.png b/.opencode/screenshots/terminal-reset.png new file mode 100644 index 00000000..447a9da0 Binary files /dev/null and b/.opencode/screenshots/terminal-reset.png differ diff --git a/.opencode/screenshots/terminal-startup.png b/.opencode/screenshots/terminal-startup.png new file mode 100644 index 00000000..39296cd3 Binary files /dev/null and b/.opencode/screenshots/terminal-startup.png differ diff --git a/bun.lock b/bun.lock index 070da26b..e9ab4100 100644 --- a/bun.lock +++ b/bun.lock @@ -97,7 +97,7 @@ }, "packages/electron": { "name": "@openchamber/electron", - "version": "1.17.1", + "version": "1.17.2", "dependencies": { "@openchamber/web": "workspace:*", "better-sqlite3": "^12.10.0", @@ -134,7 +134,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.17.1", + "version": "1.17.2", "dependencies": { "@aparajita/capacitor-secure-storage": "^8.0.0", "@base-ui/react": "^1.4.0", @@ -239,7 +239,7 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.17.1", + "version": "1.17.2", "dependencies": { "@openchamber/ui": "workspace:*", "@opencode-ai/sdk": "1.18.11", @@ -262,7 +262,7 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.17.1", + "version": "1.17.2", "bin": { "openchamber": "./bin/cli.js", }, diff --git a/packages/ui/src/components/terminal/TerminalViewport.tsx b/packages/ui/src/components/terminal/TerminalViewport.tsx index 90b834d3..cd86497f 100644 --- a/packages/ui/src/components/terminal/TerminalViewport.tsx +++ b/packages/ui/src/components/terminal/TerminalViewport.tsx @@ -18,6 +18,42 @@ import type { TerminalChunk } from '@/stores/useTerminalStore'; let ghosttyPromise: Promise | null = null; const loadGhostty = (): Promise => ghosttyPromise ??= Ghostty.load(); +type TerminalSize = { cols: number; rows: number }; + +const getProvisionalTerminalSize = ( + container: HTMLDivElement, + fontFamily: string, + fontSize: number, +): TerminalSize | null => { + if (typeof window === 'undefined' || typeof document === 'undefined') return null; + + const context = document.createElement('canvas').getContext('2d'); + if (!context || container.clientWidth < 24 || container.clientHeight < 24) return null; + + context.font = `${fontSize}px ${fontFamily}`; + const metrics = context.measureText('M'); + const cellWidth = Math.ceil(metrics.width); + const cellHeight = Math.ceil( + (metrics.actualBoundingBoxAscent || fontSize * 0.8) + + (metrics.actualBoundingBoxDescent || fontSize * 0.2), + ) + 2; + if (cellWidth < 1 || cellHeight < 1) return null; + + const style = window.getComputedStyle(container); + const horizontalPadding = + (Number.parseInt(style.paddingLeft, 10) || 0) + + (Number.parseInt(style.paddingRight, 10) || 0); + const verticalPadding = + (Number.parseInt(style.paddingTop, 10) || 0) + + (Number.parseInt(style.paddingBottom, 10) || 0); + + // Match Ghostty FitAddon's 15px scrollbar reservation and minimum dimensions. + return { + cols: Math.max(2, Math.floor((container.clientWidth - horizontalPadding - 15) / cellWidth)), + rows: Math.max(1, Math.floor((container.clientHeight - verticalPadding) / cellHeight)), + }; +}; + export type TerminalController = { focus: () => void; fit: () => void; @@ -47,7 +83,8 @@ const TerminalViewport = React.forwardRef(({ const fitRef = React.useRef(null); const inputRef = React.useRef(onInput); const resizeRef = React.useRef(onResize); - const lastSizeRef = React.useRef<{ cols: number; rows: number } | null>(null); + const lastSizeRef = React.useRef(null); + const provisionalSizeRef = React.useRef(null); const lastChunkRef = React.useRef(null); const writeQueueRef = React.useRef(''); const outputRewriteCarryRef = React.useRef(''); @@ -65,6 +102,14 @@ const TerminalViewport = React.forwardRef(({ visibleRef.current = isVisible; safeResetRef.current = getGhosttySafeResetSequence(theme.background); + React.useLayoutEffect(() => { + const container = containerRef.current; + if (!container) return; + const size = getProvisionalTerminalSize(container, fontFamily, fontSize); + provisionalSizeRef.current = size; + if (size) resizeRef.current(size.cols, size.rows); + }, [fontFamily, fontSize]); + const fit = React.useCallback(() => { const container = containerRef.current; const terminal = terminalRef.current; @@ -168,7 +213,10 @@ const TerminalViewport = React.forwardRef(({ loadGhostty().then((ghostty) => { if (disposed) return; - terminal = new GhosttyTerminal(getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, false)); + terminal = new GhosttyTerminal({ + ...getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, false), + ...(provisionalSizeRef.current ?? {}), + }); const fitAddon = new FitAddon(); terminal.loadAddon(fitAddon); terminal.open(container); diff --git a/packages/ui/src/components/views/TerminalView.tsx b/packages/ui/src/components/views/TerminalView.tsx index a373f880..0c8e6465 100644 --- a/packages/ui/src/components/views/TerminalView.tsx +++ b/packages/ui/src/components/views/TerminalView.tsx @@ -26,6 +26,8 @@ type TerminalViewProps = { visible?: boolean; }; +const FALLBACK_TERMINAL_SIZE = { cols: 80, rows: 24 } as const; + export const TerminalView: React.FC = ({ visible }) => { const { t } = useI18n(); const { terminal, runtime } = useRuntimeAPIs(); @@ -109,7 +111,6 @@ export const TerminalView: React.FC = ({ visible }) => { const [isReconnectPending, setIsReconnectPending] = React.useState(false); const [activeModifier, setActiveModifier] = React.useState(null); const [isRestarting, setIsRestarting] = React.useState(false); - const [hasViewportSize, setHasViewportSize] = React.useState(false); const streamCleanupRef = React.useRef<(() => void) | null>(null); const activeTerminalIdRef = React.useRef(null); @@ -118,7 +119,7 @@ export const TerminalView: React.FC = ({ visible }) => { const directoryRef = React.useRef(effectiveDirectory); const terminalControllerRef = React.useRef(null); const lastViewportSizeRef = React.useRef<{ cols: number; rows: number } | null>(null); - const isTerminalVisibleRef = React.useRef(false); + const pendingTerminalCreatesRef = React.useRef(new Set()); const previewScanTailRef = React.useRef(''); const pendingPreviewProbeUrlsRef = React.useRef>(new Set()); const previewProbeGenerationRef = React.useRef(0); @@ -157,10 +158,6 @@ export const TerminalView: React.FC = ({ visible }) => { } }, [isTerminalVisible]); - React.useEffect(() => { - isTerminalVisibleRef.current = isTerminalVisible; - }, [isTerminalVisible]); - React.useEffect(() => { terminalIdRef.current = terminalSessionId; }, [terminalSessionId]); @@ -441,11 +438,17 @@ export const TerminalView: React.FC = ({ visible }) => { return; } - const size = lastViewportSizeRef.current; - if (!size && isTerminalVisibleRef.current) { + 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); @@ -454,8 +457,8 @@ export const TerminalView: React.FC = ({ visible }) => { const session = await terminal.createSession({ cwd: directory, sessionId: tabId, - cols: size?.cols, - rows: size?.rows, + cols: initialSize.cols, + rows: initialSize.rows, shell: terminalShell, loginShell: terminalLoginShell, ...terminalAppearanceRef.current, @@ -476,6 +479,14 @@ export const TerminalView: React.FC = ({ visible }) => { 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(() => {}); + } terminalId = session.sessionId; } catch (error) { if (!cancelled) { @@ -489,6 +500,8 @@ export const TerminalView: React.FC = ({ visible }) => { setConnecting(directory, tabId, false); } return; + } finally { + pendingTerminalCreatesRef.current.delete(createKey); } } @@ -513,7 +526,6 @@ export const TerminalView: React.FC = ({ visible }) => { terminalLifecycle, activeTabId, hasOpenedTerminalViewport, - hasViewportSize, enableTabs, terminalHydrated, ensureDirectory, @@ -568,7 +580,7 @@ export const TerminalView: React.FC = ({ visible }) => { resetTerminalPreviewScan(); try { - const size = lastViewportSizeRef.current ?? { cols: 80, rows: 24 }; + const size = lastViewportSizeRef.current ?? FALLBACK_TERMINAL_SIZE; const restarted = await terminal.restartSession(originalSessionId, { cwd: effectiveDirectory, shell: terminalShell, loginShell: terminalLoginShell, ...size, ...terminalAppearanceRef.current }); const owningTab = useTerminalStore.getState().getDirectoryState(effectiveDirectory)?.tabs.find((tab) => tab.id === tabId); if (owningTab?.terminalSessionId !== originalSessionId) return; @@ -696,11 +708,10 @@ export const TerminalView: React.FC = ({ visible }) => { const previous = lastViewportSizeRef.current; if (!previous) { lastViewportSizeRef.current = { cols, rows }; - if (!terminalIdRef.current) setHasViewportSize(true); } else if (previous.cols !== cols || previous.rows !== rows) { lastViewportSizeRef.current = { cols, rows }; } - if (!isTerminalVisibleRef.current) { + if (!isTerminalVisible) { return; } const terminalId = terminalIdRef.current; @@ -709,7 +720,7 @@ export const TerminalView: React.FC = ({ visible }) => { }); }, - [terminal] + [isTerminalVisible, terminal] ); const handleModifierToggle = React.useCallback( diff --git a/packages/ui/src/components/views/__tests__/terminalViewportRemount.test.ts b/packages/ui/src/components/views/__tests__/terminalViewportRemount.test.ts index f33bd2cd..6b03ec22 100644 --- a/packages/ui/src/components/views/__tests__/terminalViewportRemount.test.ts +++ b/packages/ui/src/components/views/__tests__/terminalViewportRemount.test.ts @@ -2,8 +2,8 @@ * Regression guard for slow terminal opening on Linux. * * `TerminalViewport` is keyed by `terminalViewportKey`. That key used to include - * the PTY session id, which is null until `createSession` resolves. Because the - * viewport must mount first to report its size before a session can be created, + * the PTY session id, which is null until `createSession` resolves. Historically, + * the viewport had to mount first to report its size before session creation, so * every terminal open built a Ghostty terminal (WASM VT + 2D canvas renderer + * font atlas), threw it away when the session id arrived, and built a second one. * The same churn repeated on reconnect and on every incidental session-id change, @@ -12,6 +12,8 @@ * * Viewport identity must therefore be directory + tab only. Session changes are * handled by the chunk replay path, which resets the existing terminal in place. + * New sessions start concurrently with a container-derived size (or 80x24) and + * resize after their viewport fits. */ import { describe, expect, test } from 'bun:test'; import { readFileSync } from 'node:fs'; @@ -61,4 +63,27 @@ describe('terminal viewport remount guard', () => { expect(terminalViewSource).toContain('getBuffer('); expect(terminalViewSource).not.toContain('activeTab?.bufferChunks'); }); + + test('starts the PTY before Ghostty reports its first viewport size', () => { + 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).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'); + }); + + test('deduplicates create attempts while the viewport layout settles', () => { + expect(terminalViewSource).toContain('pendingTerminalCreatesRef.current.has(createKey)'); + expect(terminalViewSource).toContain('pendingTerminalCreatesRef.current.delete(createKey)'); + }); + + 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 ?? {})'); + }); }); diff --git a/packages/web/server/lib/terminal/DOCUMENTATION.md b/packages/web/server/lib/terminal/DOCUMENTATION.md index ffc27411..fb943ab0 100644 --- a/packages/web/server/lib/terminal/DOCUMENTATION.md +++ b/packages/web/server/lib/terminal/DOCUMENTATION.md @@ -23,6 +23,7 @@ HTTP remains the authenticated command plane for create, resize, appearance upda - IDs are client-provided or generated with `randomUUID()`. - Concurrent creates for one ID are single-flight only when working directory and shell preference match. Existing IDs cannot be reused for another working directory. - Dimensions are bounded to 1-1000 columns and 1-500 rows; input is capped at 64 KiB. +- A client may create before its renderer has mounted. It derives an initial size from the container and font metrics (falling back to 80x24 when unavailable), then sends a resize once Ghostty reports its final dimensions. This allows shell startup and renderer initialization to overlap. - PTY children explicitly clear `NODE_CHANNEL_FD`; daemon IPC descriptors are host-private and invalid after PTY descriptor cleanup. - `GET /api/terminal/shells` reports shell IDs available on the active server using the same augmented PATH provided to spawned PTYs, plus whether each executable has a supported login-mode argument. `auto` preserves environment/platform fallback order; an explicit unavailable shell fails creation instead of silently running a different shell. Login mode is opt-in and uses only built-in arguments for known shells. Preference changes affect new sessions and explicit restarts, not running PTYs. - PTY data and exit callbacks enter one FIFO queue. Stale callbacks from replaced processes are ignored.