summaryrefslogtreecommitdiffhomepage
path: root/packages/desktop/src/components
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-04 20:32:08 -0600
committerAdam <[email protected]>2025-12-04 20:32:08 -0600
commit09f522f0aa698be60c954e58bb7eee0e460c4439 (patch)
tree8b936f4ab3cbafab391551e898412d1617dbd66b /packages/desktop/src/components
parentd82bd430f68b8227a93c39e0b7b617c9463ceea8 (diff)
downloadopencode-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.tsx151
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}
+ />
+ )
+}