398 lines
12 KiB
TypeScript
398 lines
12 KiB
TypeScript
import type { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
|
|
import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
|
|
import { useSDK } from "@/context/sdk"
|
|
import { monoFontFamily, useSettings } from "@/context/settings"
|
|
import { SerializeAddon } from "@/addons/serialize"
|
|
import { LocalPTY } from "@/context/terminal"
|
|
import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme"
|
|
import { useLanguage } from "@/context/language"
|
|
import { showToast } from "@opencode-ai/ui/toast"
|
|
|
|
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),
|
|
},
|
|
}
|
|
|
|
export const Terminal = (props: TerminalProps) => {
|
|
const sdk = useSDK()
|
|
const settings = useSettings()
|
|
const theme = useTheme()
|
|
const language = useLanguage()
|
|
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 handleTextareaFocus: () => void
|
|
let handleTextareaBlur: () => void
|
|
let disposed = false
|
|
const cleanups: VoidFunction[] = []
|
|
|
|
const cleanup = () => {
|
|
if (!cleanups.length) return
|
|
const fns = cleanups.splice(0).reverse()
|
|
for (const fn of fns) {
|
|
try {
|
|
fn()
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
|
|
const getTerminalColors = (): TerminalColors => {
|
|
const mode = theme.mode()
|
|
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<TerminalColors>(getTerminalColors())
|
|
|
|
createEffect(() => {
|
|
const colors = getTerminalColors()
|
|
setTerminalColors(colors)
|
|
if (!term) return
|
|
const setOption = (term as unknown as { setOption?: (key: string, value: TerminalColors) => void }).setOption
|
|
if (!setOption) return
|
|
setOption("theme", colors)
|
|
})
|
|
|
|
createEffect(() => {
|
|
const font = monoFontFamily(settings.appearance.font())
|
|
if (!term) return
|
|
const setOption = (term as unknown as { setOption?: (key: string, value: string) => void }).setOption
|
|
if (!setOption) return
|
|
setOption("fontFamily", font)
|
|
})
|
|
|
|
const focusTerminal = () => {
|
|
const t = term
|
|
if (!t) return
|
|
t.focus()
|
|
setTimeout(() => t.textarea?.focus(), 0)
|
|
}
|
|
const handlePointerDown = () => {
|
|
const activeElement = document.activeElement
|
|
if (activeElement instanceof HTMLElement && activeElement !== container) {
|
|
activeElement.blur()
|
|
}
|
|
focusTerminal()
|
|
}
|
|
|
|
onMount(() => {
|
|
const run = async () => {
|
|
const loaded = await loadGhostty()
|
|
if (disposed) return
|
|
|
|
const mod = loaded.mod
|
|
const g = loaded.ghostty
|
|
|
|
const once = { value: false }
|
|
|
|
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
|
|
if (window.__OPENCODE__?.serverPassword) {
|
|
url.username = "opencode"
|
|
url.password = window.__OPENCODE__?.serverPassword
|
|
}
|
|
const socket = new WebSocket(url)
|
|
cleanups.push(() => {
|
|
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close()
|
|
})
|
|
if (disposed) {
|
|
cleanup()
|
|
return
|
|
}
|
|
ws = socket
|
|
|
|
const t = new mod.Terminal({
|
|
cursorBlink: true,
|
|
cursorStyle: "bar",
|
|
fontSize: 14,
|
|
fontFamily: monoFontFamily(settings.appearance.font()),
|
|
allowTransparency: true,
|
|
theme: terminalColors(),
|
|
scrollback: 10_000,
|
|
ghostty: g,
|
|
})
|
|
cleanups.push(() => t.dispose())
|
|
if (disposed) {
|
|
cleanup()
|
|
return
|
|
}
|
|
ghostty = g
|
|
term = t
|
|
|
|
const copy = () => {
|
|
const selection = t.getSelection()
|
|
if (!selection) return false
|
|
|
|
const body = document.body
|
|
if (body) {
|
|
const textarea = document.createElement("textarea")
|
|
textarea.value = selection
|
|
textarea.setAttribute("readonly", "")
|
|
textarea.style.position = "fixed"
|
|
textarea.style.opacity = "0"
|
|
body.appendChild(textarea)
|
|
textarea.select()
|
|
const copied = document.execCommand("copy")
|
|
body.removeChild(textarea)
|
|
if (copied) return true
|
|
}
|
|
|
|
const clipboard = navigator.clipboard
|
|
if (clipboard?.writeText) {
|
|
clipboard.writeText(selection).catch(() => {})
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
t.attachCustomKeyEventHandler((event) => {
|
|
const key = event.key.toLowerCase()
|
|
|
|
if (event.ctrlKey && event.shiftKey && !event.metaKey && key === "c") {
|
|
copy()
|
|
return true
|
|
}
|
|
|
|
if (event.metaKey && !event.ctrlKey && !event.altKey && key === "c") {
|
|
if (!t.hasSelection()) return true
|
|
copy()
|
|
return true
|
|
}
|
|
|
|
// allow for ctrl-` to toggle terminal in parent
|
|
if (event.ctrlKey && key === "`") {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
})
|
|
|
|
const fit = new mod.FitAddon()
|
|
const serializer = new SerializeAddon()
|
|
cleanups.push(() => (fit as unknown as { dispose?: VoidFunction }).dispose?.())
|
|
t.loadAddon(serializer)
|
|
t.loadAddon(fit)
|
|
fitAddon = fit
|
|
serializeAddon = serializer
|
|
|
|
t.open(container)
|
|
container.addEventListener("pointerdown", handlePointerDown)
|
|
cleanups.push(() => container.removeEventListener("pointerdown", handlePointerDown))
|
|
|
|
handleTextareaFocus = () => {
|
|
t.options.cursorBlink = true
|
|
}
|
|
handleTextareaBlur = () => {
|
|
t.options.cursorBlink = false
|
|
}
|
|
|
|
t.textarea?.addEventListener("focus", handleTextareaFocus)
|
|
t.textarea?.addEventListener("blur", handleTextareaBlur)
|
|
cleanups.push(() => t.textarea?.removeEventListener("focus", handleTextareaFocus))
|
|
cleanups.push(() => t.textarea?.removeEventListener("blur", handleTextareaBlur))
|
|
|
|
focusTerminal()
|
|
|
|
if (local.pty.buffer) {
|
|
if (local.pty.rows && local.pty.cols) {
|
|
t.resize(local.pty.cols, local.pty.rows)
|
|
}
|
|
t.write(local.pty.buffer, () => {
|
|
if (local.pty.scrollY) {
|
|
t.scrollToLine(local.pty.scrollY)
|
|
}
|
|
fitAddon.fit()
|
|
})
|
|
}
|
|
|
|
fit.observeResize()
|
|
handleResize = () => fit.fit()
|
|
window.addEventListener("resize", handleResize)
|
|
cleanups.push(() => window.removeEventListener("resize", handleResize))
|
|
const onResize = t.onResize(async (size) => {
|
|
if (socket.readyState === WebSocket.OPEN) {
|
|
await sdk.client.pty
|
|
.update({
|
|
ptyID: local.pty.id,
|
|
size: {
|
|
cols: size.cols,
|
|
rows: size.rows,
|
|
},
|
|
})
|
|
.catch(() => {})
|
|
}
|
|
})
|
|
cleanups.push(() => (onResize as unknown as { dispose?: VoidFunction }).dispose?.())
|
|
const onData = t.onData((data) => {
|
|
if (socket.readyState === WebSocket.OPEN) {
|
|
socket.send(data)
|
|
}
|
|
})
|
|
cleanups.push(() => (onData as unknown as { dispose?: VoidFunction }).dispose?.())
|
|
const onKey = t.onKey((key) => {
|
|
if (key.key == "Enter") {
|
|
props.onSubmit?.()
|
|
}
|
|
})
|
|
cleanups.push(() => (onKey as unknown as { dispose?: VoidFunction }).dispose?.())
|
|
// t.onScroll((ydisp) => {
|
|
// console.log("Scroll position:", ydisp)
|
|
// })
|
|
|
|
const handleOpen = () => {
|
|
local.onConnect?.()
|
|
sdk.client.pty
|
|
.update({
|
|
ptyID: local.pty.id,
|
|
size: {
|
|
cols: t.cols,
|
|
rows: t.rows,
|
|
},
|
|
})
|
|
.catch(() => {})
|
|
}
|
|
socket.addEventListener("open", handleOpen)
|
|
cleanups.push(() => socket.removeEventListener("open", handleOpen))
|
|
|
|
const handleMessage = (event: MessageEvent) => {
|
|
t.write(event.data)
|
|
}
|
|
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
|
|
const t = term
|
|
if (serializeAddon && props.onCleanup && t) {
|
|
const buffer = (() => {
|
|
try {
|
|
return serializeAddon.serialize()
|
|
} catch {
|
|
return ""
|
|
}
|
|
})()
|
|
props.onCleanup({
|
|
...local.pty,
|
|
buffer,
|
|
rows: t.rows,
|
|
cols: t.cols,
|
|
scrollY: t.getViewportY(),
|
|
})
|
|
}
|
|
|
|
cleanup()
|
|
})
|
|
|
|
return (
|
|
<div
|
|
ref={container}
|
|
data-component="terminal"
|
|
data-prevent-autofocus
|
|
tabIndex={-1}
|
|
style={{ "background-color": terminalColors().background }}
|
|
classList={{
|
|
...(local.classList ?? {}),
|
|
"select-text": true,
|
|
"size-full px-6 py-3 font-mono": true,
|
|
[local.class ?? ""]: !!local.class,
|
|
}}
|
|
{...others}
|
|
/>
|
|
)
|
|
}
|