From 3929f0b5bd5535fc601b8ce9092929af1adb6629 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Tue, 10 Feb 2026 10:15:19 -0600 Subject: [PATCH] fix(app): terminal replay (#12991) --- packages/app/src/components/terminal.tsx | 75 +++++++++------------- packages/app/src/context/terminal.tsx | 2 +- packages/opencode/src/pty/index.ts | 62 ++++++++++++++---- packages/opencode/src/server/routes/pty.ts | 9 ++- 4 files changed, 87 insertions(+), 61 deletions(-) diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 3baafe511..97491d0d3 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -74,7 +74,9 @@ export const Terminal = (props: TerminalProps) => { let handleTextareaBlur: () => void let disposed = false 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 = () => { if (!cleanups.length) return @@ -164,13 +166,16 @@ export const Terminal = (props: TerminalProps) => { 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:" if (window.__OPENCODE__?.serverPassword) { url.username = "opencode" url.password = window.__OPENCODE__?.serverPassword } const socket = new WebSocket(url) + socket.binaryType = "arraybuffer" cleanups.push(() => { if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close() }) @@ -289,26 +294,6 @@ export const Terminal = (props: TerminalProps) => { handleResize = () => fit.fit() window.addEventListener("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) => { if (socket.readyState === WebSocket.OPEN) { @@ -325,7 +310,6 @@ export const Terminal = (props: TerminalProps) => { }) cleanups.push(() => disposeIfDisposable(onResize)) const onData = t.onData((data) => { - if (data) stopSync() if (socket.readyState === WebSocket.OPEN) { socket.send(data) } @@ -343,7 +327,6 @@ export const Terminal = (props: TerminalProps) => { const handleOpen = () => { local.onConnect?.() - if (sync) syncUntil = Date.now() + windowMs sdk.client.pty .update({ ptyID: local.pty.id, @@ -357,31 +340,31 @@ export const Terminal = (props: TerminalProps) => { socket.addEventListener("open", handleOpen) cleanups.push(() => socket.removeEventListener("open", handleOpen)) + const decoder = new TextDecoder() + const handleMessage = (event: MessageEvent) => { if (disposed) return + 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 + } + 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 - } - const trimmed = data.slice(n) - if (trimmed) stopSync() - return trimmed - })() - - if (!next) return - - t.write(next) - tail = next.length >= limit ? next.slice(-limit) : (tail + next).slice(-limit) + t.write(data) + cursor += data.length } socket.addEventListener("message", handleMessage) cleanups.push(() => socket.removeEventListener("message", handleMessage)) @@ -435,7 +418,7 @@ export const Terminal = (props: TerminalProps) => { props.onCleanup({ ...local.pty, buffer, - tail, + cursor, rows: t.rows, cols: t.cols, scrollY: t.getViewportY(), diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index 76e8cf0f7..f0f184f8b 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -13,7 +13,7 @@ export type LocalPTY = { cols?: number buffer?: string scrollY?: number - tail?: string + cursor?: number } const WORKSPACE_KEY = "__workspace__" diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index a27ee9a74..7a07e3ef3 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -15,6 +15,17 @@ export namespace Pty { const BUFFER_LIMIT = 1024 * 1024 * 2 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 { spawn } = await import("bun-pty") @@ -68,6 +79,8 @@ export namespace Pty { info: Info process: IPty buffer: string + bufferCursor: number + cursor: number subscribers: Set } @@ -139,23 +152,27 @@ export namespace Pty { info, process: ptyProcess, buffer: "", + bufferCursor: 0, + cursor: 0, subscribers: new Set(), } state().set(id, session) ptyProcess.onData((data) => { - let open = false + session.cursor += data.length + for (const ws of session.subscribers) { 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) + const excess = session.buffer.length - BUFFER_LIMIT + session.buffer = session.buffer.slice(excess) + session.bufferCursor += excess }) ptyProcess.onExit(({ 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) if (!session) { ws.close() return } 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 { - 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 { - session.subscribers.delete(ws) - session.buffer = buffer ws.close() return } } + + try { + ws.send(meta(end)) + } catch { + ws.close() + return + } + + session.subscribers.add(ws) return { onMessage: (message: string | ArrayBuffer) => { session.process.write(String(message)) diff --git a/packages/opencode/src/server/routes/pty.ts b/packages/opencode/src/server/routes/pty.ts index 1ac6cf797..1085c1175 100644 --- a/packages/opencode/src/server/routes/pty.ts +++ b/packages/opencode/src/server/routes/pty.ts @@ -151,11 +151,18 @@ export const PtyRoutes = lazy(() => validator("param", z.object({ ptyID: z.string() })), upgradeWebSocket((c) => { 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 if (!Pty.get(id)) throw new Error("Session not found") return { onOpen(_event, ws) { - handler = Pty.connect(id, ws) + handler = Pty.connect(id, ws, cursor) }, onMessage(event) { handler?.onMessage(String(event.data))