Просмотр исходного кода

fix(app): terminal long-running processes

Adam 1 месяц назад
Родитель
Сommit
cb8533ef5b

+ 55 - 42
packages/app/src/components/terminal.tsx

@@ -36,12 +36,14 @@ export const Terminal = (props: TerminalProps) => {
   const theme = useTheme()
   let container!: HTMLDivElement
   const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"])
-  let ws: WebSocket
-  let term: Term
+  let ws: WebSocket | undefined
+  let term: Term | undefined
   let ghostty: Ghostty
   let serializeAddon: SerializeAddon
   let fitAddon: FitAddon
   let handleResize: () => void
+  let reconnect: number | undefined
+  let disposed = false
 
   const getTerminalColors = (): TerminalColors => {
     const mode = theme.mode()
@@ -76,22 +78,24 @@ export const Terminal = (props: TerminalProps) => {
     if (!term || !term.hasSelection()) return false
     const selection = term.getSelection()
     if (!selection) return false
+    if (document.body) {
+      const textarea = document.createElement("textarea")
+      textarea.value = selection
+      textarea.setAttribute("readonly", "")
+      textarea.style.position = "fixed"
+      textarea.style.opacity = "0"
+      document.body.appendChild(textarea)
+      textarea.select()
+      const copied = document.execCommand("copy")
+      document.body.removeChild(textarea)
+      if (copied) return true
+    }
     const clipboard = navigator.clipboard
     if (clipboard?.writeText) {
       clipboard.writeText(selection).catch(() => {})
       return true
     }
-    if (!document.body) return false
-    const textarea = document.createElement("textarea")
-    textarea.value = selection
-    textarea.setAttribute("readonly", "")
-    textarea.style.position = "fixed"
-    textarea.style.opacity = "0"
-    document.body.appendChild(textarea)
-    textarea.select()
-    const copied = document.execCommand("copy")
-    document.body.removeChild(textarea)
-    return copied
+    return false
   }
   const handlePointerDown = () => {
     const activeElement = document.activeElement
@@ -104,8 +108,12 @@ export const Terminal = (props: TerminalProps) => {
   onMount(async () => {
     ghostty = await Ghostty.load()
 
-    ws = new WebSocket(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
-    term = new Term({
+    const socket = new WebSocket(
+      sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`,
+    )
+    ws = socket
+
+    const t = new Term({
       cursorBlink: true,
       fontSize: 14,
       fontFamily: "IBM Plex Mono, monospace",
@@ -114,7 +122,9 @@ export const Terminal = (props: TerminalProps) => {
       scrollback: 10_000,
       ghostty,
     })
-    term.attachCustomKeyEventHandler((event) => {
+    term = t
+
+    t.attachCustomKeyEventHandler((event) => {
       const key = event.key.toLowerCase()
       if (key === "c") {
         const macCopy = event.metaKey && !event.ctrlKey && !event.altKey
@@ -134,21 +144,21 @@ export const Terminal = (props: TerminalProps) => {
 
     fitAddon = new FitAddon()
     serializeAddon = new SerializeAddon()
-    term.loadAddon(serializeAddon)
-    term.loadAddon(fitAddon)
+    t.loadAddon(serializeAddon)
+    t.loadAddon(fitAddon)
 
-    term.open(container)
+    t.open(container)
     container.addEventListener("pointerdown", handlePointerDown)
     focusTerminal()
 
     if (local.pty.buffer) {
       if (local.pty.rows && local.pty.cols) {
-        term.resize(local.pty.cols, local.pty.rows)
+        t.resize(local.pty.cols, local.pty.rows)
       }
-      term.reset()
-      term.write(local.pty.buffer, () => {
+      t.reset()
+      t.write(local.pty.buffer, () => {
         if (local.pty.scrollY) {
-          term.scrollToLine(local.pty.scrollY)
+          t.scrollToLine(local.pty.scrollY)
         }
         fitAddon.fit()
       })
@@ -157,8 +167,8 @@ export const Terminal = (props: TerminalProps) => {
     fitAddon.observeResize()
     handleResize = () => fitAddon.fit()
     window.addEventListener("resize", handleResize)
-    term.onResize(async (size) => {
-      if (ws && ws.readyState === WebSocket.OPEN) {
+    t.onResize(async (size) => {
+      if (socket.readyState === WebSocket.OPEN) {
         await sdk.client.pty
           .update({
             ptyID: local.pty.id,
@@ -170,39 +180,39 @@ export const Terminal = (props: TerminalProps) => {
           .catch(() => {})
       }
     })
-    term.onData((data) => {
-      if (ws && ws.readyState === WebSocket.OPEN) {
-        ws.send(data)
+    t.onData((data) => {
+      if (socket.readyState === WebSocket.OPEN) {
+        socket.send(data)
       }
     })
-    term.onKey((key) => {
+    t.onKey((key) => {
       if (key.key == "Enter") {
         props.onSubmit?.()
       }
     })
-    // term.onScroll((ydisp) => {
+    // t.onScroll((ydisp) => {
     // console.log("Scroll position:", ydisp)
     // })
-    ws.addEventListener("open", () => {
+    socket.addEventListener("open", () => {
       console.log("WebSocket connected")
       sdk.client.pty
         .update({
           ptyID: local.pty.id,
           size: {
-            cols: term.cols,
-            rows: term.rows,
+            cols: t.cols,
+            rows: t.rows,
           },
         })
         .catch(() => {})
     })
-    ws.addEventListener("message", (event) => {
-      term.write(event.data)
+    socket.addEventListener("message", (event) => {
+      t.write(event.data)
     })
-    ws.addEventListener("error", (error) => {
+    socket.addEventListener("error", (error) => {
       console.error("WebSocket error:", error)
       props.onConnectError?.(error)
     })
-    ws.addEventListener("close", () => {
+    socket.addEventListener("close", () => {
       console.log("WebSocket disconnected")
     })
   })
@@ -212,18 +222,21 @@ export const Terminal = (props: TerminalProps) => {
       window.removeEventListener("resize", handleResize)
     }
     container.removeEventListener("pointerdown", handlePointerDown)
-    if (serializeAddon && props.onCleanup) {
+
+    const t = term
+    if (serializeAddon && props.onCleanup && t) {
       const buffer = serializeAddon.serialize()
       props.onCleanup({
         ...local.pty,
         buffer,
-        rows: term.rows,
-        cols: term.cols,
-        scrollY: term.getViewportY(),
+        rows: t.rows,
+        cols: t.cols,
+        scrollY: t.getViewportY(),
       })
     }
+
     ws?.close()
-    term?.dispose()
+    t?.dispose()
   })
 
   return (

+ 2 - 2
packages/app/src/pages/session.tsx

@@ -863,9 +863,9 @@ export default function Page() {
                                 data-message-id={message.id}
                                 classList={{
                                   "min-w-0 w-full max-w-full": true,
-                                  "last:min-h-[calc(100vh-13.5rem)] md:last:min-h-[calc(100vh-14.5rem)]":
+                                  "last:min-h-[calc(100vh-5.5rem-var(--prompt-height,8rem)-32px)] md:last:min-h-[calc(100vh-4.5rem-var(--prompt-height,10rem)-32px)]":
                                     platform.platform !== "desktop",
-                                  "last:min-h-[calc(100vh-15rem)] md:last:min-h-[calc(100vh-16rem)]":
+                                  "last:min-h-[calc(100vh-7rem-var(--prompt-height,8rem)-32px)] md:last:min-h-[calc(100vh-6rem-var(--prompt-height,10rem)-32px)]":
                                     platform.platform === "desktop",
                                 }}
                               >

+ 24 - 7
packages/opencode/src/pty/index.ts

@@ -12,6 +12,9 @@ import { Shell } from "@/shell/shell"
 export namespace Pty {
   const log = Log.create({ service: "pty" })
 
+  const BUFFER_LIMIT = 1024 * 1024 * 2
+  const BUFFER_CHUNK = 64 * 1024
+
   const pty = lazy(async () => {
     const { spawn } = await import("bun-pty")
     return spawn
@@ -126,15 +129,19 @@ export namespace Pty {
     }
     state().set(id, session)
     ptyProcess.onData((data) => {
-      if (session.subscribers.size === 0) {
-        session.buffer += data
-        return
-      }
+      let open = false
       for (const ws of session.subscribers) {
-        if (ws.readyState === 1) {
-          ws.send(data)
+        if (ws.readyState !== 1) {
+          session.subscribers.delete(ws)
+          continue
         }
+        open = true
+        ws.send(data)
       }
+      if (open) return
+      session.buffer += data
+      if (session.buffer.length <= BUFFER_LIMIT) return
+      session.buffer = session.buffer.slice(-BUFFER_LIMIT)
     })
     ptyProcess.onExit(({ exitCode }) => {
       log.info("session exited", { id, exitCode })
@@ -196,8 +203,18 @@ export namespace Pty {
     log.info("client connected to session", { id })
     session.subscribers.add(ws)
     if (session.buffer) {
-      ws.send(session.buffer)
+      const buffer = session.buffer.length <= BUFFER_LIMIT ? session.buffer : session.buffer.slice(-BUFFER_LIMIT)
       session.buffer = ""
+      try {
+        for (let i = 0; i < buffer.length; i += BUFFER_CHUNK) {
+          ws.send(buffer.slice(i, i + BUFFER_CHUNK))
+        }
+      } catch {
+        session.subscribers.delete(ws)
+        session.buffer = buffer
+        ws.close()
+        return
+      }
     }
     return {
       onMessage: (message: string | ArrayBuffer) => {