diff --git a/bun.lock b/bun.lock index 19063898c..477e140d0 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "@e2b/dashboard", @@ -50,7 +51,6 @@ "@vercel/analytics": "^1.5.0", "@vercel/otel": "^2.1.2", "@vercel/speed-insights": "^1.2.0", - "@xterm/xterm": "^6.0.0", "cheerio": "^1.0.0", "chrono-node": "^2.8.4", "class-variance-authority": "^0.7.1", @@ -62,6 +62,7 @@ "echarts": "^6.0.0", "echarts-for-react": "^3.0.2", "fast-xml-parser": "^5.3.5", + "ghostty-web": "^0.4.0", "immer": "^10.1.1", "input-otp": "^1.4.2", "micromatch": "^4.0.8", @@ -90,6 +91,7 @@ "server-only": "^0.0.1", "shiki": "3.2.1", "sonner": "^2.0.7", + "strip-ansi": "^7.1.2", "superjson": "^2.2.5", "tailwind-merge": "^3.4.0", "tw-animate-css": "^1.3.6", @@ -1081,8 +1083,6 @@ "@webassemblyjs/wast-printer": ["@webassemblyjs/wast-printer@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" } }, "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw=="], - "@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="], - "@xtuc/ieee754": ["@xtuc/ieee754@1.2.0", "", {}, "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA=="], "@xtuc/long": ["@xtuc/long@4.2.2", "", {}, "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="], @@ -1375,6 +1375,8 @@ "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], + "ghostty-web": ["ghostty-web@0.4.0", "", {}, "sha512-0puDBik2qapbD/QQBW9o5ZHfXnZBqZWx/ctBiVtKZ6ZLds4NYb+wZuw1cRLXZk9zYovIQ908z3rvFhexAvc5Hg=="], + "glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], diff --git a/package.json b/package.json index 15f4ee75c..a136c94e8 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,6 @@ "@vercel/analytics": "^1.5.0", "@vercel/otel": "^2.1.2", "@vercel/speed-insights": "^1.2.0", - "@xterm/xterm": "^6.0.0", "cheerio": "^1.0.0", "chrono-node": "^2.8.4", "class-variance-authority": "^0.7.1", @@ -103,6 +102,7 @@ "echarts": "^6.0.0", "echarts-for-react": "^3.0.2", "fast-xml-parser": "^5.3.5", + "ghostty-web": "^0.4.0", "immer": "^10.1.1", "input-otp": "^1.4.2", "micromatch": "^4.0.8", @@ -131,6 +131,7 @@ "server-only": "^0.0.1", "shiki": "3.2.1", "sonner": "^2.0.7", + "strip-ansi": "^7.1.2", "superjson": "^2.2.5", "tailwind-merge": "^3.4.0", "tw-animate-css": "^1.3.6", diff --git a/src/features/dashboard/terminal/dashboard-terminal.tsx b/src/features/dashboard/terminal/dashboard-terminal.tsx index cbc388f9c..b1ec268cd 100644 --- a/src/features/dashboard/terminal/dashboard-terminal.tsx +++ b/src/features/dashboard/terminal/dashboard-terminal.tsx @@ -1,6 +1,6 @@ 'use client' -import { type CommandHandle, type Sandbox } from 'e2b' +import type { CommandHandle, Sandbox } from 'e2b' import { useCallback, useEffect, useRef, useState } from 'react' import type { SandboxManagementAuth } from '@/core/shared/sandbox-management-auth' import { @@ -200,6 +200,7 @@ export default function DashboardTerminal({ appendOutput, copyTerminalText, focusTerminal, + pasteTerminalText, resetTerminal, resizeTerminal, terminalContainerRef, @@ -597,6 +598,7 @@ export default function DashboardTerminal({ template={sandboxScoped ? undefined : template} terminalContainerRef={terminalContainerRef} onFocusTerminal={focusTerminal} + onPasteTerminalText={pasteTerminalText} onCopyTerminalText={() => void copyTerminalText()} onRestartTerminal={restartTerminal} /> diff --git a/src/features/dashboard/terminal/terminal-panel.tsx b/src/features/dashboard/terminal/terminal-panel.tsx index 3225f9b4e..b9985acc6 100644 --- a/src/features/dashboard/terminal/terminal-panel.tsx +++ b/src/features/dashboard/terminal/terminal-panel.tsx @@ -1,4 +1,4 @@ -import type { RefObject } from 'react' +import type { ClipboardEvent, RefObject } from 'react' import { IconButton } from '@/ui/primitives/icon-button' import { CopyIcon, RefreshIcon, TerminalIcon } from '@/ui/primitives/icons' @@ -9,6 +9,7 @@ interface TerminalPanelProps { restartLabel: string terminalContainerRef: RefObject onFocusTerminal: () => void + onPasteTerminalText: (event: ClipboardEvent) => void onCopyTerminalText: () => void onRestartTerminal: () => void } @@ -20,6 +21,7 @@ export default function TerminalPanel({ restartLabel, terminalContainerRef, onFocusTerminal, + onPasteTerminalText, onCopyTerminalText, onRestartTerminal, }: TerminalPanelProps) { @@ -41,6 +43,7 @@ export default function TerminalPanel({ aria-label="Terminal" className="min-h-0 flex-1 cursor-text overflow-hidden bg-black p-3" onMouseDown={onFocusTerminal} + onPasteCapture={onPasteTerminalText} /> ) diff --git a/src/features/dashboard/terminal/terminal-paste.ts b/src/features/dashboard/terminal/terminal-paste.ts new file mode 100644 index 000000000..5a9ec046b --- /dev/null +++ b/src/features/dashboard/terminal/terminal-paste.ts @@ -0,0 +1,26 @@ +import stripAnsi from 'strip-ansi' + +const DELETE = 0x7f + +const isAllowedPasteControl = (code: number) => + code === 0x09 || code === 0x0a || code === 0x0d + +export function sanitizeTerminalPaste(value: string) { + let sanitized = '' + + for (const char of stripAnsi(value)) { + const code = char.charCodeAt(0) + + if ((code < 0x20 && !isAllowedPasteControl(code)) || code === DELETE) { + continue + } + + if (code >= 0x80 && code <= 0x9f) { + continue + } + + sanitized += char + } + + return sanitized +} diff --git a/src/features/dashboard/terminal/terminal-size.ts b/src/features/dashboard/terminal/terminal-size.ts index 59669ff60..66ebde4a3 100644 --- a/src/features/dashboard/terminal/terminal-size.ts +++ b/src/features/dashboard/terminal/terminal-size.ts @@ -1,4 +1,3 @@ -import type { Terminal as XTerm } from '@xterm/xterm' import { DEFAULT_COLS, DEFAULT_PANEL_HEIGHT, DEFAULT_ROWS } from './constants' const MIN_TERMINAL_COLS = 40 @@ -7,58 +6,27 @@ const TERMINAL_PADDING_PX = 24 const TERMINAL_SCROLLBAR_GUTTER_PX = 44 const DEFAULT_CELL_WIDTH_PX = 8 const DEFAULT_CELL_HEIGHT_PX = 20 -const MIN_CELL_WIDTH_PX = 4 -const MAX_CELL_WIDTH_PX = 16 -const MIN_CELL_HEIGHT_PX = 8 -const MAX_CELL_HEIGHT_PX = 40 -function getElementSize(element: Element | null) { - if (!element) return undefined - - const rect = element.getBoundingClientRect() - if (!rect.width || !rect.height) return undefined - - return rect -} - -function getMeasuredCellSize(terminal: XTerm | null) { - const measureElement = terminal?.element?.querySelector( - '.xterm-char-measure-element' - ) - const rowElement = terminal?.element?.querySelector('.xterm-rows > div') - const measuredCharSize = getElementSize(measureElement ?? null) - const rowSize = getElementSize(rowElement ?? null) - - if (!measuredCharSize && !rowSize) return undefined - - const measuredWidth = measuredCharSize?.width - const measuredHeight = rowSize?.height ?? measuredCharSize?.height - - return { - width: - measuredWidth && - measuredWidth >= MIN_CELL_WIDTH_PX && - measuredWidth <= MAX_CELL_WIDTH_PX - ? measuredWidth - : undefined, - height: - measuredHeight && - measuredHeight >= MIN_CELL_HEIGHT_PX && - measuredHeight <= MAX_CELL_HEIGHT_PX - ? measuredHeight - : undefined, - } +type TerminalLike = { + cols: number + rows: number } export function calculateTerminalSize( container: HTMLDivElement | null, - terminal: XTerm | null + terminal: TerminalLike | null ) { + if (terminal) { + return { + cols: terminal.cols, + rows: terminal.rows, + } + } + if (!container) { return { cols: DEFAULT_COLS, rows: DEFAULT_ROWS } } - const measuredCellSize = getMeasuredCellSize(terminal) const containerRect = container.getBoundingClientRect() const containerWidth = container.clientWidth || containerRect.width || window.innerWidth @@ -67,20 +35,15 @@ export function calculateTerminalSize( const availableWidth = containerWidth - TERMINAL_PADDING_PX - TERMINAL_SCROLLBAR_GUTTER_PX const availableHeight = containerHeight - TERMINAL_PADDING_PX - const cellWidth = Math.max( - measuredCellSize?.width ?? DEFAULT_CELL_WIDTH_PX, - 1 - ) - const cellHeight = Math.max( - measuredCellSize?.height ?? DEFAULT_CELL_HEIGHT_PX, - 1 - ) return { - cols: Math.max(MIN_TERMINAL_COLS, Math.floor(availableWidth / cellWidth)), + cols: Math.max( + MIN_TERMINAL_COLS, + Math.floor(availableWidth / DEFAULT_CELL_WIDTH_PX) + ), rows: Math.max( MIN_TERMINAL_ROWS, - Math.floor(availableHeight / cellHeight) - 1 + Math.floor(availableHeight / DEFAULT_CELL_HEIGHT_PX) - 1 ), } } diff --git a/src/features/dashboard/terminal/use-terminal-instance.ts b/src/features/dashboard/terminal/use-terminal-instance.ts index 95b70196b..0f323356a 100644 --- a/src/features/dashboard/terminal/use-terminal-instance.ts +++ b/src/features/dashboard/terminal/use-terminal-instance.ts @@ -1,11 +1,16 @@ -import '@xterm/xterm/css/xterm.css' -import { Terminal as XTerm } from '@xterm/xterm' +import { + FitAddon, + Terminal as GhosttyTerminal, + init as initGhostty, +} from 'ghostty-web' +import type { ClipboardEvent } from 'react' import { useCallback, useEffect, useRef } from 'react' import { DEFAULT_COLS, DEFAULT_ROWS, MAX_TERMINAL_TRANSCRIPT_CHARS, } from './constants' +import { sanitizeTerminalPaste } from './terminal-paste' import { calculateTerminalSize } from './terminal-size' const INITIAL_TERMINAL_TEXT = @@ -16,6 +21,10 @@ const TERMINAL_THEME = { foreground: '#ffffff', selectionBackground: '#ffffff40', } +const TERMINAL_INIT_ERROR_TEXT = + '\r\nTerminal failed to load. Reload the page to try again.\r\n' + +const ghosttyReady = initGhostty() interface UseTerminalInstanceOptions { onInput: (data: string) => void @@ -26,19 +35,20 @@ export function useTerminalInstance({ onInput, onResize, }: UseTerminalInstanceOptions) { - const xtermRef = useRef(null) + const terminalRef = 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 + terminalRef.current ) terminalSizeRef.current = nextSize - xtermRef.current?.resize(nextSize.cols, nextSize.rows) onResize(nextSize) return nextSize @@ -53,24 +63,46 @@ export function useTerminalInstance({ terminalTranscriptRef.current = ( terminalTranscriptRef.current + text ).slice(-MAX_TERMINAL_TRANSCRIPT_CHARS) - xtermRef.current?.write(chunk, () => { - xtermRef.current?.scrollToBottom() + terminalRef.current?.write(chunk, () => { + terminalRef.current?.scrollToBottom() }) }, []) const resetTerminal = useCallback(() => { decoderRef.current = new TextDecoder() terminalTranscriptRef.current = '' - xtermRef.current?.reset() + terminalRef.current?.reset() }, []) const focusTerminal = useCallback(() => { - xtermRef.current?.focus() + terminalRef.current?.focus() }, []) + const pasteTerminalText = useCallback( + (event: ClipboardEvent) => { + const text = event.clipboardData.getData('text/plain') + if (!text) return + + event.preventDefault() + event.stopPropagation() + + const sanitizedText = sanitizeTerminalPaste(text) + if (sanitizedText) { + if (terminalRef.current) { + terminalRef.current.paste(sanitizedText) + } else { + onInput(sanitizedText) + } + } + + terminalRef.current?.focus() + }, + [onInput] + ) + const copyTerminalText = useCallback(async () => { const value = - xtermRef.current?.getSelection() || terminalTranscriptRef.current + terminalRef.current?.getSelection() || terminalTranscriptRef.current if (!value) return try { @@ -86,38 +118,73 @@ export function useTerminalInstance({ const container = terminalContainerRef.current if (!container) return - const terminal = new XTerm({ - cols: terminalSizeRef.current.cols, - rows: terminalSizeRef.current.rows, - cursorBlink: true, - cursorStyle: 'block', - fontFamily: - 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', - fontSize: 13, - lineHeight: 1.54, - scrollback: 10_000, - theme: TERMINAL_THEME, - }) - - xtermRef.current = terminal - terminal.open(container) - terminal.write(terminalTranscriptRef.current) - const dataSubscription = terminal.onData(onInput) - - requestAnimationFrame(() => { - resizeTerminal() - terminal.focus() - }) - const resizeTimer = window.setTimeout(() => { - resizeTerminal() - }, 100) + let disposed = false + let terminal: GhosttyTerminal | null = null + let fitAddon: FitAddon | null = null + let dataSubscription: { dispose: () => void } | undefined + let resizeTimer: number | undefined + + void ghosttyReady + .then(() => { + const currentContainer = terminalContainerRef.current + if (disposed || !currentContainer) return + + terminal = new GhosttyTerminal({ + cols: terminalSizeRef.current.cols, + rows: terminalSizeRef.current.rows, + cursorBlink: true, + cursorStyle: 'block', + fontFamily: + 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', + fontSize: 13, + scrollback: 10_000, + theme: TERMINAL_THEME, + }) + + fitAddon = new FitAddon() + fitAddonRef.current = fitAddon + terminal.loadAddon(fitAddon) + terminalRef.current = terminal + terminal.open(currentContainer) + dataSubscription = terminal.onData(onInput) + terminal.write(terminalTranscriptRef.current, () => { + terminal?.scrollToBottom() + }) + + requestAnimationFrame(() => { + resizeTerminal() + terminal?.focus() + terminal?.scrollToBottom() + }) + resizeTimer = window.setTimeout(() => { + resizeTerminal() + terminal?.scrollToBottom() + }, 100) + }) + .catch(() => { + const currentContainer = terminalContainerRef.current + if (disposed || !currentContainer) return + + terminalTranscriptRef.current = ( + terminalTranscriptRef.current + TERMINAL_INIT_ERROR_TEXT + ).slice(-MAX_TERMINAL_TRANSCRIPT_CHARS) + currentContainer.style.color = TERMINAL_THEME.foreground + currentContainer.textContent = TERMINAL_INIT_ERROR_TEXT.trim() + }) return () => { - window.clearTimeout(resizeTimer) - dataSubscription.dispose() - terminal.dispose() - if (xtermRef.current === terminal) { - xtermRef.current = null + disposed = true + if (resizeTimer) { + window.clearTimeout(resizeTimer) + } + dataSubscription?.dispose() + fitAddon?.dispose() + terminal?.dispose() + if (terminalRef.current === terminal) { + terminalRef.current = null + } + if (fitAddonRef.current === fitAddon) { + fitAddonRef.current = null } } }, [onInput, resizeTerminal]) @@ -151,6 +218,7 @@ export function useTerminalInstance({ appendOutput, copyTerminalText, focusTerminal, + pasteTerminalText, resetTerminal, resizeTerminal, terminalContainerRef, diff --git a/tests/unit/dashboard-terminal.test.ts b/tests/unit/dashboard-terminal.test.ts index 0f164feca..da87558f1 100644 --- a/tests/unit/dashboard-terminal.test.ts +++ b/tests/unit/dashboard-terminal.test.ts @@ -15,6 +15,7 @@ import { normalizeTerminalTemplate, resolveTerminalTemplateOverride, } from '@/features/dashboard/terminal/template' +import { sanitizeTerminalPaste } from '@/features/dashboard/terminal/terminal-paste' import { calculateTerminalSize } from '@/features/dashboard/terminal/terminal-size' const { mockCreateSandbox, mockConnectSandbox } = vi.hoisted(() => ({ @@ -171,35 +172,55 @@ describe('dashboard terminal helpers', () => { }) }) - it('honors measured xterm cell dimensions when available', () => { + it('uses terminal dimensions after the emulator has fit itself', () => { const container = { clientWidth: 900, clientHeight: 500, getBoundingClientRect: () => ({ width: 900, height: 500 }), } as HTMLDivElement const terminal = { - element: { - querySelector: (selector: string) => { - if (selector === '.xterm-char-measure-element') { - return { - getBoundingClientRect: () => ({ width: 10, height: 18 }), - } - } - if (selector === '.xterm-rows > div') { - return { - getBoundingClientRect: () => ({ width: 900, height: 22 }), - } - } - return null - }, - }, - } as never + cols: 120, + rows: 32, + } expect(calculateTerminalSize(container, terminal)).toEqual({ - cols: 83, - rows: 20, + cols: 120, + rows: 32, }) }) + + it('does not inflate fitted terminal dimensions to fallback minimums', () => { + const container = { + clientWidth: 200, + clientHeight: 120, + getBoundingClientRect: () => ({ width: 200, height: 120 }), + } as HTMLDivElement + const terminal = { + cols: 20, + rows: 6, + } + + expect(calculateTerminalSize(container, terminal)).toEqual({ + cols: 20, + rows: 6, + }) + }) + }) + + describe('sanitizeTerminalPaste', () => { + it('preserves printable shell input and common whitespace', () => { + expect(sanitizeTerminalPaste('echo hello\tworld\npwd\r\n')).toBe( + 'echo hello\tworld\npwd\r\n' + ) + }) + + it('strips escape sequences and non-whitespace control characters', () => { + expect( + sanitizeTerminalPaste( + '\x1b[200~echo nope\x1b[201~\x1b]0;title\x07\x9b31m\x00\x7f' + ) + ).toBe('echo nope') + }) }) describe('openTerminalSandbox', () => {