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-patches/bun-pty@0.4.8.patch b/bun-patches/bun-pty@0.4.8.patch new file mode 100644 index 00000000..f65b3bba --- /dev/null +++ b/bun-patches/bun-pty@0.4.8.patch @@ -0,0 +1,77 @@ +diff --git a/src/terminal.ts b/src/terminal.ts +index ec248d46a939f8a09cd669e853cefb126922c80a..c0473bc625edda7be2ade987e8aa3bd99160ce67 100644 +--- a/src/terminal.ts ++++ b/src/terminal.ts +@@ -11,6 +11,7 @@ export const DEFAULT_COLS = 80; + export const DEFAULT_ROWS = 24; + export const DEFAULT_FILE = "sh"; + export const DEFAULT_NAME = "xterm"; ++const INITIAL_OUTPUT_BUFFER_LIMIT = 512 * 1024; + + /** + * Quote a string for shell-words compatible splitting on the Rust side. +@@ -136,6 +137,8 @@ export class Terminal implements IPty { + + private _readLoop = false; + private _closing = false; ++ private _hasDataSubscriber = false; ++ private _initialOutput = ""; + + // TextDecoder with streaming mode to properly handle UTF-8 across chunk boundaries + // Without this, multi-byte characters (like box-drawing ─) that span chunks become � +@@ -191,12 +194,29 @@ export class Terminal implements IPty { + } + + get onData() { +- return this._onData.event; ++ return (listener: (data: string) => void) => { ++ const disposable = this._onData.event(listener); ++ if (!this._hasDataSubscriber) { ++ this._hasDataSubscriber = true; ++ const initialOutput = this._initialOutput; ++ this._initialOutput = ""; ++ if (initialOutput) listener(initialOutput); ++ } ++ return disposable; ++ }; + } + get onExit() { + return this._onExit.event; + } + ++ private _emitData(data: string) { ++ if (this._hasDataSubscriber) { ++ this._onData.fire(data); ++ } else { ++ this._initialOutput = `${this._initialOutput}${data}`.slice(-INITIAL_OUTPUT_BUFFER_LIMIT); ++ } ++ } ++ + /* ------------- IO methods ------------- */ + + write(data: string) { +@@ -235,13 +255,13 @@ export class Terminal implements IPty { + // This prevents corruption when multi-byte chars span chunk boundaries + const decoded = this._decoder.decode(buf.subarray(0, n), { stream: true }); + if (decoded) { +- this._onData.fire(decoded); ++ this._emitData(decoded); + } + } else if (n === -2) { + // CHILD_EXITED - flush any remaining bytes in the decoder + const remaining = this._decoder.decode(); + if (remaining) { +- this._onData.fire(remaining); ++ this._emitData(remaining); + } + const exitCode = lib.symbols.bun_pty_get_exit_code(this.handle); + this._onExit.fire({ exitCode }); +@@ -250,7 +270,7 @@ export class Terminal implements IPty { + // error - flush decoder before breaking + const remaining = this._decoder.decode(); + if (remaining) { +- this._onData.fire(remaining); ++ this._emitData(remaining); + } + break; + } else { diff --git a/bun.lock b/bun.lock index 15b89095..2209fc6d 100644 --- a/bun.lock +++ b/bun.lock @@ -95,7 +95,7 @@ }, "packages/electron": { "name": "@openchamber/electron", - "version": "1.17.1", + "version": "1.17.2", "dependencies": { "@openchamber/web": "workspace:*", "better-sqlite3": "^12.10.0", @@ -132,7 +132,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", @@ -237,7 +237,7 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.17.1", + "version": "1.17.2", "dependencies": { "@openchamber/ui": "workspace:*", "@opencode-ai/sdk": "1.18.11", @@ -260,7 +260,7 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.17.1", + "version": "1.17.2", "bin": { "openchamber": "./bin/cli.js", }, @@ -352,6 +352,7 @@ ], "patchedDependencies": { "@tanstack/virtual-core@3.17.3": "bun-patches/@tanstack+virtual-core+3.17.3.patch", + "bun-pty@0.4.8": "bun-patches/bun-pty@0.4.8.patch", }, "overrides": { "@codemirror/language": "6.12.2", diff --git a/package.json b/package.json index 3561555d..030fc1d0 100644 --- a/package.json +++ b/package.json @@ -176,6 +176,7 @@ "vite": "^7.1.2" }, "patchedDependencies": { - "@tanstack/virtual-core@3.17.3": "bun-patches/@tanstack+virtual-core+3.17.3.patch" + "@tanstack/virtual-core@3.17.3": "bun-patches/@tanstack+virtual-core+3.17.3.patch", + "bun-pty@0.4.8": "bun-patches/bun-pty@0.4.8.patch" } } 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..c97f608e 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]); @@ -424,7 +421,7 @@ export const TerminalView: React.FC = ({ visible }) => { } const tab = state.tabs.find((t) => t.id === tabId) ?? state.tabs[0]; - let terminalId = tab?.terminalSessionId ?? null; + const terminalId = tab?.terminalSessionId ?? null; const terminalLifecycle = tab?.lifecycle ?? 'idle'; const isActionTab = Boolean(tab?.label?.startsWith('Action:')); const buffer = useTerminalStore.getState().getBuffer(directory, tabId); @@ -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,19 +479,38 @@ export const TerminalView: React.FC = ({ visible }) => { setTabSessionId(directory, tabId, session.sessionId); if (!stillActive) return; - terminalId = session.sessionId; - } catch (error) { - if (!cancelled) { - setConnectionError( - error instanceof Error - ? error.message - : t('terminalView.error.startSessionFailed') - ); - setIsFatalError(true); - setIsReconnectPending(false); - setConnecting(directory, tabId, false); + + 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); } } @@ -513,7 +535,6 @@ export const TerminalView: React.FC = ({ visible }) => { terminalLifecycle, activeTabId, hasOpenedTerminalViewport, - hasViewportSize, enableTabs, terminalHydrated, ensureDirectory, @@ -568,7 +589,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; @@ -694,22 +715,17 @@ export const TerminalView: React.FC = ({ visible }) => { const handleViewportResize = React.useCallback( (cols: number, rows: number) => { const previous = lastViewportSizeRef.current; - if (!previous) { - lastViewportSizeRef.current = { cols, rows }; - if (!terminalIdRef.current) setHasViewportSize(true); - } else if (previous.cols !== cols || previous.rows !== rows) { + if (!previous || previous.cols !== cols || previous.rows !== rows) { lastViewportSizeRef.current = { cols, rows }; } - if (!isTerminalVisibleRef.current) { + if (!isTerminalVisible) { return; } const terminalId = terminalIdRef.current; if (!terminalId) return; - void terminal.resize({ sessionId: terminalId, cols, rows }).catch(() => { - - }); + void terminal.resize({ sessionId: terminalId, cols, rows }).catch(() => {}); }, - [terminal] + [isTerminalVisible, terminal] ); const handleModifierToggle = React.useCallback( @@ -801,11 +817,7 @@ export const TerminalView: React.FC = ({ visible }) => { // 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. - const terminalViewportKey = React.useMemo(() => { - const directoryPart = effectiveDirectory ?? 'no-dir'; - const tabPart = activeTabId ?? 'no-tab'; - return `${directoryPart}::${tabPart}`; - }, [effectiveDirectory, activeTabId]); + const terminalViewportKey = `${effectiveDirectory ?? 'no-dir'}::${activeTabId ?? 'no-tab'}`; React.useEffect(() => { if (!isTerminalVisible || useTouchTerminalInput) { diff --git a/packages/ui/src/components/views/__tests__/terminalViewportRemount.test.ts b/packages/ui/src/components/views/__tests__/terminalViewportRemount.test.ts index f33bd2cd..d03379c1 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'; @@ -25,27 +27,15 @@ const terminalViewportSource = readFileSync( 'utf-8', ); -const viewportKeyBlock = (() => { - const start = terminalViewSource.indexOf('const terminalViewportKey = React.useMemo('); - expect(start).toBeGreaterThan(-1); - const end = terminalViewSource.indexOf('}, [', start); - expect(end).toBeGreaterThan(start); - return terminalViewSource.slice(start, terminalViewSource.indexOf(');', end)); -})(); +const viewportKeyDeclaration = terminalViewSource + .split('\n') + .find((line) => line.includes('const terminalViewportKey =')) ?? ''; describe('terminal viewport remount guard', () => { test('viewport identity excludes the PTY session id', () => { - expect(viewportKeyBlock).toContain('effectiveDirectory'); - expect(viewportKeyBlock).toContain('activeTabId'); - expect(viewportKeyBlock).not.toContain('terminalSessionId'); - }); - - test('viewport key memo does not depend on the PTY session id', () => { - const dependencyStart = terminalViewSource.indexOf('}, [', terminalViewSource.indexOf('const terminalViewportKey')); - const dependencies = terminalViewSource.slice(dependencyStart, terminalViewSource.indexOf(']', dependencyStart)); - expect(dependencies).toContain('effectiveDirectory'); - expect(dependencies).toContain('activeTabId'); - expect(dependencies).not.toContain('terminalSessionId'); + expect(viewportKeyDeclaration).toContain('effectiveDirectory'); + expect(viewportKeyDeclaration).toContain('activeTabId'); + expect(viewportKeyDeclaration).not.toContain('terminalSessionId'); }); test('replay discontinuities reset the terminal in place instead of remounting it', () => { @@ -61,4 +51,53 @@ 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('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); + 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).not.toContain('startStream('); + }); + + test('clears a current tab from connecting when a strict-mode create rejects', () => { + const createStart = terminalViewSource.indexOf('if (!terminalId) {'); + const catchStart = terminalViewSource.indexOf('} catch (error) {', createStart); + const catchEnd = terminalViewSource.indexOf('} finally {', catchStart); + expect(catchStart).toBeGreaterThan(createStart); + expect(catchEnd).toBeGreaterThan(catchStart); + const catchBlock = terminalViewSource.slice(catchStart, catchEnd); + + expect(catchBlock).toContain('owningTab.terminalSessionId'); + expect(catchBlock).toContain('activeTabIdRef.current !== tabId'); + expect(catchBlock).toContain('setConnecting(directory, tabId, false);'); + expect(catchBlock).not.toContain('if (!cancelled)'); + }); + + 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/ui/src/lib/terminalApi.test.ts b/packages/ui/src/lib/terminalApi.test.ts index b02aa0ac..7573984a 100644 --- a/packages/ui/src/lib/terminalApi.test.ts +++ b/packages/ui/src/lib/terminalApi.test.ts @@ -93,6 +93,157 @@ describe('terminal transport', () => { transport.dispose(); }); + test('invalidates URL auth when the current socket closes before opening', async () => { + const socket = new FakeSocket(); + let cleared = 0; + const transport = new TerminalTransport({ + refreshAuth: async () => '', + openSocket: () => socket, + clearUrlAuthToken: () => { cleared += 1; }, + }); + + const unsubscribe = transport.subscribe('term-1', { onEvent: () => {} }); + await tick(); + socket.close(); + await tick(); + + expect(cleared).toBe(1); + unsubscribe(); + transport.dispose(); + }); + + test('invalidates URL auth before retrying a pre-open socket error', async () => { + const socket = new FakeSocket(); + let cleared = 0; + const transport = new TerminalTransport({ + refreshAuth: async () => '', + openSocket: () => socket, + clearUrlAuthToken: () => { cleared += 1; }, + }); + + const unsubscribe = transport.subscribe('term-1', { onEvent: () => {} }); + await tick(); + socket.onerror?.(); + + expect(cleared).toBe(1); + unsubscribe(); + transport.dispose(); + }); + + test('does not let a cancelled opening reconnect a replacement subscription', async () => { + const sockets = [new FakeSocket(), new FakeSocket()]; + let socketIndex = 0; + const replacementEvents: string[] = []; + const transport = new TerminalTransport({ + refreshAuth: async () => '', + openSocket: () => sockets[socketIndex++]!, + }); + + const unsubscribeFirst = transport.subscribe('term-1', { onEvent: () => {} }); + await tick(); + unsubscribeFirst(); + + const unsubscribeReplacement = transport.subscribe('term-1', { + onEvent: (event) => replacementEvents.push(event.type), + }); + await tick(); + sockets[1]?.open(); + await tick(); + + expect(replacementEvents).not.toContain('reconnecting'); + unsubscribeReplacement(); + transport.dispose(); + }); + + test('starts a fresh reconnect sequence after every terminal has detached', async () => { + const firstEvents: number[] = []; + const replacementEvents: number[] = []; + const transport = new TerminalTransport({ + refreshAuth: async () => '', + openSocket: () => { throw new Error('offline'); }, + }); + + const unsubscribeFirst = transport.subscribe('term-1', { + onEvent: (event) => { + if (event.type === 'reconnecting' && typeof event.attempt === 'number') firstEvents.push(event.attempt); + }, + }); + await tick(); + await tick(); + expect(firstEvents).toEqual([1]); + + unsubscribeFirst(); + const unsubscribeReplacement = transport.subscribe('term-2', { + onEvent: (event) => { + if (event.type === 'reconnecting' && typeof event.attempt === 'number') replacementEvents.push(event.attempt); + }, + }); + await tick(); + await tick(); + + expect(replacementEvents).toEqual([1]); + unsubscribeReplacement(); + transport.dispose(); + }); + + test('waits a minute before reconnecting while hidden', async () => { + const originalSetTimeout = globalThis.setTimeout; + const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document'); + const delays: number[] = []; + let transport: TerminalTransport | null = null; + + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + visibilityState: 'hidden', + addEventListener: () => {}, + removeEventListener: () => {}, + }, + }); + globalThis.setTimeout = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => { + delays.push(Number(timeout ?? 0)); + if (timeout === 0) return originalSetTimeout(handler, 0, ...args); + return 0 as unknown as ReturnType; + }) as typeof setTimeout; + + try { + transport = new TerminalTransport({ + refreshAuth: async () => '', + openSocket: () => { throw new Error('offline'); }, + }); + transport.subscribe('term-1', { onEvent: () => {} }); + await tick(); + await tick(); + + expect(delays).toContain(60_000); + } finally { + transport?.dispose(); + globalThis.setTimeout = originalSetTimeout; + if (originalDocument) Object.defineProperty(globalThis, 'document', originalDocument); + else delete (globalThis as { document?: unknown }).document; + } + }); + + test('attaches a remaining same-terminal subscriber after the first one leaves', async () => { + const socket = new FakeSocket(); + const transport = new TerminalTransport({ refreshAuth: async () => '', openSocket: () => socket }); + + const unsubscribeOther = transport.subscribe('term-other', { onEvent: () => {} }); + await tick(); + socket.open(); + await tick(); + + const unsubscribeFirst = transport.subscribe('term-1', { onEvent: () => {} }); + const unsubscribeRemaining = transport.subscribe('term-1', { onEvent: () => {} }); + unsubscribeFirst(); + await tick(); + + expect(socket.sent.filter((message) => message.t === 'attach' && message.s === 'term-1')).toHaveLength(1); + unsubscribeRemaining(); + unsubscribeOther(); + transport.dispose(); + }); + test('releases replay projections when the last subscriber detaches', async () => { const socket = new FakeSocket(); const transport = new TerminalTransport({ refreshAuth: async () => '', openSocket: () => socket }); diff --git a/packages/ui/src/lib/terminalApi.ts b/packages/ui/src/lib/terminalApi.ts index 3e555bf7..4bfce9d5 100644 --- a/packages/ui/src/lib/terminalApi.ts +++ b/packages/ui/src/lib/terminalApi.ts @@ -3,7 +3,7 @@ import { openRuntimeWebSocket } from './relay/runtime-socket'; import type { RelayTunnelWebSocket } from './relay/tunnel-client'; import { runtimeFetch } from './runtime-fetch'; import { getRuntimeUrlResolver } from './runtime-url'; -import { refreshRuntimeUrlAuthToken } from './runtime-auth'; +import { clearRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken } from './runtime-auth'; import { isTerminalShell } from './terminalShell'; type Message = Record & { t: string; s?: string; q?: number }; @@ -66,12 +66,12 @@ const trimProjection = (value: string): string => { type TerminalTransportDependencies = { refreshAuth: () => Promise; openSocket: () => RelayTunnelWebSocket; + clearUrlAuthToken?: () => void; }; export class TerminalTransport { private socket: RelayTunnelWebSocket | null = null; private opening: Promise | null = null; - private openingGeneration: number | null = null; private subscribers = new Map>(); private projections = new Map(); private reconnectTimer: ReturnType | null = null; @@ -85,6 +85,7 @@ export class TerminalTransport { constructor(private readonly dependencies: TerminalTransportDependencies = { refreshAuth: refreshRuntimeUrlAuthToken, openSocket: () => openRuntimeWebSocket(getRuntimeUrlResolver().websocket('/api/terminal/ws')), + clearUrlAuthToken: clearRuntimeUrlAuthToken, }) {} subscribe(sessionId: string, handlers: TerminalHandlers): () => void { @@ -100,7 +101,13 @@ export class TerminalTransport { handlers.onEvent({ type: 'snapshot', sequence: projection.sequence, data: projection.history, status: projection.status, exitCode: projection.exitCode, signal: projection.signal, runtime: projection.runtime, ptyBackend: projection.ptyBackend }); } const socketWasOpen = this.socket?.readyState === SOCKET_OPEN; - this.ensureConnected().then(() => { if (first && socketWasOpen && set.has(subscriber)) this.send({ t: 'attach', v: 3, s: sessionId }); }).catch((error) => { + this.ensureConnected().then(() => { + const current = this.subscribers.get(sessionId); + if (first && socketWasOpen && current === set && current.size > 0) { + this.send({ t: 'attach', v: 3, s: sessionId }); + } + }).catch((error) => { + if (!set.has(subscriber)) return; handlers.onError?.(error, false); this.scheduleReconnect(); }); @@ -114,6 +121,7 @@ export class TerminalTransport { } if (this.subscribers.size === 0) { this.cancelReconnect(); + this.failures = 0; if (this.socket?.readyState === SOCKET_OPEN) { // Healthy socket: hold it briefly so a tab switch can reattach to it. this.scheduleIdleClose(); @@ -121,6 +129,7 @@ export class TerminalTransport { } // Nothing to reuse, so abandon any dial that is still in flight. this.generation += 1; + this.opening = null; this.closeSocket(); } }; @@ -138,6 +147,7 @@ export class TerminalTransport { dispose(): void { this.disposed = true; this.generation += 1; + this.opening = null; this.subscribers.clear(); this.projections.clear(); if (this.reconnectTimer) clearTimeout(this.reconnectTimer); @@ -155,71 +165,89 @@ export class TerminalTransport { private async ensureConnected(): Promise { if (this.disposed) throw new Error('Terminal runtime changed'); if (this.socket?.readyState === SOCKET_OPEN) return; - if (this.opening && this.openingGeneration === this.generation) { + if (this.opening) { await this.opening; if (this.socket?.readyState === SOCKET_OPEN) return; return this.ensureConnected(); } - if (this.openingGeneration !== this.generation) { - this.opening = null; - this.openingGeneration = null; - } const generation = this.generation; const opening = (async () => { await this.dependencies.refreshAuth(); if (generation !== this.generation || this.disposed) throw new Error('Terminal runtime changed'); await new Promise((resolve, reject) => { - let settled = false; - let pendingSocket: RelayTunnelWebSocket | null = null; - const finish = (error?: Error) => { - if (settled) return; - settled = true; - clearTimeout(timeout); - if (error) reject(error); - else resolve(); - }; - const timeout = setTimeout(() => { - pendingSocket?.close(); - finish(new Error('Terminal connection timed out')); - }, 10_000); - try { - const socket = this.dependencies.openSocket(); - pendingSocket = socket; - socket.binaryType = 'arraybuffer'; - this.socket = socket; - socket.onopen = () => { - if (generation !== this.generation || this.disposed) { socket.close(); finish(new Error('Terminal runtime changed')); return; } - this.failures = 0; - this.send({ t: 'hello', v: 3 }); - for (const sessionId of this.subscribers.keys()) this.send({ t: 'attach', v: 3, s: sessionId }); - this.startKeepalive(); - finish(); + let settled = false; + let opened = false; + let authInvalidated = false; + let pendingSocket: RelayTunnelWebSocket | null = null; + const isCurrentSocket = () => ( + generation === this.generation && + !this.disposed && + pendingSocket !== null && + this.socket === pendingSocket + ); + const invalidatePreOpenAuth = () => { + if (authInvalidated || opened || !isCurrentSocket()) return; + authInvalidated = true; + this.dependencies.clearUrlAuthToken?.(); }; - socket.onmessage = (event) => void this.handleMessage(event.data); - socket.onerror = () => { - finish(new Error('Terminal WebSocket failed')); + const finish = (error?: Error) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + if (error) reject(error); + else resolve(); + }; + const timeout = setTimeout(() => { + invalidatePreOpenAuth(); + pendingSocket?.close(); + finish(new Error('Terminal connection timed out')); + }, 10_000); + try { + const socket = this.dependencies.openSocket(); + pendingSocket = socket; + socket.binaryType = 'arraybuffer'; + this.socket = socket; + socket.onopen = () => { + if (!isCurrentSocket()) { socket.close(); finish(new Error('Terminal runtime changed')); return; } + opened = true; + this.failures = 0; + this.send({ t: 'hello', v: 3 }); + for (const sessionId of this.subscribers.keys()) this.send({ t: 'attach', v: 3, s: sessionId }); + this.startKeepalive(); + finish(); + }; + socket.onmessage = (event) => void this.handleMessage(event.data); + socket.onerror = () => { + const current = isCurrentSocket(); + if (current) invalidatePreOpenAuth(); + finish(new Error('Terminal WebSocket failed')); + if (current && this.subscribers.size > 0) this.scheduleReconnect(); + }; + socket.onclose = () => { + const current = isCurrentSocket(); + if (current) { + this.stopKeepalive(); + // An upgrade rejected before `open` commonly means the cached + // URL-scoped auth token is stale. Retrying it reaches the 8s + // backoff cap instead of minting a fresh token. + invalidatePreOpenAuth(); + } + if (this.socket === socket) this.socket = null; + finish(new Error('Terminal WebSocket closed')); + if (current && this.subscribers.size > 0) this.scheduleReconnect(); + }; + } catch (error) { + finish(error instanceof Error ? error : new Error('Terminal WebSocket failed')); if (!this.disposed && this.subscribers.size > 0) this.scheduleReconnect(); - }; - socket.onclose = () => { - if (this.socket === socket) this.socket = null; - this.stopKeepalive(); - finish(new Error('Terminal WebSocket closed')); - if (!this.disposed && this.subscribers.size > 0) this.scheduleReconnect(); - }; - } catch (error) { - finish(error instanceof Error ? error : new Error('Terminal WebSocket failed')); - if (!this.disposed && this.subscribers.size > 0) this.scheduleReconnect(); - } + } }); })(); this.opening = opening; - this.openingGeneration = generation; try { await opening; } finally { if (this.opening === opening) { this.opening = null; - this.openingGeneration = null; } } } @@ -279,7 +307,7 @@ export class TerminalTransport { if (this.reconnectTimer || this.disposed || this.subscribers.size === 0) return; this.failures += 1; const slow = (typeof document !== 'undefined' && document.visibilityState === 'hidden') || (typeof navigator !== 'undefined' && !navigator.onLine); - const delay = Math.min(500 * 2 ** Math.min(this.failures - 1, 10), slow ? 60_000 : 8_000); + const delay = slow ? 60_000 : Math.min(500 * 2 ** Math.min(this.failures - 1, 10), 8_000); for (const set of this.subscribers.values()) for (const sub of set) sub.handlers.onEvent({ type: 'reconnecting', attempt: this.failures, maxAttempts: Number.POSITIVE_INFINITY }); const wake = () => { if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return; @@ -304,6 +332,7 @@ export class TerminalTransport { this.idleCloseTimer = null; if (this.disposed || this.subscribers.size > 0) return; this.generation += 1; + this.opening = null; this.closeSocket(); }, IDLE_SOCKET_GRACE_MS); } diff --git a/packages/web/server/lib/terminal/DOCUMENTATION.md b/packages/web/server/lib/terminal/DOCUMENTATION.md index ffc27411..d0b4563a 100644 --- a/packages/web/server/lib/terminal/DOCUMENTATION.md +++ b/packages/web/server/lib/terminal/DOCUMENTATION.md @@ -10,11 +10,12 @@ - `attach` registers a connection for one terminal. One socket may attach to many terminals. - Every attach and reconnect begins with an authoritative `snapshot` containing bounded history and the current sequence. +- A current socket that closes or errors before its initial `open` invalidates its URL-scoped auth token before retrying, so retries mint a fresh token instead of backing off against a rejected upgrade. Hidden or offline clients wait 60 seconds and wake promptly on visibility/online recovery. - `output`, `exit`, and `restarted` carry monotonically increasing per-terminal sequences. Output carries raw live bytes plus replay-safe bytes with terminal query exchanges removed. - Attach registers before capturing the snapshot, buffers concurrent events, drops events represented by the snapshot sequence, then enters live delivery. - `write` always includes the terminal ID; sockets never have mutable single-terminal binding state. - `detach` removes only that attachment. -- Creation carries the active UI appearance. The PTY sets `COLORFGBG` and answers OSC 10, OSC 11, and Mode 2031 queries immediately, including queries emitted before a WebSocket attachment exists. Subscribed TUIs receive a Mode 2031 notification when the appearance changes. +- Creation carries the active UI appearance. The PTY sets `COLORFGBG` and answers OSC 10, OSC 11, Mode 2031, and primary-device-attribute queries immediately, including queries emitted before a WebSocket attachment exists. The DA1 fallback prevents Fish from waiting ten seconds for a renderer that cannot observe or answer its startup query. Subscribed TUIs receive a Mode 2031 notification when the appearance changes. HTTP remains the authenticated command plane for create, resize, appearance updates, restart, close, and force-kill. There is no SSE output or HTTP input compatibility path. @@ -23,6 +24,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. diff --git a/packages/web/server/lib/terminal/runtime.js b/packages/web/server/lib/terminal/runtime.js index f717cb7a..04a7135c 100644 --- a/packages/web/server/lib/terminal/runtime.js +++ b/packages/web/server/lib/terminal/runtime.js @@ -145,7 +145,7 @@ export function createTerminalRuntime({ background: session.terminalBackground, foreground: session.terminalForeground, modeEnabled: session.themeModeEnabled, - }); + }, { respondToPrimaryDeviceAttributes: true }); session.pendingThemeControlSequence = theme.pending; session.themeModeEnabled = theme.modeEnabled; for (const response of theme.responses) session.process?.write(response); @@ -331,7 +331,7 @@ export function createTerminalRuntime({ session.process = spawned.process; session.backend = spawned.backend; session.shell = spawned.shell; session.loginShell = spawned.loginShell; session.cwd = cwd; session.cols = cols; session.rows = rows; session.history = ''; session.pendingHistoryControlSequence = ''; session.pendingThemeControlSequence = ''; session.themeModeEnabled = false; session.status = 'running'; session.exitCode = null; session.signal = null; session.eventQueue.length = 0; session.themeMode = themeMode === 'light' ? 'light' : 'dark'; session.terminalBackground = terminalBackground; session.terminalForeground = terminalForeground; - wire(session, spawned.process); void terminateProcess(oldProcess); publish(session, { t: 'restarted', history: '' }); + wire(session, spawned.process); void terminateProcess(oldProcess); publish(session, { t: 'restarted', history: '' }); }); pendingSessionRestarts.set(session.id, restart); try { diff --git a/packages/web/server/lib/terminal/runtime.test.js b/packages/web/server/lib/terminal/runtime.test.js index 4bc21420..8a67c07e 100644 --- a/packages/web/server/lib/terminal/runtime.test.js +++ b/packages/web/server/lib/terminal/runtime.test.js @@ -154,8 +154,8 @@ describe('terminal runtime', () => { expect(harness.processes[0].options.cwd).toBe('/repo'); expect(harness.processes[0].options.env.COLORFGBG).toBe('0;15'); expect(harness.processes[0].options.env.NODE_CHANNEL_FD).toBe(''); - harness.processes[0].emitData('\u001b[?2031h\u001b]10;?\u0007\u001b]11;?\u0007'); - expect(harness.processes[0].writes).toEqual(['\u001b]10;rgb:1b1b/1b1b/1b1b\u001b\\', '\u001b]11;rgb:fafa/f8f8/f0f0\u001b\\']); + harness.processes[0].emitData('\u001b[?2031h\u001b]10;?\u0007\u001b]11;?\u0007\u001b[0c'); + expect(harness.processes[0].writes).toEqual(['\u001b]10;rgb:1b1b/1b1b/1b1b\u001b\\', '\u001b]11;rgb:fafa/f8f8/f0f0\u001b\\', '\u001b[?1;2c']); const appearance = createResponse(); harness.routes.post.get('/api/terminal/:sessionId/appearance')({ params: { sessionId: 'term-1' }, body: { themeMode: 'dark' } }, appearance); diff --git a/packages/web/server/lib/terminal/theme-response.js b/packages/web/server/lib/terminal/theme-response.js index 3738675a..26662610 100644 --- a/packages/web/server/lib/terminal/theme-response.js +++ b/packages/web/server/lib/terminal/theme-response.js @@ -2,11 +2,21 @@ const MODE_SET = '\u001b[?2031h'; const MODE_RESET = '\u001b[?2031l'; const CAPABILITY_QUERY = '\u001b[?2031$p'; const MODE_QUERIES = ['\u001b[?996n', '\u001b[?997n']; +// Fish asks this before an unattached browser terminal can reply. +const PRIMARY_DEVICE_ATTRIBUTE_QUERIES = ['\u001b[c', '\u001b[0c']; +const PRIMARY_DEVICE_ATTRIBUTE_RESPONSE = '\u001b[?1;2c'; const OSC_QUERIES = [10, 11].flatMap((code) => [ { sequence: `\u001b]${code};?\u0007`, code }, { sequence: `\u001b]${code};?\u001b\\`, code }, ]); -const CONTROL_SEQUENCES = [MODE_SET, MODE_RESET, CAPABILITY_QUERY, ...MODE_QUERIES, ...OSC_QUERIES.map(({ sequence }) => sequence)]; +const CONTROL_SEQUENCES = [ + MODE_SET, + MODE_RESET, + CAPABILITY_QUERY, + ...MODE_QUERIES, + ...PRIMARY_DEVICE_ATTRIBUTE_QUERIES, + ...OSC_QUERIES.map(({ sequence }) => sequence), +]; const parseColor = (value) => { if (typeof value !== 'string') return null; @@ -28,7 +38,12 @@ const colorReport = (code, color) => { export const terminalThemeModeReport = (themeMode) => `\u001b[?997;${themeMode === 'light' ? 2 : 1}n`; -export const consumeTerminalThemeQueries = (pending, data, appearance) => { +export const consumeTerminalThemeQueries = ( + pending, + data, + appearance, + { respondToPrimaryDeviceAttributes = false } = {}, +) => { if (!pending && !data.includes('\u001b')) return { pending: '', responses: [], modeEnabled: appearance.modeEnabled === true }; const input = `${pending}${data}`; const responses = []; @@ -56,6 +71,15 @@ export const consumeTerminalThemeQueries = (pending, data, appearance) => { index += modeQuery.length - 1; continue; } + const primaryDeviceAttributeQuery = PRIMARY_DEVICE_ATTRIBUTE_QUERIES.find((query) => input.startsWith(query, index)); + if (primaryDeviceAttributeQuery && respondToPrimaryDeviceAttributes) { + // A shell can ask before any browser terminal is attached. Answer with a + // conservative VT100 DA1 response so Fish does not block startup for its + // ten-second query timeout while waiting for a renderer that cannot see it. + responses.push(PRIMARY_DEVICE_ATTRIBUTE_RESPONSE); + index += primaryDeviceAttributeQuery.length - 1; + continue; + } const oscQuery = OSC_QUERIES.find(({ sequence }) => input.startsWith(sequence, index)); if (oscQuery) { const response = colorReport(oscQuery.code, oscQuery.code === 10 ? appearance.foreground : appearance.background); diff --git a/packages/web/server/lib/terminal/theme-response.test.js b/packages/web/server/lib/terminal/theme-response.test.js index f67fe418..e45fdd9c 100644 --- a/packages/web/server/lib/terminal/theme-response.test.js +++ b/packages/web/server/lib/terminal/theme-response.test.js @@ -44,4 +44,27 @@ describe('terminal theme responses', () => { '\u001b]10;rgb:1b1b/1b1b/1b1b\u001b\\', ]); }); + + test('answers a primary device attribute query when the fallback is enabled', () => { + const attached = consumeTerminalThemeQueries('', '\u001b[0c', lightAppearance); + const unattached = consumeTerminalThemeQueries('', '\u001b[0c', lightAppearance, { + respondToPrimaryDeviceAttributes: true, + }); + + expect(attached.responses).toEqual([]); + expect(unattached.responses).toEqual(['\u001b[?1;2c']); + }); + + test('answers a primary device attribute query split across PTY chunks', () => { + const first = consumeTerminalThemeQueries('', '\u001b[0', lightAppearance, { + respondToPrimaryDeviceAttributes: true, + }); + const second = consumeTerminalThemeQueries(first.pending, 'c', { + ...lightAppearance, + modeEnabled: first.modeEnabled, + }, { respondToPrimaryDeviceAttributes: true }); + + expect(first.pending).toBe('\u001b[0'); + expect(second.responses).toEqual(['\u001b[?1;2c']); + }); });