From 6eecc9b5aaf67719922a4b9eef7ccabecf07422b Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 17 Jun 2026 16:29:47 -0700 Subject: [PATCH 1/6] Switch dashboard terminal to Ghostty --- bun.lock | 4 + package.json | 1 + .../dashboard/terminal/dashboard-terminal.tsx | 2 +- .../dashboard/terminal/terminal-size.ts | 20 ++++- .../terminal/use-terminal-instance.ts | 90 +++++++++++-------- 5 files changed, 74 insertions(+), 43 deletions(-) diff --git a/bun.lock b/bun.lock index 19063898c..7615b2589 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "@e2b/dashboard", @@ -62,6 +63,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", @@ -1375,6 +1377,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..48b98f7f9 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,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", diff --git a/src/features/dashboard/terminal/dashboard-terminal.tsx b/src/features/dashboard/terminal/dashboard-terminal.tsx index cbc388f9c..00b5b181c 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 { diff --git a/src/features/dashboard/terminal/terminal-size.ts b/src/features/dashboard/terminal/terminal-size.ts index 59669ff60..5b694405d 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 @@ -21,7 +20,22 @@ function getElementSize(element: Element | null) { return rect } -function getMeasuredCellSize(terminal: XTerm | null) { +type TerminalLike = { + element?: HTMLElement + renderer?: { + getMetrics?: () => { width: number; height: number } + } +} + +function getMeasuredCellSize(terminal: TerminalLike | null) { + const rendererMetrics = terminal?.renderer?.getMetrics?.() + if (rendererMetrics?.width && rendererMetrics.height) { + return { + width: rendererMetrics.width, + height: rendererMetrics.height, + } + } + const measureElement = terminal?.element?.querySelector( '.xterm-char-measure-element' ) @@ -52,7 +66,7 @@ function getMeasuredCellSize(terminal: XTerm | null) { export function calculateTerminalSize( container: HTMLDivElement | null, - terminal: XTerm | null + terminal: TerminalLike | null ) { if (!container) { return { cols: DEFAULT_COLS, rows: DEFAULT_ROWS } diff --git a/src/features/dashboard/terminal/use-terminal-instance.ts b/src/features/dashboard/terminal/use-terminal-instance.ts index 95b70196b..dddbc1938 100644 --- a/src/features/dashboard/terminal/use-terminal-instance.ts +++ b/src/features/dashboard/terminal/use-terminal-instance.ts @@ -1,5 +1,4 @@ -import '@xterm/xterm/css/xterm.css' -import { Terminal as XTerm } from '@xterm/xterm' +import { Terminal as GhosttyTerminal, init as initGhostty } from 'ghostty-web' import { useCallback, useEffect, useRef } from 'react' import { DEFAULT_COLS, @@ -17,6 +16,8 @@ const TERMINAL_THEME = { selectionBackground: '#ffffff40', } +const ghosttyReady = initGhostty() + interface UseTerminalInstanceOptions { onInput: (data: string) => void onResize: (size: { cols: number; rows: number }) => void @@ -26,7 +27,7 @@ export function useTerminalInstance({ onInput, onResize, }: UseTerminalInstanceOptions) { - const xtermRef = useRef(null) + const terminalRef = useRef(null) const terminalContainerRef = useRef(null) const terminalTranscriptRef = useRef(INITIAL_TERMINAL_TEXT) const terminalSizeRef = useRef({ cols: DEFAULT_COLS, rows: DEFAULT_ROWS }) @@ -35,10 +36,10 @@ export function useTerminalInstance({ const resizeTerminal = useCallback(() => { const nextSize = calculateTerminalSize( terminalContainerRef.current, - xtermRef.current + terminalRef.current ) terminalSizeRef.current = nextSize - xtermRef.current?.resize(nextSize.cols, nextSize.rows) + terminalRef.current?.resize(nextSize.cols, nextSize.rows) onResize(nextSize) return nextSize @@ -53,24 +54,24 @@ 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 copyTerminalText = useCallback(async () => { const value = - xtermRef.current?.getSelection() || terminalTranscriptRef.current + terminalRef.current?.getSelection() || terminalTranscriptRef.current if (!value) return try { @@ -86,38 +87,49 @@ 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, + let disposed = false + let terminal: GhosttyTerminal | null = null + let dataSubscription: { dispose: () => void } | undefined + let resizeTimer: number | undefined + + void ghosttyReady.then(() => { + if (disposed || !terminalContainerRef.current) 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, + }) + + terminalRef.current = terminal + terminal.open(terminalContainerRef.current) + terminal.write(terminalTranscriptRef.current) + dataSubscription = terminal.onData(onInput) + + requestAnimationFrame(() => { + resizeTerminal() + terminal?.focus() + }) + resizeTimer = window.setTimeout(() => { + resizeTerminal() + }, 100) }) - 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) - return () => { - window.clearTimeout(resizeTimer) - dataSubscription.dispose() - terminal.dispose() - if (xtermRef.current === terminal) { - xtermRef.current = null + disposed = true + if (resizeTimer) { + window.clearTimeout(resizeTimer) + } + dataSubscription?.dispose() + terminal?.dispose() + if (terminalRef.current === terminal) { + terminalRef.current = null } } }, [onInput, resizeTerminal]) From 579ab0c35dbe1bee421f2ecc61480aa3f50e97f6 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 17 Jun 2026 16:31:26 -0700 Subject: [PATCH 2/6] Remove unused xterm dependency --- bun.lock | 3 --- package.json | 1 - 2 files changed, 4 deletions(-) diff --git a/bun.lock b/bun.lock index 7615b2589..a54de155d 100644 --- a/bun.lock +++ b/bun.lock @@ -51,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", @@ -1083,8 +1082,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=="], diff --git a/package.json b/package.json index 48b98f7f9..753efeba9 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", From 5892d15ddd3774f750a436c40458d8500c9c0d71 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 17 Jun 2026 16:38:59 -0700 Subject: [PATCH 3/6] Harden Ghostty terminal initialization --- .../terminal/use-terminal-instance.ts | 70 ++++++++++++------- 1 file changed, 44 insertions(+), 26 deletions(-) diff --git a/src/features/dashboard/terminal/use-terminal-instance.ts b/src/features/dashboard/terminal/use-terminal-instance.ts index dddbc1938..688072ef1 100644 --- a/src/features/dashboard/terminal/use-terminal-instance.ts +++ b/src/features/dashboard/terminal/use-terminal-instance.ts @@ -15,6 +15,8 @@ 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() @@ -92,34 +94,50 @@ export function useTerminalInstance({ let dataSubscription: { dispose: () => void } | undefined let resizeTimer: number | undefined - void ghosttyReady.then(() => { - if (disposed || !terminalContainerRef.current) 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, + 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, + }) + + 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) }) - - terminalRef.current = terminal - terminal.open(terminalContainerRef.current) - terminal.write(terminalTranscriptRef.current) - dataSubscription = terminal.onData(onInput) - - requestAnimationFrame(() => { - resizeTerminal() - terminal?.focus() + .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() }) - resizeTimer = window.setTimeout(() => { - resizeTerminal() - }, 100) - }) return () => { disposed = true From d4c11dc2b7fedbb363610ead94bc243f8a8d9a96 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 17 Jun 2026 16:47:47 -0700 Subject: [PATCH 4/6] Sanitize pasted terminal input --- .../dashboard/terminal/dashboard-terminal.tsx | 2 + .../dashboard/terminal/terminal-panel.tsx | 5 +- .../dashboard/terminal/terminal-paste.ts | 99 +++++++++++++++++++ .../terminal/use-terminal-instance.ts | 20 ++++ tests/unit/dashboard-terminal.test.ts | 17 ++++ 5 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 src/features/dashboard/terminal/terminal-paste.ts diff --git a/src/features/dashboard/terminal/dashboard-terminal.tsx b/src/features/dashboard/terminal/dashboard-terminal.tsx index 00b5b181c..b1ec268cd 100644 --- a/src/features/dashboard/terminal/dashboard-terminal.tsx +++ b/src/features/dashboard/terminal/dashboard-terminal.tsx @@ -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..edd5a3400 --- /dev/null +++ b/src/features/dashboard/terminal/terminal-paste.ts @@ -0,0 +1,99 @@ +const CONTROL_SEQUENCE_INTRODUCER = '['.charCodeAt(0) +const OPERATING_SYSTEM_COMMAND = ']'.charCodeAt(0) +const STRING_TERMINATOR = '\\'.charCodeAt(0) +const BELL = 0x07 +const ESCAPE = 0x1b +const DELETE = 0x7f + +const isAllowedPasteControl = (code: number) => + code === 0x09 || code === 0x0a || code === 0x0d + +const isFinalControlSequenceByte = (code: number) => + code >= 0x40 && code <= 0x7e + +const isStringControlSequence = (code: number) => + code === OPERATING_SYSTEM_COMMAND || + code === 'P'.charCodeAt(0) || + code === '_'.charCodeAt(0) || + code === 'X'.charCodeAt(0) || + code === '^'.charCodeAt(0) + +function skipUntilControlSequenceFinal(value: string, index: number) { + let nextIndex = index + while (nextIndex < value.length) { + if (isFinalControlSequenceByte(value.charCodeAt(nextIndex))) { + return nextIndex + 1 + } + nextIndex += 1 + } + + return nextIndex +} + +function skipUntilStringTerminator(value: string, index: number) { + let nextIndex = index + while (nextIndex < value.length) { + const code = value.charCodeAt(nextIndex) + if (code === BELL) { + return nextIndex + 1 + } + if ( + code === ESCAPE && + value.charCodeAt(nextIndex + 1) === STRING_TERMINATOR + ) { + return nextIndex + 2 + } + nextIndex += 1 + } + + return nextIndex +} + +export function sanitizeTerminalPaste(value: string) { + let sanitized = '' + let index = 0 + + while (index < value.length) { + const code = value.charCodeAt(index) + + if (code === ESCAPE) { + const nextCode = value.charCodeAt(index + 1) + if (nextCode === CONTROL_SEQUENCE_INTRODUCER) { + index = skipUntilControlSequenceFinal(value, index + 2) + continue + } + if (isStringControlSequence(nextCode)) { + index = skipUntilStringTerminator(value, index + 2) + continue + } + + index += nextCode ? 2 : 1 + continue + } + + if (code === 0x9b) { + index = skipUntilControlSequenceFinal(value, index + 1) + continue + } + + if (code === 0x9d || code === 0x90 || code === 0x9e || code === 0x9f) { + index = skipUntilStringTerminator(value, index + 1) + continue + } + + if ((code < 0x20 && !isAllowedPasteControl(code)) || code === DELETE) { + index += 1 + continue + } + + if (code >= 0x80 && code <= 0x9f) { + index += 1 + continue + } + + sanitized += value[index] + index += 1 + } + + return sanitized +} diff --git a/src/features/dashboard/terminal/use-terminal-instance.ts b/src/features/dashboard/terminal/use-terminal-instance.ts index 688072ef1..375bf6040 100644 --- a/src/features/dashboard/terminal/use-terminal-instance.ts +++ b/src/features/dashboard/terminal/use-terminal-instance.ts @@ -1,10 +1,12 @@ import { 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 = @@ -71,6 +73,23 @@ export function useTerminalInstance({ terminalRef.current?.focus() }, []) + const pasteTerminalText = useCallback( + (event: ClipboardEvent) => { + const text = event.clipboardData.getData('text/plain') + if (!text) return + + event.preventDefault() + + const sanitizedText = sanitizeTerminalPaste(text) + if (sanitizedText) { + onInput(sanitizedText) + } + + terminalRef.current?.focus() + }, + [onInput] + ) + const copyTerminalText = useCallback(async () => { const value = terminalRef.current?.getSelection() || terminalTranscriptRef.current @@ -181,6 +200,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..beef8ea7a 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(() => ({ @@ -202,6 +203,22 @@ describe('dashboard terminal helpers', () => { }) }) + 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', () => { it('connects to an explicit sandbox without writing a stored session', async () => { const statuses: string[] = [] From 8acce2d2af846555ee028b76e6adc94f419eae83 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 17 Jun 2026 17:44:03 -0700 Subject: [PATCH 5/6] Fix Ghostty terminal sizing and paste handling --- bun.lock | 1 + package.json | 1 + .../dashboard/terminal/terminal-paste.ts | 83 ++----------------- .../dashboard/terminal/terminal-size.ts | 79 ++++-------------- .../terminal/use-terminal-instance.ts | 24 +++++- tests/unit/dashboard-terminal.test.ts | 25 ++---- 6 files changed, 48 insertions(+), 165 deletions(-) diff --git a/bun.lock b/bun.lock index a54de155d..477e140d0 100644 --- a/bun.lock +++ b/bun.lock @@ -91,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", diff --git a/package.json b/package.json index 753efeba9..a136c94e8 100644 --- a/package.json +++ b/package.json @@ -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/terminal-paste.ts b/src/features/dashboard/terminal/terminal-paste.ts index edd5a3400..5a9ec046b 100644 --- a/src/features/dashboard/terminal/terminal-paste.ts +++ b/src/features/dashboard/terminal/terminal-paste.ts @@ -1,98 +1,25 @@ -const CONTROL_SEQUENCE_INTRODUCER = '['.charCodeAt(0) -const OPERATING_SYSTEM_COMMAND = ']'.charCodeAt(0) -const STRING_TERMINATOR = '\\'.charCodeAt(0) -const BELL = 0x07 -const ESCAPE = 0x1b +import stripAnsi from 'strip-ansi' + const DELETE = 0x7f const isAllowedPasteControl = (code: number) => code === 0x09 || code === 0x0a || code === 0x0d -const isFinalControlSequenceByte = (code: number) => - code >= 0x40 && code <= 0x7e - -const isStringControlSequence = (code: number) => - code === OPERATING_SYSTEM_COMMAND || - code === 'P'.charCodeAt(0) || - code === '_'.charCodeAt(0) || - code === 'X'.charCodeAt(0) || - code === '^'.charCodeAt(0) - -function skipUntilControlSequenceFinal(value: string, index: number) { - let nextIndex = index - while (nextIndex < value.length) { - if (isFinalControlSequenceByte(value.charCodeAt(nextIndex))) { - return nextIndex + 1 - } - nextIndex += 1 - } - - return nextIndex -} - -function skipUntilStringTerminator(value: string, index: number) { - let nextIndex = index - while (nextIndex < value.length) { - const code = value.charCodeAt(nextIndex) - if (code === BELL) { - return nextIndex + 1 - } - if ( - code === ESCAPE && - value.charCodeAt(nextIndex + 1) === STRING_TERMINATOR - ) { - return nextIndex + 2 - } - nextIndex += 1 - } - - return nextIndex -} - export function sanitizeTerminalPaste(value: string) { let sanitized = '' - let index = 0 - - while (index < value.length) { - const code = value.charCodeAt(index) - - if (code === ESCAPE) { - const nextCode = value.charCodeAt(index + 1) - if (nextCode === CONTROL_SEQUENCE_INTRODUCER) { - index = skipUntilControlSequenceFinal(value, index + 2) - continue - } - if (isStringControlSequence(nextCode)) { - index = skipUntilStringTerminator(value, index + 2) - continue - } - - index += nextCode ? 2 : 1 - continue - } - - if (code === 0x9b) { - index = skipUntilControlSequenceFinal(value, index + 1) - continue - } - if (code === 0x9d || code === 0x90 || code === 0x9e || code === 0x9f) { - index = skipUntilStringTerminator(value, index + 1) - continue - } + for (const char of stripAnsi(value)) { + const code = char.charCodeAt(0) if ((code < 0x20 && !isAllowedPasteControl(code)) || code === DELETE) { - index += 1 continue } if (code >= 0x80 && code <= 0x9f) { - index += 1 continue } - sanitized += value[index] - index += 1 + sanitized += char } return sanitized diff --git a/src/features/dashboard/terminal/terminal-size.ts b/src/features/dashboard/terminal/terminal-size.ts index 5b694405d..bfc9927cf 100644 --- a/src/features/dashboard/terminal/terminal-size.ts +++ b/src/features/dashboard/terminal/terminal-size.ts @@ -6,73 +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 -} type TerminalLike = { - element?: HTMLElement - renderer?: { - getMetrics?: () => { width: number; height: number } - } -} - -function getMeasuredCellSize(terminal: TerminalLike | null) { - const rendererMetrics = terminal?.renderer?.getMetrics?.() - if (rendererMetrics?.width && rendererMetrics.height) { - return { - width: rendererMetrics.width, - height: rendererMetrics.height, - } - } - - 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, - } + cols: number + rows: number } export function calculateTerminalSize( container: HTMLDivElement | null, terminal: TerminalLike | null ) { + if (terminal) { + return { + cols: Math.max(MIN_TERMINAL_COLS, terminal.cols), + rows: Math.max(MIN_TERMINAL_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 @@ -81,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 375bf6040..0f323356a 100644 --- a/src/features/dashboard/terminal/use-terminal-instance.ts +++ b/src/features/dashboard/terminal/use-terminal-instance.ts @@ -1,4 +1,8 @@ -import { Terminal as GhosttyTerminal, init as initGhostty } from 'ghostty-web' +import { + FitAddon, + Terminal as GhosttyTerminal, + init as initGhostty, +} from 'ghostty-web' import type { ClipboardEvent } from 'react' import { useCallback, useEffect, useRef } from 'react' import { @@ -32,18 +36,19 @@ export function useTerminalInstance({ onResize, }: UseTerminalInstanceOptions) { 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, terminalRef.current ) terminalSizeRef.current = nextSize - terminalRef.current?.resize(nextSize.cols, nextSize.rows) onResize(nextSize) return nextSize @@ -79,10 +84,15 @@ export function useTerminalInstance({ if (!text) return event.preventDefault() + event.stopPropagation() const sanitizedText = sanitizeTerminalPaste(text) if (sanitizedText) { - onInput(sanitizedText) + if (terminalRef.current) { + terminalRef.current.paste(sanitizedText) + } else { + onInput(sanitizedText) + } } terminalRef.current?.focus() @@ -110,6 +120,7 @@ export function useTerminalInstance({ let disposed = false let terminal: GhosttyTerminal | null = null + let fitAddon: FitAddon | null = null let dataSubscription: { dispose: () => void } | undefined let resizeTimer: number | undefined @@ -130,6 +141,9 @@ export function useTerminalInstance({ theme: TERMINAL_THEME, }) + fitAddon = new FitAddon() + fitAddonRef.current = fitAddon + terminal.loadAddon(fitAddon) terminalRef.current = terminal terminal.open(currentContainer) dataSubscription = terminal.onData(onInput) @@ -164,10 +178,14 @@ export function useTerminalInstance({ 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]) diff --git a/tests/unit/dashboard-terminal.test.ts b/tests/unit/dashboard-terminal.test.ts index beef8ea7a..04193cec5 100644 --- a/tests/unit/dashboard-terminal.test.ts +++ b/tests/unit/dashboard-terminal.test.ts @@ -172,33 +172,20 @@ 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, }) }) }) From 392ddd79b73a8f70e16955da5966b51bed86b14d Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 17 Jun 2026 17:49:06 -0700 Subject: [PATCH 6/6] Match PTY size to Ghostty grid --- .../dashboard/terminal/terminal-size.ts | 4 ++-- tests/unit/dashboard-terminal.test.ts | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/features/dashboard/terminal/terminal-size.ts b/src/features/dashboard/terminal/terminal-size.ts index bfc9927cf..66ebde4a3 100644 --- a/src/features/dashboard/terminal/terminal-size.ts +++ b/src/features/dashboard/terminal/terminal-size.ts @@ -18,8 +18,8 @@ export function calculateTerminalSize( ) { if (terminal) { return { - cols: Math.max(MIN_TERMINAL_COLS, terminal.cols), - rows: Math.max(MIN_TERMINAL_ROWS, terminal.rows), + cols: terminal.cols, + rows: terminal.rows, } } diff --git a/tests/unit/dashboard-terminal.test.ts b/tests/unit/dashboard-terminal.test.ts index 04193cec5..da87558f1 100644 --- a/tests/unit/dashboard-terminal.test.ts +++ b/tests/unit/dashboard-terminal.test.ts @@ -188,6 +188,23 @@ describe('dashboard terminal helpers', () => { 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', () => {