Browse Source

fix(app): terminal replay (#12991)

Adam 2 weeks ago
parent
commit
3929f0b5bd

+ 29 - 46
packages/app/src/components/terminal.tsx

@@ -74,7 +74,9 @@ export const Terminal = (props: TerminalProps) => {
   let handleTextareaBlur: () => void
   let handleTextareaBlur: () => void
   let disposed = false
   let disposed = false
   const cleanups: VoidFunction[] = []
   const cleanups: VoidFunction[] = []
-  let tail = local.pty.tail ?? ""
+  const start =
+    typeof local.pty.cursor === "number" && Number.isSafeInteger(local.pty.cursor) ? local.pty.cursor : undefined
+  let cursor = start ?? 0
 
 
   const cleanup = () => {
   const cleanup = () => {
     if (!cleanups.length) return
     if (!cleanups.length) return
@@ -164,13 +166,16 @@ export const Terminal = (props: TerminalProps) => {
 
 
       const once = { value: false }
       const once = { value: false }
 
 
-      const url = new URL(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
+      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.protocol = url.protocol === "https:" ? "wss:" : "ws:"
       if (window.__OPENCODE__?.serverPassword) {
       if (window.__OPENCODE__?.serverPassword) {
         url.username = "opencode"
         url.username = "opencode"
         url.password = window.__OPENCODE__?.serverPassword
         url.password = window.__OPENCODE__?.serverPassword
       }
       }
       const socket = new WebSocket(url)
       const socket = new WebSocket(url)
+      socket.binaryType = "arraybuffer"
       cleanups.push(() => {
       cleanups.push(() => {
         if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close()
         if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close()
       })
       })
@@ -289,26 +294,6 @@ export const Terminal = (props: TerminalProps) => {
       handleResize = () => fit.fit()
       handleResize = () => fit.fit()
       window.addEventListener("resize", handleResize)
       window.addEventListener("resize", handleResize)
       cleanups.push(() => window.removeEventListener("resize", handleResize))
       cleanups.push(() => window.removeEventListener("resize", handleResize))
-      const limit = 16_384
-      const min = 32
-      const windowMs = 750
-      const seed = tail.length > limit ? tail.slice(-limit) : tail
-      let sync = seed.length >= min
-      let syncUntil = 0
-      const stopSync = () => {
-        sync = false
-        syncUntil = 0
-      }
-
-      const overlap = (data: string) => {
-        if (!seed) return 0
-        const max = Math.min(seed.length, data.length)
-        if (max < min) return 0
-        for (let i = max; i >= min; i--) {
-          if (seed.slice(-i) === data.slice(0, i)) return i
-        }
-        return 0
-      }
 
 
       const onResize = t.onResize(async (size) => {
       const onResize = t.onResize(async (size) => {
         if (socket.readyState === WebSocket.OPEN) {
         if (socket.readyState === WebSocket.OPEN) {
@@ -325,7 +310,6 @@ export const Terminal = (props: TerminalProps) => {
       })
       })
       cleanups.push(() => disposeIfDisposable(onResize))
       cleanups.push(() => disposeIfDisposable(onResize))
       const onData = t.onData((data) => {
       const onData = t.onData((data) => {
-        if (data) stopSync()
         if (socket.readyState === WebSocket.OPEN) {
         if (socket.readyState === WebSocket.OPEN) {
           socket.send(data)
           socket.send(data)
         }
         }
@@ -343,7 +327,6 @@ export const Terminal = (props: TerminalProps) => {
 
 
       const handleOpen = () => {
       const handleOpen = () => {
         local.onConnect?.()
         local.onConnect?.()
-        if (sync) syncUntil = Date.now() + windowMs
         sdk.client.pty
         sdk.client.pty
           .update({
           .update({
             ptyID: local.pty.id,
             ptyID: local.pty.id,
@@ -357,31 +340,31 @@ export const Terminal = (props: TerminalProps) => {
       socket.addEventListener("open", handleOpen)
       socket.addEventListener("open", handleOpen)
       cleanups.push(() => socket.removeEventListener("open", handleOpen))
       cleanups.push(() => socket.removeEventListener("open", handleOpen))
 
 
+      const decoder = new TextDecoder()
+
       const handleMessage = (event: MessageEvent) => {
       const handleMessage = (event: MessageEvent) => {
         if (disposed) return
         if (disposed) return
-        const data = typeof event.data === "string" ? event.data : ""
-        if (!data) return
-
-        const next = (() => {
-          if (!sync) return data
-          if (syncUntil && Date.now() > syncUntil) {
-            stopSync()
-            return data
-          }
-          const n = overlap(data)
-          if (!n) {
-            stopSync()
-            return data
+        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 {
+            // ignore
           }
           }
-          const trimmed = data.slice(n)
-          if (trimmed) stopSync()
-          return trimmed
-        })()
-
-        if (!next) return
+          return
+        }
 
 
-        t.write(next)
-        tail = next.length >= limit ? next.slice(-limit) : (tail + next).slice(-limit)
+        const data = typeof event.data === "string" ? event.data : ""
+        if (!data) return
+        t.write(data)
+        cursor += data.length
       }
       }
       socket.addEventListener("message", handleMessage)
       socket.addEventListener("message", handleMessage)
       cleanups.push(() => socket.removeEventListener("message", handleMessage))
       cleanups.push(() => socket.removeEventListener("message", handleMessage))
@@ -435,7 +418,7 @@ export const Terminal = (props: TerminalProps) => {
       props.onCleanup({
       props.onCleanup({
         ...local.pty,
         ...local.pty,
         buffer,
         buffer,
-        tail,
+        cursor,
         rows: t.rows,
         rows: t.rows,
         cols: t.cols,
         cols: t.cols,
         scrollY: t.getViewportY(),
         scrollY: t.getViewportY(),

+ 1 - 1
packages/app/src/context/terminal.tsx

@@ -13,7 +13,7 @@ export type LocalPTY = {
   cols?: number
   cols?: number
   buffer?: string
   buffer?: string
   scrollY?: number
   scrollY?: number
-  tail?: string
+  cursor?: number
 }
 }
 
 
 const WORKSPACE_KEY = "__workspace__"
 const WORKSPACE_KEY = "__workspace__"

+ 49 - 13
packages/opencode/src/pty/index.ts

@@ -15,6 +15,17 @@ export namespace Pty {
 
 
   const BUFFER_LIMIT = 1024 * 1024 * 2
   const BUFFER_LIMIT = 1024 * 1024 * 2
   const BUFFER_CHUNK = 64 * 1024
   const BUFFER_CHUNK = 64 * 1024
+  const encoder = new TextEncoder()
+
+  // WebSocket control frame: 0x00 + UTF-8 JSON (currently { cursor }).
+  const meta = (cursor: number) => {
+    const json = JSON.stringify({ cursor })
+    const bytes = encoder.encode(json)
+    const out = new Uint8Array(bytes.length + 1)
+    out[0] = 0
+    out.set(bytes, 1)
+    return out
+  }
 
 
   const pty = lazy(async () => {
   const pty = lazy(async () => {
     const { spawn } = await import("bun-pty")
     const { spawn } = await import("bun-pty")
@@ -68,6 +79,8 @@ export namespace Pty {
     info: Info
     info: Info
     process: IPty
     process: IPty
     buffer: string
     buffer: string
+    bufferCursor: number
+    cursor: number
     subscribers: Set<WSContext>
     subscribers: Set<WSContext>
   }
   }
 
 
@@ -139,23 +152,27 @@ export namespace Pty {
       info,
       info,
       process: ptyProcess,
       process: ptyProcess,
       buffer: "",
       buffer: "",
+      bufferCursor: 0,
+      cursor: 0,
       subscribers: new Set(),
       subscribers: new Set(),
     }
     }
     state().set(id, session)
     state().set(id, session)
     ptyProcess.onData((data) => {
     ptyProcess.onData((data) => {
-      let open = false
+      session.cursor += data.length
+
       for (const ws of session.subscribers) {
       for (const ws of session.subscribers) {
         if (ws.readyState !== 1) {
         if (ws.readyState !== 1) {
           session.subscribers.delete(ws)
           session.subscribers.delete(ws)
           continue
           continue
         }
         }
-        open = true
         ws.send(data)
         ws.send(data)
       }
       }
-      if (open) return
+
       session.buffer += data
       session.buffer += data
       if (session.buffer.length <= BUFFER_LIMIT) return
       if (session.buffer.length <= BUFFER_LIMIT) return
-      session.buffer = session.buffer.slice(-BUFFER_LIMIT)
+      const excess = session.buffer.length - BUFFER_LIMIT
+      session.buffer = session.buffer.slice(excess)
+      session.bufferCursor += excess
     })
     })
     ptyProcess.onExit(({ exitCode }) => {
     ptyProcess.onExit(({ exitCode }) => {
       log.info("session exited", { id, exitCode })
       log.info("session exited", { id, exitCode })
@@ -215,28 +232,47 @@ export namespace Pty {
     }
     }
   }
   }
 
 
-  export function connect(id: string, ws: WSContext) {
+  export function connect(id: string, ws: WSContext, cursor?: number) {
     const session = state().get(id)
     const session = state().get(id)
     if (!session) {
     if (!session) {
       ws.close()
       ws.close()
       return
       return
     }
     }
     log.info("client connected to session", { id })
     log.info("client connected to session", { id })
-    session.subscribers.add(ws)
-    if (session.buffer) {
-      const buffer = session.buffer.length <= BUFFER_LIMIT ? session.buffer : session.buffer.slice(-BUFFER_LIMIT)
-      session.buffer = ""
+
+    const start = session.bufferCursor
+    const end = session.cursor
+
+    const from =
+      cursor === -1 ? end : typeof cursor === "number" && Number.isSafeInteger(cursor) ? Math.max(0, cursor) : 0
+
+    const data = (() => {
+      if (!session.buffer) return ""
+      if (from >= end) return ""
+      const offset = Math.max(0, from - start)
+      if (offset >= session.buffer.length) return ""
+      return session.buffer.slice(offset)
+    })()
+
+    if (data) {
       try {
       try {
-        for (let i = 0; i < buffer.length; i += BUFFER_CHUNK) {
-          ws.send(buffer.slice(i, i + BUFFER_CHUNK))
+        for (let i = 0; i < data.length; i += BUFFER_CHUNK) {
+          ws.send(data.slice(i, i + BUFFER_CHUNK))
         }
         }
       } catch {
       } catch {
-        session.subscribers.delete(ws)
-        session.buffer = buffer
         ws.close()
         ws.close()
         return
         return
       }
       }
     }
     }
+
+    try {
+      ws.send(meta(end))
+    } catch {
+      ws.close()
+      return
+    }
+
+    session.subscribers.add(ws)
     return {
     return {
       onMessage: (message: string | ArrayBuffer) => {
       onMessage: (message: string | ArrayBuffer) => {
         session.process.write(String(message))
         session.process.write(String(message))

+ 8 - 1
packages/opencode/src/server/routes/pty.ts

@@ -151,11 +151,18 @@ export const PtyRoutes = lazy(() =>
       validator("param", z.object({ ptyID: z.string() })),
       validator("param", z.object({ ptyID: z.string() })),
       upgradeWebSocket((c) => {
       upgradeWebSocket((c) => {
         const id = c.req.param("ptyID")
         const id = c.req.param("ptyID")
+        const cursor = (() => {
+          const value = c.req.query("cursor")
+          if (!value) return
+          const parsed = Number(value)
+          if (!Number.isSafeInteger(parsed) || parsed < -1) return
+          return parsed
+        })()
         let handler: ReturnType<typeof Pty.connect>
         let handler: ReturnType<typeof Pty.connect>
         if (!Pty.get(id)) throw new Error("Session not found")
         if (!Pty.get(id)) throw new Error("Session not found")
         return {
         return {
           onOpen(_event, ws) {
           onOpen(_event, ws) {
-            handler = Pty.connect(id, ws)
+            handler = Pty.connect(id, ws, cursor)
           },
           },
           onMessage(event) {
           onMessage(event) {
             handler?.onMessage(String(event.data))
             handler?.onMessage(String(event.data))