summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-10 10:15:19 -0600
committerGitHub <[email protected]>2026-02-10 10:15:19 -0600
commit3929f0b5bd5535fc601b8ce9092929af1adb6629 (patch)
tree62aaa849e84ab62b6b960b8378661dfd10dd681e
parent6f5dfe125aaa82514318ea39d0ae443da37612f6 (diff)
downloadopencode-3929f0b5bd5535fc601b8ce9092929af1adb6629.tar.gz
opencode-3929f0b5bd5535fc601b8ce9092929af1adb6629.zip
fix(app): terminal replay (#12991)
-rw-r--r--packages/app/src/components/terminal.tsx75
-rw-r--r--packages/app/src/context/terminal.tsx2
-rw-r--r--packages/opencode/src/pty/index.ts62
-rw-r--r--packages/opencode/src/server/routes/pty.ts9
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
- 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)
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<WSContext>
}
@@ -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<typeof Pty.connect>
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))