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/dashboard-terminal.tsx b/src/features/dashboard/terminal/dashboard-terminal.tsx index cbc388f9c..ddf2c4e6b 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, @@ -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() @@ -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({ force: true }) + 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/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..7eb9ce570 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,44 +22,68 @@ 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 - ) - terminalSizeRef.current = nextSize - xtermRef.current?.resize(nextSize.cols, nextSize.rows) - onResize(nextSize) - - 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 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] + ) + + 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 = '' @@ -104,73 +125,94 @@ 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({ force: true }) + scrollTerminalToBottom(terminal) } 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({ force: true }) + scrollTerminalToBottom(terminal) + } catch { + rendererAddon?.dispose() + rendererAddon = undefined + } } - } + })() 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() rendererAddon?.dispose() - fitAddon.dispose() terminal.dispose() if (xtermRef.current === terminal) { xtermRef.current = null } - if (fitAddonRef.current === fitAddon) { - fitAddonRef.current = null - } } - }, [onInput, resizeTerminal]) + }, [onInput, resizeTerminal, scrollTerminalToBottom]) useEffect(() => { const container = terminalContainerRef.current 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, }) }) })