import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme" import { showToast } from "@opencode-ai/ui/toast" import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web" import { type ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js" import { SerializeAddon } from "@/addons/serialize" import { matchKeybind, parseKeybind } from "@/context/command" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useSDK } from "@/context/sdk" import { useServer } from "@/context/server" import { monoFontFamily, useSettings } from "@/context/settings" import type { LocalPTY } from "@/context/terminal" import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" import { terminalWriter } from "@/utils/terminal-writer" const TOGGLE_TERMINAL_ID = "terminal.toggle" const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`" export interface TerminalProps extends ComponentProps<"div"> { pty: LocalPTY onSubmit?: () => void onCleanup?: (pty: LocalPTY) => void onConnect?: () => void onConnectError?: (error: unknown) => void } let shared: Promise<{ mod: typeof import("ghostty-web"); ghostty: Ghostty }> | undefined const loadGhostty = () => { if (shared) return shared shared = import("ghostty-web") .then(async (mod) => ({ mod, ghostty: await mod.Ghostty.load() })) .catch((err) => { shared = undefined throw err }) return shared } type TerminalColors = { background: string foreground: string cursor: string selectionBackground: string } const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = { light: { background: "#fcfcfc", foreground: "#211e1e", cursor: "#211e1e", selectionBackground: withAlpha("#211e1e", 0.2), }, dark: { background: "#191515", foreground: "#d4d4d4", cursor: "#d4d4d4", selectionBackground: withAlpha("#d4d4d4", 0.25), }, } const debugTerminal = (...values: unknown[]) => { if (!import.meta.env.DEV) return console.debug("[terminal]", ...values) } const useTerminalUiBindings = (input: { container: HTMLDivElement term: Term cleanups: VoidFunction[] handlePointerDown: () => void handleLinkClick: (event: MouseEvent) => void }) => { const handleCopy = (event: ClipboardEvent) => { const selection = input.term.getSelection() if (!selection) return const clipboard = event.clipboardData if (!clipboard) return event.preventDefault() clipboard.setData("text/plain", selection) } const handlePaste = (event: ClipboardEvent) => { const clipboard = event.clipboardData const text = clipboard?.getData("text/plain") ?? clipboard?.getData("text") ?? "" if (!text) return event.preventDefault() event.stopPropagation() input.term.paste(text) } const handleTextareaFocus = () => { input.term.options.cursorBlink = true } const handleTextareaBlur = () => { input.term.options.cursorBlink = false } input.container.addEventListener("copy", handleCopy, true) input.cleanups.push(() => input.container.removeEventListener("copy", handleCopy, true)) input.container.addEventListener("paste", handlePaste, true) input.cleanups.push(() => input.container.removeEventListener("paste", handlePaste, true)) input.container.addEventListener("pointerdown", input.handlePointerDown) input.cleanups.push(() => input.container.removeEventListener("pointerdown", input.handlePointerDown)) input.container.addEventListener("click", input.handleLinkClick, { capture: true, }) input.cleanups.push(() => input.container.removeEventListener("click", input.handleLinkClick, { capture: true, }), ) input.term.textarea?.addEventListener("focus", handleTextareaFocus) input.term.textarea?.addEventListener("blur", handleTextareaBlur) input.cleanups.push(() => input.term.textarea?.removeEventListener("focus", handleTextareaFocus)) input.cleanups.push(() => input.term.textarea?.removeEventListener("blur", handleTextareaBlur)) } const persistTerminal = (input: { term: Term | undefined addon: SerializeAddon | undefined cursor: number pty: LocalPTY onCleanup?: (pty: LocalPTY) => void }) => { if (!input.addon || !input.onCleanup || !input.term) return const buffer = (() => { try { return input.addon.serialize() } catch { debugTerminal("failed to serialize terminal buffer") return "" } })() input.onCleanup({ ...input.pty, buffer, cursor: input.cursor, rows: input.term.rows, cols: input.term.cols, scrollY: input.term.getViewportY(), }) } export const Terminal = (props: TerminalProps) => { const platform = usePlatform() const sdk = useSDK() const settings = useSettings() const theme = useTheme() const language = useLanguage() const server = useServer() let container!: HTMLDivElement const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"]) let ws: WebSocket | undefined let term: Term | undefined let ghostty: Ghostty let serializeAddon: SerializeAddon let fitAddon: FitAddon let handleResize: () => void let fitFrame: number | undefined let sizeTimer: ReturnType | undefined let pendingSize: { cols: number; rows: number } | undefined let lastSize: { cols: number; rows: number } | undefined let disposed = false const cleanups: VoidFunction[] = [] const start = typeof local.pty.cursor === "number" && Number.isSafeInteger(local.pty.cursor) ? local.pty.cursor : undefined let cursor = start ?? 0 let output: ReturnType | undefined const cleanup = () => { if (!cleanups.length) return const fns = cleanups.splice(0).reverse() for (const fn of fns) { try { fn() } catch (err) { debugTerminal("cleanup failed", err) } } } const pushSize = (cols: number, rows: number) => { return sdk.client.pty .update({ ptyID: local.pty.id, size: { cols, rows }, }) .catch((err) => { debugTerminal("failed to sync terminal size", err) }) } const getTerminalColors = (): TerminalColors => { const mode = theme.mode() === "dark" ? "dark" : "light" const fallback = DEFAULT_TERMINAL_COLORS[mode] const currentTheme = theme.themes()[theme.themeId()] if (!currentTheme) return fallback const variant = mode === "dark" ? currentTheme.dark : currentTheme.light if (!variant?.seeds) return fallback const resolved = resolveThemeVariant(variant, mode === "dark") const text = resolved["text-stronger"] ?? fallback.foreground const background = resolved["background-stronger"] ?? fallback.background const alpha = mode === "dark" ? 0.25 : 0.2 const base = text.startsWith("#") ? (text as HexColor) : (fallback.foreground as HexColor) const selectionBackground = withAlpha(base, alpha) return { background, foreground: text, cursor: text, selectionBackground, } } const [terminalColors, setTerminalColors] = createSignal(getTerminalColors()) const scheduleFit = () => { if (disposed) return if (!fitAddon) return if (fitFrame !== undefined) return fitFrame = requestAnimationFrame(() => { fitFrame = undefined if (disposed) return fitAddon.fit() }) } const scheduleSize = (cols: number, rows: number) => { if (disposed) return if (lastSize?.cols === cols && lastSize?.rows === rows) return pendingSize = { cols, rows } if (!lastSize) { lastSize = pendingSize void pushSize(cols, rows) return } if (sizeTimer !== undefined) return sizeTimer = setTimeout(() => { sizeTimer = undefined const next = pendingSize if (!next) return pendingSize = undefined if (disposed) return if (lastSize?.cols === next.cols && lastSize?.rows === next.rows) return lastSize = next void pushSize(next.cols, next.rows) }, 100) } createEffect(() => { const colors = getTerminalColors() setTerminalColors(colors) if (!term) return setOptionIfSupported(term, "theme", colors) }) createEffect(() => { const font = monoFontFamily(settings.appearance.font()) if (!term) return setOptionIfSupported(term, "fontFamily", font) scheduleFit() }) let zoom = platform.webviewZoom?.() createEffect(() => { const next = platform.webviewZoom?.() if (next === undefined) return if (next === zoom) return zoom = next scheduleFit() }) const focusTerminal = () => { const t = term if (!t) return t.focus() t.textarea?.focus() setTimeout(() => t.textarea?.focus(), 0) } const handlePointerDown = () => { const activeElement = document.activeElement if (activeElement instanceof HTMLElement && activeElement !== container && !container.contains(activeElement)) { activeElement.blur() } focusTerminal() } const handleLinkClick = (event: MouseEvent) => { if (!event.shiftKey && !event.ctrlKey && !event.metaKey) return if (event.altKey) return if (event.button !== 0) return const t = term if (!t) return const text = getHoveredLinkText(t) if (!text) return event.preventDefault() event.stopImmediatePropagation() platform.openLink(text) } onMount(() => { const run = async () => { const loaded = await loadGhostty() if (disposed) return const mod = loaded.mod const g = loaded.ghostty const once = { value: false } const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : "" const restoreSize = restore && typeof local.pty.cols === "number" && Number.isSafeInteger(local.pty.cols) && local.pty.cols > 0 && typeof local.pty.rows === "number" && Number.isSafeInteger(local.pty.rows) && local.pty.rows > 0 ? { cols: local.pty.cols, rows: local.pty.rows } : undefined const t = new mod.Terminal({ cursorBlink: true, cursorStyle: "bar", cols: restoreSize?.cols, rows: restoreSize?.rows, fontSize: 14, fontFamily: monoFontFamily(settings.appearance.font()), allowTransparency: false, convertEol: false, theme: terminalColors(), scrollback: 10_000, ghostty: g, }) cleanups.push(() => t.dispose()) if (disposed) { cleanup() return } ghostty = g term = t output = terminalWriter((data, done) => t.write(data, done)) t.attachCustomKeyEventHandler((event) => { const key = event.key.toLowerCase() if (event.ctrlKey && event.shiftKey && !event.metaKey && key === "c") { document.execCommand("copy") return true } // allow for toggle terminal keybinds in parent const config = settings.keybinds.get(TOGGLE_TERMINAL_ID) ?? DEFAULT_TOGGLE_TERMINAL_KEYBIND const keybinds = parseKeybind(config) return matchKeybind(keybinds, event) }) const fit = new mod.FitAddon() const serializer = new SerializeAddon() cleanups.push(() => disposeIfDisposable(fit)) t.loadAddon(serializer) t.loadAddon(fit) fitAddon = fit serializeAddon = serializer t.open(container) useTerminalUiBindings({ container, term: t, cleanups, handlePointerDown, handleLinkClick, }) focusTerminal() if (typeof document !== "undefined" && document.fonts) { document.fonts.ready.then(scheduleFit) } const onResize = t.onResize((size) => { scheduleSize(size.cols, size.rows) }) cleanups.push(() => disposeIfDisposable(onResize)) const onData = t.onData((data) => { if (ws?.readyState === WebSocket.OPEN) ws.send(data) }) cleanups.push(() => disposeIfDisposable(onData)) const onKey = t.onKey((key) => { if (key.key == "Enter") { props.onSubmit?.() } }) cleanups.push(() => disposeIfDisposable(onKey)) const startResize = () => { fit.observeResize() handleResize = scheduleFit window.addEventListener("resize", handleResize) cleanups.push(() => window.removeEventListener("resize", handleResize)) } if (restore && restoreSize) { t.write(restore, () => { fit.fit() scheduleSize(t.cols, t.rows) if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY) startResize() }) } else { fit.fit() scheduleSize(t.cols, t.rows) if (restore) { t.write(restore, () => { if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY) }) } startResize() } // t.onScroll((ydisp) => { // console.log("Scroll position:", ydisp) // }) const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`) url.searchParams.set("directory", sdk.directory) url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0)) url.protocol = url.protocol === "https:" ? "wss:" : "ws:" url.username = server.current?.http.username ?? "" url.password = server.current?.http.password ?? "" const socket = new WebSocket(url) socket.binaryType = "arraybuffer" ws = socket cleanups.push(() => { if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close() }) if (disposed) { cleanup() return } const handleOpen = () => { local.onConnect?.() scheduleSize(t.cols, t.rows) } socket.addEventListener("open", handleOpen) cleanups.push(() => socket.removeEventListener("open", handleOpen)) if (socket.readyState === WebSocket.OPEN) handleOpen() const decoder = new TextDecoder() const handleMessage = (event: MessageEvent) => { if (disposed) return if (event.data instanceof ArrayBuffer) { // WebSocket control frame: 0x00 + UTF-8 JSON (currently { cursor }). const bytes = new Uint8Array(event.data) if (bytes[0] !== 0) return const json = decoder.decode(bytes.subarray(1)) try { const meta = JSON.parse(json) as { cursor?: unknown } const next = meta?.cursor if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) { cursor = next } } catch (err) { debugTerminal("invalid websocket control frame", err) } return } const data = typeof event.data === "string" ? event.data : "" if (!data) return output?.push(data) cursor += data.length } socket.addEventListener("message", handleMessage) cleanups.push(() => socket.removeEventListener("message", handleMessage)) const handleError = (error: Event) => { if (disposed) return if (once.value) return once.value = true console.error("WebSocket error:", error) local.onConnectError?.(error) } socket.addEventListener("error", handleError) cleanups.push(() => socket.removeEventListener("error", handleError)) const handleClose = (event: CloseEvent) => { if (disposed) return // Normal closure (code 1000) means PTY process exited - server event handles cleanup // For other codes (network issues, server restart), trigger error handler if (event.code !== 1000) { if (once.value) return once.value = true local.onConnectError?.(new Error(`WebSocket closed abnormally: ${event.code}`)) } } socket.addEventListener("close", handleClose) cleanups.push(() => socket.removeEventListener("close", handleClose)) } void run().catch((err) => { if (disposed) return showToast({ variant: "error", title: language.t("terminal.connectionLost.title"), description: err instanceof Error ? err.message : language.t("terminal.connectionLost.description"), }) local.onConnectError?.(err) }) }) onCleanup(() => { disposed = true if (fitFrame !== undefined) cancelAnimationFrame(fitFrame) if (sizeTimer !== undefined) clearTimeout(sizeTimer) if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close() const finalize = () => { persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup }) cleanup() } if (!output) { finalize() return } output.flush(finalize) }) return (
) }