diff options
| author | Adam <[email protected]> | 2025-12-04 20:32:08 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-12-04 20:32:08 -0600 |
| commit | 09f522f0aa698be60c954e58bb7eee0e460c4439 (patch) | |
| tree | 8b936f4ab3cbafab391551e898412d1617dbd66b /packages/desktop/src/components | |
| parent | d82bd430f68b8227a93c39e0b7b617c9463ceea8 (diff) | |
| download | opencode-09f522f0aa698be60c954e58bb7eee0e460c4439.tar.gz opencode-09f522f0aa698be60c954e58bb7eee0e460c4439.zip | |
Reapply "feat(desktop): terminal pane (#5081)"
This reverts commit f9dcd979364acc5172fd0044c1c8b04dcaec9229.
Diffstat (limited to 'packages/desktop/src/components')
| -rw-r--r-- | packages/desktop/src/components/terminal.tsx | 151 |
1 files changed, 151 insertions, 0 deletions
diff --git a/packages/desktop/src/components/terminal.tsx b/packages/desktop/src/components/terminal.tsx new file mode 100644 index 000000000..49a45a432 --- /dev/null +++ b/packages/desktop/src/components/terminal.tsx @@ -0,0 +1,151 @@ +import { init, Terminal as Term, FitAddon } from "ghostty-web" +import { ComponentProps, onCleanup, onMount, splitProps } from "solid-js" +import { createReconnectingWS, ReconnectingWebSocket } from "@solid-primitives/websocket" +import { useSDK } from "@/context/sdk" +import { SerializeAddon } from "@/addons/serialize" +import { LocalPTY } from "@/context/session" + +await init() + +export interface TerminalProps extends ComponentProps<"div"> { + pty: LocalPTY + onSubmit?: () => void + onCleanup?: (pty: LocalPTY) => void +} + +export const Terminal = (props: TerminalProps) => { + const sdk = useSDK() + let container!: HTMLDivElement + const [local, others] = splitProps(props, ["pty", "class", "classList"]) + let ws: ReconnectingWebSocket + let term: Term + let serializeAddon: SerializeAddon + let fitAddon: FitAddon + + onMount(async () => { + ws = createReconnectingWS(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`) + term = new Term({ + cursorBlink: true, + fontSize: 14, + fontFamily: "TX-02, monospace", + allowTransparency: true, + theme: { + background: "#191515", + foreground: "#d4d4d4", + }, + scrollback: 10_000, + }) + term.attachCustomKeyEventHandler((event) => { + // allow for ctrl-` to toggle terminal in parent + if (event.ctrlKey && event.key.toLowerCase() === "`") { + event.preventDefault() + return true + } + return false + }) + + fitAddon = new FitAddon() + serializeAddon = new SerializeAddon() + term.loadAddon(serializeAddon) + term.loadAddon(fitAddon) + + term.open(container) + + if (local.pty.buffer) { + const originalSize = { cols: term.cols, rows: term.rows } + let resized = false + if (local.pty.rows && local.pty.cols) { + term.resize(local.pty.cols, local.pty.rows) + resized = true + } + term.write(local.pty.buffer) + if (local.pty.scrollY) { + term.scrollToLine(local.pty.scrollY) + } + if (resized) { + term.resize(originalSize.cols, originalSize.rows) + } + } + + container.focus() + + fitAddon.fit() + fitAddon.observeResize() + window.addEventListener("resize", () => fitAddon.fit()) + term.onResize(async (size) => { + if (ws && ws.readyState === WebSocket.OPEN) { + await sdk.client.pty.update({ + path: { id: local.pty.id }, + body: { + size: { + cols: size.cols, + rows: size.rows, + }, + }, + }) + } + }) + term.onData((data) => { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(data) + } + }) + term.onKey((key) => { + if (key.key == "Enter") { + props.onSubmit?.() + } + }) + // term.onScroll((ydisp) => { + // console.log("Scroll position:", ydisp) + // }) + ws.addEventListener("open", () => { + console.log("WebSocket connected") + sdk.client.pty.update({ + path: { id: local.pty.id }, + body: { + size: { + cols: term.cols, + rows: term.rows, + }, + }, + }) + }) + ws.addEventListener("message", (event) => { + term.write(event.data) + }) + ws.addEventListener("error", (error) => { + console.error("WebSocket error:", error) + }) + ws.addEventListener("close", () => { + console.log("WebSocket disconnected") + }) + }) + + onCleanup(() => { + if (serializeAddon && props.onCleanup) { + const buffer = serializeAddon.serialize() + props.onCleanup({ + ...local.pty, + buffer, + rows: term.rows, + cols: term.cols, + scrollY: term.getViewportY(), + }) + } + ws?.close() + term?.dispose() + }) + + return ( + <div + ref={container} + data-component="terminal" + classList={{ + ...(local.classList ?? {}), + "size-full px-6 py-3 font-mono": true, + [local.class ?? ""]: !!local.class, + }} + {...others} + /> + ) +} |
