From a1fd48e8db2037a2fb999c7ae1170da865c7914c Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Thu, 18 Jun 2026 13:45:03 -0700 Subject: [PATCH 1/5] Fix xterm renderer sizing --- bun.lock | 3 - package.json | 1 - .../dashboard/terminal/terminal-size.ts | 49 +++++++++-- .../terminal/use-terminal-instance.ts | 85 +++++++++++-------- tests/unit/dashboard-terminal.test.ts | 63 +++++++++++++- 5 files changed, 152 insertions(+), 49 deletions(-) diff --git a/bun.lock b/bun.lock index 51cfe3c0a..4d271e3b4 100644 --- a/bun.lock +++ b/bun.lock @@ -55,7 +55,6 @@ "@vercel/otel": "^2.1.2", "@vercel/speed-insights": "^1.2.0", "@xterm/addon-canvas": "^0.7.0", - "@xterm/addon-fit": "^0.11.0", "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^6.0.0", "cheerio": "^1.0.0", @@ -1101,8 +1100,6 @@ "@xterm/addon-canvas": ["@xterm/addon-canvas@0.7.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-LF5LYcfvefJuJ7QotNRdRSPc9YASAVDeoT5uyXS/nZshZXjYplGXRECBGiznwvhNL2I8bq1Lf5MzRwstsYQ2Iw=="], - "@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="], - "@xterm/addon-webgl": ["@xterm/addon-webgl@0.19.0", "", {}, "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A=="], "@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="], diff --git a/package.json b/package.json index a8239698f..256d9e69d 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,6 @@ "@vercel/otel": "^2.1.2", "@vercel/speed-insights": "^1.2.0", "@xterm/addon-canvas": "^0.7.0", - "@xterm/addon-fit": "^0.11.0", "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^6.0.0", "cheerio": "^1.0.0", diff --git a/src/features/dashboard/terminal/terminal-size.ts b/src/features/dashboard/terminal/terminal-size.ts index 59669ff60..367804625 100644 --- a/src/features/dashboard/terminal/terminal-size.ts +++ b/src/features/dashboard/terminal/terminal-size.ts @@ -12,6 +12,21 @@ const MAX_CELL_WIDTH_PX = 16 const MIN_CELL_HEIGHT_PX = 8 const MAX_CELL_HEIGHT_PX = 40 +type XTermWithRenderDimensions = XTerm & { + _core?: { + _renderService?: { + dimensions?: { + css?: { + cell?: { + width?: number + height?: number + } + } + } + } + } +} + function getElementSize(element: Element | null) { if (!element) return undefined @@ -21,18 +36,41 @@ function getElementSize(element: Element | null) { return rect } +function getRenderCellSize(terminal: XTerm | null) { + const cell = (terminal as XTermWithRenderDimensions | null)?._core + ?._renderService?.dimensions?.css?.cell + + if (!cell?.width && !cell?.height) return undefined + + return { + width: cell.width, + height: cell.height, + } +} + function getMeasuredCellSize(terminal: XTerm | null) { + const renderCellSize = getRenderCellSize(terminal) const measureElement = terminal?.element?.querySelector( '.xterm-char-measure-element' ) const rowElement = terminal?.element?.querySelector('.xterm-rows > div') + const helperTextArea = terminal?.element?.querySelector( + '.xterm-helper-textarea' + ) const measuredCharSize = getElementSize(measureElement ?? null) const rowSize = getElementSize(rowElement ?? null) + const helperSize = getElementSize(helperTextArea ?? null) - if (!measuredCharSize && !rowSize) return undefined + if (!renderCellSize && !measuredCharSize && !rowSize && !helperSize) { + return undefined + } - const measuredWidth = measuredCharSize?.width - const measuredHeight = rowSize?.height ?? measuredCharSize?.height + const measuredWidth = renderCellSize?.width ?? measuredCharSize?.width + const measuredHeight = + renderCellSize?.height ?? + rowSize?.height ?? + measuredCharSize?.height ?? + helperSize?.height return { width: @@ -78,9 +116,6 @@ export function calculateTerminalSize( return { cols: Math.max(MIN_TERMINAL_COLS, Math.floor(availableWidth / cellWidth)), - rows: Math.max( - MIN_TERMINAL_ROWS, - Math.floor(availableHeight / cellHeight) - 1 - ), + rows: Math.max(MIN_TERMINAL_ROWS, Math.floor(availableHeight / cellHeight)), } } diff --git a/src/features/dashboard/terminal/use-terminal-instance.ts b/src/features/dashboard/terminal/use-terminal-instance.ts index 584ce9f85..ae2b6cf45 100644 --- a/src/features/dashboard/terminal/use-terminal-instance.ts +++ b/src/features/dashboard/terminal/use-terminal-instance.ts @@ -1,6 +1,3 @@ -import { CanvasAddon } from '@xterm/addon-canvas' -import { FitAddon } from '@xterm/addon-fit' -import { WebglAddon } from '@xterm/addon-webgl' import '@xterm/xterm/css/xterm.css' import { Terminal as XTerm } from '@xterm/xterm' import { useCallback, useEffect, useRef } from 'react' @@ -25,19 +22,21 @@ interface UseTerminalInstanceOptions { onResize: (size: { cols: number; rows: number }) => void } +type DisposableAddon = { + dispose: () => void +} + export function useTerminalInstance({ onInput, onResize, }: UseTerminalInstanceOptions) { const xtermRef = useRef(null) - const fitAddonRef = useRef(null) const terminalContainerRef = useRef(null) const terminalTranscriptRef = useRef(INITIAL_TERMINAL_TEXT) const terminalSizeRef = useRef({ cols: DEFAULT_COLS, rows: DEFAULT_ROWS }) const decoderRef = useRef(new TextDecoder()) const resizeTerminal = useCallback(() => { - fitAddonRef.current?.fit() const nextSize = calculateTerminalSize( terminalContainerRef.current, xtermRef.current @@ -104,42 +103,59 @@ export function useTerminalInstance({ theme: TERMINAL_THEME, }) - const fitAddon = new FitAddon() - let rendererAddon: WebglAddon | CanvasAddon | undefined + let disposed = false + let rendererAddon: DisposableAddon | undefined let contextLossSubscription: { dispose: () => void } | undefined xtermRef.current = terminal - fitAddonRef.current = fitAddon - terminal.loadAddon(fitAddon) terminal.open(container) - try { - const webglAddon = new WebglAddon() - const webglContextLossSubscription = webglAddon.onContextLoss(() => { - webglContextLossSubscription.dispose() - webglAddon.dispose() - if (rendererAddon === webglAddon) { - rendererAddon = undefined - } - if (contextLossSubscription === webglContextLossSubscription) { - contextLossSubscription = undefined - } - }) - contextLossSubscription = webglContextLossSubscription - rendererAddon = webglAddon - terminal.loadAddon(webglAddon) - } catch { - contextLossSubscription?.dispose() - contextLossSubscription = undefined - rendererAddon?.dispose() + void (async () => { try { - rendererAddon = new CanvasAddon() - terminal.loadAddon(rendererAddon) + const { WebglAddon } = await import('@xterm/addon-webgl') + if (disposed) return + + const webglAddon = new WebglAddon() + const webglContextLossSubscription = webglAddon.onContextLoss(() => { + webglContextLossSubscription.dispose() + webglAddon.dispose() + if (rendererAddon === webglAddon) { + rendererAddon = undefined + } + if (contextLossSubscription === webglContextLossSubscription) { + contextLossSubscription = undefined + } + }) + + if (disposed) { + webglContextLossSubscription.dispose() + webglAddon.dispose() + return + } + + contextLossSubscription = webglContextLossSubscription + rendererAddon = webglAddon + terminal.loadAddon(webglAddon) + resizeTerminal() } catch { + contextLossSubscription?.dispose() + contextLossSubscription = undefined rendererAddon?.dispose() - rendererAddon = undefined + + try { + const { CanvasAddon } = await import('@xterm/addon-canvas') + if (disposed) return + + const canvasAddon = new CanvasAddon() + rendererAddon = canvasAddon + terminal.loadAddon(canvasAddon) + resizeTerminal() + } catch { + rendererAddon?.dispose() + rendererAddon = undefined + } } - } + })() const dataSubscription = terminal.onData(onInput) terminal.write(terminalTranscriptRef.current, () => { @@ -157,18 +173,15 @@ export function useTerminalInstance({ }, 100) return () => { + disposed = true window.clearTimeout(resizeTimer) dataSubscription.dispose() contextLossSubscription?.dispose() rendererAddon?.dispose() - fitAddon.dispose() terminal.dispose() if (xtermRef.current === terminal) { xtermRef.current = null } - if (fitAddonRef.current === fitAddon) { - fitAddonRef.current = null - } } }, [onInput, resizeTerminal]) diff --git a/tests/unit/dashboard-terminal.test.ts b/tests/unit/dashboard-terminal.test.ts index 0f164feca..e8ab4b345 100644 --- a/tests/unit/dashboard-terminal.test.ts +++ b/tests/unit/dashboard-terminal.test.ts @@ -167,7 +167,7 @@ describe('dashboard terminal helpers', () => { expect(calculateTerminalSize(container, null)).toEqual({ cols: 104, - rows: 22, + rows: 23, }) }) @@ -197,7 +197,66 @@ describe('dashboard terminal helpers', () => { expect(calculateTerminalSize(container, terminal)).toEqual({ cols: 83, - rows: 20, + rows: 21, + }) + }) + + it('uses xterm renderer dimensions for canvas renderers', () => { + const container = { + clientWidth: 900, + clientHeight: 500, + getBoundingClientRect: () => ({ width: 900, height: 500 }), + } as HTMLDivElement + const terminal = { + _core: { + _renderService: { + dimensions: { + css: { + cell: { width: 8, height: 24 }, + }, + }, + }, + }, + element: { + querySelector: (selector: string) => { + if (selector === '.xterm-helper-textarea') { + return { + getBoundingClientRect: () => ({ width: 20, height: 24 }), + } + } + return null + }, + }, + } as never + + expect(calculateTerminalSize(container, terminal)).toEqual({ + cols: 104, + rows: 19, + }) + }) + + it('does not use helper textarea width for columns', () => { + const container = { + clientWidth: 900, + clientHeight: 500, + getBoundingClientRect: () => ({ width: 900, height: 500 }), + } as HTMLDivElement + const terminal = { + element: { + querySelector: (selector: string) => { + if (selector === '.xterm-helper-textarea') { + return { + getBoundingClientRect: () => ({ width: 20, height: 24 }), + } + } + return null + }, + }, + } as never + + expect(calculateTerminalSize(container, terminal)).toEqual({ + cols: 104, + rows: 19, }) }) }) From 2d13f5f914965b38a3aa51475b8b5613f469783a Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Thu, 18 Jun 2026 13:45:30 -0700 Subject: [PATCH 2/5] Avoid redundant terminal resize calls --- .../dashboard/terminal/use-terminal-instance.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/features/dashboard/terminal/use-terminal-instance.ts b/src/features/dashboard/terminal/use-terminal-instance.ts index ae2b6cf45..e9287ec6c 100644 --- a/src/features/dashboard/terminal/use-terminal-instance.ts +++ b/src/features/dashboard/terminal/use-terminal-instance.ts @@ -41,9 +41,16 @@ export function useTerminalInstance({ terminalContainerRef.current, xtermRef.current ) + const currentSize = terminalSizeRef.current + const sizeChanged = + nextSize.cols !== currentSize.cols || nextSize.rows !== currentSize.rows + terminalSizeRef.current = nextSize - xtermRef.current?.resize(nextSize.cols, nextSize.rows) - onResize(nextSize) + + if (sizeChanged) { + xtermRef.current?.resize(nextSize.cols, nextSize.rows) + onResize(nextSize) + } return nextSize }, [onResize]) From 4e01ff0282ddd7a2363ab46919a0f37d0abb2cf6 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Thu, 18 Jun 2026 13:47:34 -0700 Subject: [PATCH 3/5] Harden terminal lifecycle cleanup --- .../dashboard/terminal/dashboard-terminal.tsx | 45 ++++++++-------- .../terminal/use-terminal-instance.ts | 52 +++++++++++++------ 2 files changed, 57 insertions(+), 40 deletions(-) diff --git a/src/features/dashboard/terminal/dashboard-terminal.tsx b/src/features/dashboard/terminal/dashboard-terminal.tsx index cbc388f9c..c5dffddcf 100644 --- a/src/features/dashboard/terminal/dashboard-terminal.tsx +++ b/src/features/dashboard/terminal/dashboard-terminal.tsx @@ -1,7 +1,7 @@ 'use client' -import { type CommandHandle, type Sandbox } from 'e2b' -import { useCallback, useEffect, useRef, useState } from 'react' +import type { CommandHandle, Sandbox } from 'e2b' +import { useCallback, useEffect, useEffectEvent, useRef, useState } from 'react' import type { SandboxManagementAuth } from '@/core/shared/sandbox-management-auth' import { DEFAULT_CWD, @@ -555,38 +555,37 @@ export default function DashboardTerminal({ } }, [autoStart, launchTarget, queueTerminalCommand, status]) - useEffect(() => { - const handlePageHide = (event: PageTransitionEvent) => { - if (event.persisted) return + const handlePageHide = useEffectEvent((event: PageTransitionEvent) => { + if (event.persisted) return - abortCurrentStart() - void closeTerminal() - } + abortCurrentStart() + void closeTerminal() + }) - const handlePageShow = (event: PageTransitionEvent) => { - if (!event.persisted || !ptyRef.current) return + const handlePageShow = useEffectEvent((event: PageTransitionEvent) => { + if (!event.persisted || !ptyRef.current) return - resizeTerminal() - focusTerminal() - } + resizeTerminal() + focusTerminal() + }) + + const handleTerminalUnmount = useEffectEvent(() => { + startGenerationRef.current += 1 + isStartingRef.current = false + clearPendingInput() + void closeTerminal() + }) + useEffect(() => { window.addEventListener('pagehide', handlePageHide) window.addEventListener('pageshow', handlePageShow) return () => { window.removeEventListener('pagehide', handlePageHide) window.removeEventListener('pageshow', handlePageShow) - abortCurrentStart() - clearPendingInput() - void closeTerminal() + handleTerminalUnmount() } - }, [ - abortCurrentStart, - clearPendingInput, - closeTerminal, - focusTerminal, - resizeTerminal, - ]) + }, []) return ( <> diff --git a/src/features/dashboard/terminal/use-terminal-instance.ts b/src/features/dashboard/terminal/use-terminal-instance.ts index e9287ec6c..a4d3621f1 100644 --- a/src/features/dashboard/terminal/use-terminal-instance.ts +++ b/src/features/dashboard/terminal/use-terminal-instance.ts @@ -55,20 +55,31 @@ export function useTerminalInstance({ return nextSize }, [onResize]) - const appendOutput = useCallback((chunk: string | Uint8Array) => { - const text = - typeof chunk === 'string' - ? chunk - : decoderRef.current.decode(chunk, { stream: true }) - - terminalTranscriptRef.current = ( - terminalTranscriptRef.current + text - ).slice(-MAX_TERMINAL_TRANSCRIPT_CHARS) - xtermRef.current?.write(chunk, () => { - xtermRef.current?.scrollToBottom() - }) + const scrollTerminalToBottom = useCallback((terminal = xtermRef.current) => { + try { + terminal?.scrollToBottom() + } catch {} }, []) + const appendOutput = useCallback( + (chunk: string | Uint8Array) => { + const text = + typeof chunk === 'string' + ? chunk + : decoderRef.current.decode(chunk, { stream: true }) + + terminalTranscriptRef.current = ( + terminalTranscriptRef.current + text + ).slice(-MAX_TERMINAL_TRANSCRIPT_CHARS) + + const terminal = xtermRef.current + terminal?.write(chunk, () => { + scrollTerminalToBottom(terminal) + }) + }, + [scrollTerminalToBottom] + ) + const resetTerminal = useCallback(() => { decoderRef.current = new TextDecoder() terminalTranscriptRef.current = '' @@ -144,6 +155,7 @@ export function useTerminalInstance({ rendererAddon = webglAddon terminal.loadAddon(webglAddon) resizeTerminal() + scrollTerminalToBottom(terminal) } catch { contextLossSubscription?.dispose() contextLossSubscription = undefined @@ -157,6 +169,7 @@ export function useTerminalInstance({ rendererAddon = canvasAddon terminal.loadAddon(canvasAddon) resizeTerminal() + scrollTerminalToBottom(terminal) } catch { rendererAddon?.dispose() rendererAddon = undefined @@ -166,21 +179,26 @@ export function useTerminalInstance({ const dataSubscription = terminal.onData(onInput) terminal.write(terminalTranscriptRef.current, () => { - terminal.scrollToBottom() + scrollTerminalToBottom(terminal) }) - requestAnimationFrame(() => { + const resizeFrame = requestAnimationFrame(() => { + if (disposed) return + resizeTerminal() terminal.focus() - terminal.scrollToBottom() + scrollTerminalToBottom(terminal) }) const resizeTimer = window.setTimeout(() => { + if (disposed) return + resizeTerminal() - terminal.scrollToBottom() + scrollTerminalToBottom(terminal) }, 100) return () => { disposed = true + cancelAnimationFrame(resizeFrame) window.clearTimeout(resizeTimer) dataSubscription.dispose() contextLossSubscription?.dispose() @@ -190,7 +208,7 @@ export function useTerminalInstance({ xtermRef.current = null } } - }, [onInput, resizeTerminal]) + }, [onInput, resizeTerminal, scrollTerminalToBottom]) useEffect(() => { const container = terminalContainerRef.current From e49f81f7a642b1fed0936c6d0b6e077e12a12350 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Thu, 18 Jun 2026 14:59:10 -0700 Subject: [PATCH 4/5] Ensure terminal resize reaches attached PTY --- .../dashboard/terminal/dashboard-terminal.tsx | 4 +- .../terminal/use-terminal-instance.ts | 42 ++++++++++--------- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/src/features/dashboard/terminal/dashboard-terminal.tsx b/src/features/dashboard/terminal/dashboard-terminal.tsx index c5dffddcf..3c7eaa9ef 100644 --- a/src/features/dashboard/terminal/dashboard-terminal.tsx +++ b/src/features/dashboard/terminal/dashboard-terminal.tsx @@ -192,7 +192,7 @@ export default function DashboardTerminal({ const resizePty = useCallback((size: { cols: number; rows: number }) => { if (sandboxRef.current && pidRef.current) { - void sandboxRef.current.pty.resize(pidRef.current, size) + void sandboxRef.current.pty.resize(pidRef.current, size).catch(() => {}) } }, []) @@ -378,7 +378,7 @@ export default function DashboardTerminal({ }) ptyRef.current = pty pidRef.current = pty.pid - resizeTerminal() + resizeTerminal({ force: true }) setStatus('ready') appendOutput(`PTY ${pty.pid} attached.\r\n`) focusTerminal() diff --git a/src/features/dashboard/terminal/use-terminal-instance.ts b/src/features/dashboard/terminal/use-terminal-instance.ts index a4d3621f1..7eb9ce570 100644 --- a/src/features/dashboard/terminal/use-terminal-instance.ts +++ b/src/features/dashboard/terminal/use-terminal-instance.ts @@ -36,24 +36,28 @@ export function useTerminalInstance({ const terminalSizeRef = useRef({ cols: DEFAULT_COLS, rows: DEFAULT_ROWS }) const decoderRef = useRef(new TextDecoder()) - const resizeTerminal = useCallback(() => { - const nextSize = calculateTerminalSize( - terminalContainerRef.current, - xtermRef.current - ) - const currentSize = terminalSizeRef.current - const sizeChanged = - nextSize.cols !== currentSize.cols || nextSize.rows !== currentSize.rows - - terminalSizeRef.current = nextSize - - if (sizeChanged) { - xtermRef.current?.resize(nextSize.cols, nextSize.rows) - onResize(nextSize) - } + const resizeTerminal = useCallback( + (options?: { force?: boolean }) => { + const nextSize = calculateTerminalSize( + terminalContainerRef.current, + xtermRef.current + ) + const currentSize = terminalSizeRef.current + const sizeChanged = + nextSize.cols !== currentSize.cols || nextSize.rows !== currentSize.rows + const shouldResize = sizeChanged || options?.force + + terminalSizeRef.current = nextSize + + if (shouldResize) { + xtermRef.current?.resize(nextSize.cols, nextSize.rows) + onResize(nextSize) + } - return nextSize - }, [onResize]) + return nextSize + }, + [onResize] + ) const scrollTerminalToBottom = useCallback((terminal = xtermRef.current) => { try { @@ -154,7 +158,7 @@ export function useTerminalInstance({ contextLossSubscription = webglContextLossSubscription rendererAddon = webglAddon terminal.loadAddon(webglAddon) - resizeTerminal() + resizeTerminal({ force: true }) scrollTerminalToBottom(terminal) } catch { contextLossSubscription?.dispose() @@ -168,7 +172,7 @@ export function useTerminalInstance({ const canvasAddon = new CanvasAddon() rendererAddon = canvasAddon terminal.loadAddon(canvasAddon) - resizeTerminal() + resizeTerminal({ force: true }) scrollTerminalToBottom(terminal) } catch { rendererAddon?.dispose() From 75e691b6748f77a04bebfdc1ef0ff0e9814ea3a4 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Thu, 18 Jun 2026 15:04:38 -0700 Subject: [PATCH 5/5] Force terminal resize after bfcache restore --- src/features/dashboard/terminal/dashboard-terminal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/dashboard/terminal/dashboard-terminal.tsx b/src/features/dashboard/terminal/dashboard-terminal.tsx index 3c7eaa9ef..ddf2c4e6b 100644 --- a/src/features/dashboard/terminal/dashboard-terminal.tsx +++ b/src/features/dashboard/terminal/dashboard-terminal.tsx @@ -565,7 +565,7 @@ export default function DashboardTerminal({ const handlePageShow = useEffectEvent((event: PageTransitionEvent) => { if (!event.persisted || !ptyRef.current) return - resizeTerminal() + resizeTerminal({ force: true }) focusTerminal() })