Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion web/e2e/terminal-isolation.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { test, expect } from '@playwright/test';
import { test } from '@playwright/test';

test.describe('Terminal Session Isolation', () => {
test('rapid tab switching within same workspace', async ({ page }) => {
Expand Down
320 changes: 198 additions & 122 deletions web/src/components/Terminal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState, useCallback } from 'react'
import { Ghostty, Terminal as GhosttyTerminal, FitAddon } from 'ghostty-web'
import { getTerminalUrl } from '@/lib/api'

Expand All @@ -7,75 +7,196 @@ interface TerminalProps {
initialCommand?: string
}

const MAX_CACHED_TERMINALS = 5

interface CachedTerminal {
ghostty: Ghostty
terminal: GhosttyTerminal
fitAddon: FitAddon
ws: WebSocket | null
lastUsed: number
initialCommandSent: boolean
}

const terminalCache = new Map<string, CachedTerminal>()

function evictLRU(): void {
if (terminalCache.size <= MAX_CACHED_TERMINALS) return

let oldest: string | null = null
let oldestTime = Infinity

for (const [name, cached] of terminalCache) {
if (cached.lastUsed < oldestTime) {
oldestTime = cached.lastUsed
oldest = name
}
}

if (oldest) {
const cached = terminalCache.get(oldest)
if (cached) {
cached.ws?.close()
cached.terminal.dispose()
terminalCache.delete(oldest)
}
}
}

function getOrCreateTerminal(
workspaceName: string,
ghosttyFactory: () => Promise<Ghostty>
): Promise<CachedTerminal> {
const existing = terminalCache.get(workspaceName)
if (existing) {
existing.lastUsed = Date.now()
return Promise.resolve(existing)
}

return ghosttyFactory().then((ghostty) => {
const terminal = new GhosttyTerminal({
ghostty,
cursorBlink: false,
cursorStyle: 'block',
fontSize: 14,
fontFamily: 'Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
scrollback: 10000,
theme: {
background: '#0d1117',
foreground: '#c9d1d9',
cursor: '#58a6ff',
cursorAccent: '#0d1117',
selectionBackground: '#264f78',
selectionForeground: '#ffffff',
black: '#484f58',
red: '#ff7b72',
green: '#3fb950',
yellow: '#d29922',
blue: '#58a6ff',
magenta: '#bc8cff',
cyan: '#39c5cf',
white: '#b1bac4',
brightBlack: '#6e7681',
brightRed: '#ffa198',
brightGreen: '#56d364',
brightYellow: '#e3b341',
brightBlue: '#79c0ff',
brightMagenta: '#d2a8ff',
brightCyan: '#56d4dd',
brightWhite: '#f0f6fc',
},
})

const fitAddon = new FitAddon()
terminal.loadAddon(fitAddon)

const cached: CachedTerminal = {
ghostty,
terminal,
fitAddon,
ws: null,
lastUsed: Date.now(),
initialCommandSent: false,
}

terminalCache.set(workspaceName, cached)
evictLRU()

return cached
})
}

function TerminalInstance({ workspaceName, initialCommand }: TerminalProps) {
const terminalRef = useRef<HTMLDivElement>(null)
const termRef = useRef<GhosttyTerminal | null>(null)
const ghosttyRef = useRef<Ghostty | null>(null)
const fitAddonRef = useRef<FitAddon | null>(null)
const wsRef = useRef<WebSocket | null>(null)
const initialCommandSent = useRef(false)
const cachedRef = useRef<CachedTerminal | null>(null)
const resizeObserverRef = useRef<ResizeObserver | null>(null)
const [isConnected, setIsConnected] = useState(false)
const [isInitialized, setIsInitialized] = useState(false)
const [hasReceivedData, setHasReceivedData] = useState(false)

useEffect(() => {
let cancelled = false
const setupWebSocket = useCallback((cached: CachedTerminal, cancelled: { current: boolean }) => {
if (cached.ws && cached.ws.readyState === WebSocket.OPEN) {
setIsConnected(true)
return
}

const connect = async () => {
if (!terminalRef.current || cancelled) return
cached.ws?.close()

const ghostty = await Ghostty.load()
if (cancelled) return
const wsUrl = getTerminalUrl(workspaceName)
const ws = new WebSocket(wsUrl)
cached.ws = ws

ghosttyRef.current = ghostty
setIsInitialized(true)
ws.onopen = () => {
if (cancelled.current) return
setIsConnected(true)
const { cols, rows } = cached.terminal
ws.send(JSON.stringify({ type: 'resize', cols, rows }))

const term = new GhosttyTerminal({
ghostty,
cursorBlink: false,
cursorStyle: 'block',
fontSize: 14,
fontFamily: 'Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
scrollback: 10000,
theme: {
background: '#0d1117',
foreground: '#c9d1d9',
cursor: '#58a6ff',
cursorAccent: '#0d1117',
selectionBackground: '#264f78',
selectionForeground: '#ffffff',
black: '#484f58',
red: '#ff7b72',
green: '#3fb950',
yellow: '#d29922',
blue: '#58a6ff',
magenta: '#bc8cff',
cyan: '#39c5cf',
white: '#b1bac4',
brightBlack: '#6e7681',
brightRed: '#ffa198',
brightGreen: '#56d364',
brightYellow: '#e3b341',
brightBlue: '#79c0ff',
brightMagenta: '#d2a8ff',
brightCyan: '#56d4dd',
brightWhite: '#f0f6fc',
},
})
if (initialCommand && !cached.initialCommandSent) {
cached.initialCommandSent = true
setTimeout(() => {
if (!cancelled.current && ws.readyState === WebSocket.OPEN) {
ws.send(initialCommand + '\n')
}
}, 500)
}
}

ws.onmessage = (event) => {
if (cancelled.current) return
setHasReceivedData(true)
if (event.data instanceof Blob) {
event.data.text().then((text) => {
if (!cancelled.current) cached.terminal.write(text)
})
} else if (event.data instanceof ArrayBuffer) {
cached.terminal.write(new Uint8Array(event.data))
} else {
cached.terminal.write(event.data)
}
}

if (cancelled) {
term.dispose()
return
ws.onclose = (event) => {
if (cancelled.current) return
setIsConnected(false)
cached.terminal.writeln('')
if (event.code === 1000) {
cached.terminal.writeln('\x1b[38;5;245mSession ended\x1b[0m')
} else if (event.code === 404 || event.reason?.includes('not found')) {
cached.terminal.writeln('\x1b[31mWorkspace not found or not running\x1b[0m')
} else {
cached.terminal.writeln(`\x1b[31mDisconnected (code: ${event.code})\x1b[0m`)
}
}

ws.onerror = () => {
if (cancelled.current) return
setIsConnected(false)
cached.terminal.writeln('\x1b[31mConnection error - is the workspace running?\x1b[0m')
}
}, [workspaceName, initialCommand])

useEffect(() => {
const cancelled = { current: false }

termRef.current = term
const connect = async () => {
if (!terminalRef.current || cancelled.current) return

const cached = await getOrCreateTerminal(workspaceName, () => Ghostty.load())
if (cancelled.current) return

const fitAddon = new FitAddon()
fitAddonRef.current = fitAddon
term.loadAddon(fitAddon)
cachedRef.current = cached
cached.lastUsed = Date.now()
setIsInitialized(true)

term.open(terminalRef.current)
const term = cached.terminal

const isAlreadyOpen = term.element?.parentElement != null
if (!isAlreadyOpen) {
term.open(terminalRef.current)
} else {
terminalRef.current.appendChild(term.element!)
}

if (term.textarea) {
term.textarea.style.opacity = '0'
Expand All @@ -85,69 +206,24 @@ function TerminalInstance({ workspaceName, initialCommand }: TerminalProps) {
}

requestAnimationFrame(() => {
if (!cancelled) fitAddon.fit()
})

const wsUrl = getTerminalUrl(workspaceName)
const ws = new WebSocket(wsUrl)
wsRef.current = ws

ws.onopen = () => {
if (cancelled) return
setIsConnected(true)
const { cols, rows } = term
ws.send(JSON.stringify({ type: 'resize', cols, rows }))

if (initialCommand && !initialCommandSent.current) {
initialCommandSent.current = true
setTimeout(() => {
if (!cancelled) ws.send(initialCommand + '\n')
}, 500)
if (!cancelled.current) {
try {
cached.fitAddon.fit()
} catch {}
}
}

ws.onmessage = (event) => {
if (cancelled) return
setHasReceivedData(true)
if (event.data instanceof Blob) {
event.data.text().then((text) => {
if (!cancelled) term.write(text)
})
} else if (event.data instanceof ArrayBuffer) {
term.write(new Uint8Array(event.data))
} else {
term.write(event.data)
}
}

ws.onclose = (event) => {
if (cancelled) return
setIsConnected(false)
term.writeln('')
if (event.code === 1000) {
term.writeln('\x1b[38;5;245mSession ended\x1b[0m')
} else if (event.code === 404 || event.reason?.includes('not found')) {
term.writeln('\x1b[31mWorkspace not found or not running\x1b[0m')
} else {
term.writeln(`\x1b[31mDisconnected (code: ${event.code})\x1b[0m`)
}
}
})

ws.onerror = () => {
if (cancelled) return
setIsConnected(false)
term.writeln('\x1b[31mConnection error - is the workspace running?\x1b[0m')
}
setupWebSocket(cached, cancelled)

term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data)
if (cached.ws?.readyState === WebSocket.OPEN) {
cached.ws.send(data)
}
})

term.onResize(({ cols, rows }) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'resize', cols, rows }))
if (cached.ws?.readyState === WebSocket.OPEN) {
cached.ws.send(JSON.stringify({ type: 'resize', cols, rows }))
}
})

Expand All @@ -157,11 +233,10 @@ function TerminalInstance({ workspaceName, initialCommand }: TerminalProps) {
connect()

const handleFit = () => {
if (fitAddonRef.current && termRef.current) {
if (cachedRef.current) {
try {
fitAddonRef.current.fit()
} catch {
}
cachedRef.current.fitAddon.fit()
} catch {}
}
}

Expand All @@ -176,19 +251,20 @@ function TerminalInstance({ workspaceName, initialCommand }: TerminalProps) {

window.addEventListener('resize', debouncedFit)

const containerElement = terminalRef.current

return () => {
cancelled = true
cancelled.current = true
window.removeEventListener('resize', debouncedFit)
resizeObserverRef.current?.disconnect()
resizeObserverRef.current = null
wsRef.current?.close()
wsRef.current = null
termRef.current?.dispose()
termRef.current = null
fitAddonRef.current = null
ghosttyRef.current = null

if (cachedRef.current?.terminal.element?.parentElement === containerElement) {
containerElement?.removeChild(cachedRef.current.terminal.element)
}
cachedRef.current = null
}
}, [workspaceName, initialCommand])
}, [workspaceName, setupWebSocket])

return (
<>
Expand All @@ -200,7 +276,7 @@ function TerminalInstance({ workspaceName, initialCommand }: TerminalProps) {
padding: '8px',
opacity: 1,
}}
onClick={() => termRef.current?.focus()}
onClick={() => cachedRef.current?.terminal.focus()}
/>
{!isInitialized && (
<div className="absolute inset-0 flex items-center justify-center bg-[#0d1117]">
Expand Down