Skip to content

Commit ec2cf2e

Browse files
adamdotdevinBryceRyan
authored andcommitted
fix(app): terminal replay (anomalyco#12991)
1 parent aa9efca commit ec2cf2e

File tree

4 files changed

+87
-61
lines changed

4 files changed

+87
-61
lines changed

packages/app/src/components/terminal.tsx

Lines changed: 29 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,9 @@ export const Terminal = (props: TerminalProps) => {
7474
let handleTextareaBlur: () => void
7575
let disposed = false
7676
const cleanups: VoidFunction[] = []
77-
let tail = local.pty.tail ?? ""
77+
const start =
78+
typeof local.pty.cursor === "number" && Number.isSafeInteger(local.pty.cursor) ? local.pty.cursor : undefined
79+
let cursor = start ?? 0
7880

7981
const cleanup = () => {
8082
if (!cleanups.length) return
@@ -164,13 +166,16 @@ export const Terminal = (props: TerminalProps) => {
164166

165167
const once = { value: false }
166168

167-
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
169+
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`)
170+
url.searchParams.set("directory", sdk.directory)
171+
url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
168172
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
169173
if (window.__OPENCODE__?.serverPassword) {
170174
url.username = "opencode"
171175
url.password = window.__OPENCODE__?.serverPassword
172176
}
173177
const socket = new WebSocket(url)
178+
socket.binaryType = "arraybuffer"
174179
cleanups.push(() => {
175180
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close()
176181
})
@@ -289,26 +294,6 @@ export const Terminal = (props: TerminalProps) => {
289294
handleResize = () => fit.fit()
290295
window.addEventListener("resize", handleResize)
291296
cleanups.push(() => window.removeEventListener("resize", handleResize))
292-
const limit = 16_384
293-
const min = 32
294-
const windowMs = 750
295-
const seed = tail.length > limit ? tail.slice(-limit) : tail
296-
let sync = seed.length >= min
297-
let syncUntil = 0
298-
const stopSync = () => {
299-
sync = false
300-
syncUntil = 0
301-
}
302-
303-
const overlap = (data: string) => {
304-
if (!seed) return 0
305-
const max = Math.min(seed.length, data.length)
306-
if (max < min) return 0
307-
for (let i = max; i >= min; i--) {
308-
if (seed.slice(-i) === data.slice(0, i)) return i
309-
}
310-
return 0
311-
}
312297

313298
const onResize = t.onResize(async (size) => {
314299
if (socket.readyState === WebSocket.OPEN) {
@@ -325,7 +310,6 @@ export const Terminal = (props: TerminalProps) => {
325310
})
326311
cleanups.push(() => disposeIfDisposable(onResize))
327312
const onData = t.onData((data) => {
328-
if (data) stopSync()
329313
if (socket.readyState === WebSocket.OPEN) {
330314
socket.send(data)
331315
}
@@ -343,7 +327,6 @@ export const Terminal = (props: TerminalProps) => {
343327

344328
const handleOpen = () => {
345329
local.onConnect?.()
346-
if (sync) syncUntil = Date.now() + windowMs
347330
sdk.client.pty
348331
.update({
349332
ptyID: local.pty.id,
@@ -357,31 +340,31 @@ export const Terminal = (props: TerminalProps) => {
357340
socket.addEventListener("open", handleOpen)
358341
cleanups.push(() => socket.removeEventListener("open", handleOpen))
359342

343+
const decoder = new TextDecoder()
344+
360345
const handleMessage = (event: MessageEvent) => {
361346
if (disposed) return
362-
const data = typeof event.data === "string" ? event.data : ""
363-
if (!data) return
364-
365-
const next = (() => {
366-
if (!sync) return data
367-
if (syncUntil && Date.now() > syncUntil) {
368-
stopSync()
369-
return data
370-
}
371-
const n = overlap(data)
372-
if (!n) {
373-
stopSync()
374-
return data
347+
if (event.data instanceof ArrayBuffer) {
348+
// WebSocket control frame: 0x00 + UTF-8 JSON (currently { cursor }).
349+
const bytes = new Uint8Array(event.data)
350+
if (bytes[0] !== 0) return
351+
const json = decoder.decode(bytes.subarray(1))
352+
try {
353+
const meta = JSON.parse(json) as { cursor?: unknown }
354+
const next = meta?.cursor
355+
if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) {
356+
cursor = next
357+
}
358+
} catch {
359+
// ignore
375360
}
376-
const trimmed = data.slice(n)
377-
if (trimmed) stopSync()
378-
return trimmed
379-
})()
380-
381-
if (!next) return
361+
return
362+
}
382363

383-
t.write(next)
384-
tail = next.length >= limit ? next.slice(-limit) : (tail + next).slice(-limit)
364+
const data = typeof event.data === "string" ? event.data : ""
365+
if (!data) return
366+
t.write(data)
367+
cursor += data.length
385368
}
386369
socket.addEventListener("message", handleMessage)
387370
cleanups.push(() => socket.removeEventListener("message", handleMessage))
@@ -435,7 +418,7 @@ export const Terminal = (props: TerminalProps) => {
435418
props.onCleanup({
436419
...local.pty,
437420
buffer,
438-
tail,
421+
cursor,
439422
rows: t.rows,
440423
cols: t.cols,
441424
scrollY: t.getViewportY(),

packages/app/src/context/terminal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export type LocalPTY = {
1313
cols?: number
1414
buffer?: string
1515
scrollY?: number
16-
tail?: string
16+
cursor?: number
1717
}
1818

1919
const WORKSPACE_KEY = "__workspace__"

packages/opencode/src/pty/index.ts

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,17 @@ export namespace Pty {
1515

1616
const BUFFER_LIMIT = 1024 * 1024 * 2
1717
const BUFFER_CHUNK = 64 * 1024
18+
const encoder = new TextEncoder()
19+
20+
// WebSocket control frame: 0x00 + UTF-8 JSON (currently { cursor }).
21+
const meta = (cursor: number) => {
22+
const json = JSON.stringify({ cursor })
23+
const bytes = encoder.encode(json)
24+
const out = new Uint8Array(bytes.length + 1)
25+
out[0] = 0
26+
out.set(bytes, 1)
27+
return out
28+
}
1829

1930
const pty = lazy(async () => {
2031
const { spawn } = await import("bun-pty")
@@ -68,6 +79,8 @@ export namespace Pty {
6879
info: Info
6980
process: IPty
7081
buffer: string
82+
bufferCursor: number
83+
cursor: number
7184
subscribers: Set<WSContext>
7285
}
7386

@@ -139,23 +152,27 @@ export namespace Pty {
139152
info,
140153
process: ptyProcess,
141154
buffer: "",
155+
bufferCursor: 0,
156+
cursor: 0,
142157
subscribers: new Set(),
143158
}
144159
state().set(id, session)
145160
ptyProcess.onData((data) => {
146-
let open = false
161+
session.cursor += data.length
162+
147163
for (const ws of session.subscribers) {
148164
if (ws.readyState !== 1) {
149165
session.subscribers.delete(ws)
150166
continue
151167
}
152-
open = true
153168
ws.send(data)
154169
}
155-
if (open) return
170+
156171
session.buffer += data
157172
if (session.buffer.length <= BUFFER_LIMIT) return
158-
session.buffer = session.buffer.slice(-BUFFER_LIMIT)
173+
const excess = session.buffer.length - BUFFER_LIMIT
174+
session.buffer = session.buffer.slice(excess)
175+
session.bufferCursor += excess
159176
})
160177
ptyProcess.onExit(({ exitCode }) => {
161178
log.info("session exited", { id, exitCode })
@@ -215,28 +232,47 @@ export namespace Pty {
215232
}
216233
}
217234

218-
export function connect(id: string, ws: WSContext) {
235+
export function connect(id: string, ws: WSContext, cursor?: number) {
219236
const session = state().get(id)
220237
if (!session) {
221238
ws.close()
222239
return
223240
}
224241
log.info("client connected to session", { id })
225-
session.subscribers.add(ws)
226-
if (session.buffer) {
227-
const buffer = session.buffer.length <= BUFFER_LIMIT ? session.buffer : session.buffer.slice(-BUFFER_LIMIT)
228-
session.buffer = ""
242+
243+
const start = session.bufferCursor
244+
const end = session.cursor
245+
246+
const from =
247+
cursor === -1 ? end : typeof cursor === "number" && Number.isSafeInteger(cursor) ? Math.max(0, cursor) : 0
248+
249+
const data = (() => {
250+
if (!session.buffer) return ""
251+
if (from >= end) return ""
252+
const offset = Math.max(0, from - start)
253+
if (offset >= session.buffer.length) return ""
254+
return session.buffer.slice(offset)
255+
})()
256+
257+
if (data) {
229258
try {
230-
for (let i = 0; i < buffer.length; i += BUFFER_CHUNK) {
231-
ws.send(buffer.slice(i, i + BUFFER_CHUNK))
259+
for (let i = 0; i < data.length; i += BUFFER_CHUNK) {
260+
ws.send(data.slice(i, i + BUFFER_CHUNK))
232261
}
233262
} catch {
234-
session.subscribers.delete(ws)
235-
session.buffer = buffer
236263
ws.close()
237264
return
238265
}
239266
}
267+
268+
try {
269+
ws.send(meta(end))
270+
} catch {
271+
ws.close()
272+
return
273+
}
274+
275+
session.subscribers.add(ws)
240276
return {
241277
onMessage: (message: string | ArrayBuffer) => {
242278
session.process.write(String(message))

packages/opencode/src/server/routes/pty.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,11 +151,18 @@ export const PtyRoutes = lazy(() =>
151151
validator("param", z.object({ ptyID: z.string() })),
152152
upgradeWebSocket((c) => {
153153
const id = c.req.param("ptyID")
154+
const cursor = (() => {
155+
const value = c.req.query("cursor")
156+
if (!value) return
157+
const parsed = Number(value)
158+
if (!Number.isSafeInteger(parsed) || parsed < -1) return
159+
return parsed
160+
})()
154161
let handler: ReturnType<typeof Pty.connect>
155162
if (!Pty.get(id)) throw new Error("Session not found")
156163
return {
157164
onOpen(_event, ws) {
158-
handler = Pty.connect(id, ws)
165+
handler = Pty.connect(id, ws, cursor)
159166
},
160167
onMessage(event) {
161168
handler?.onMessage(String(event.data))

0 commit comments

Comments
 (0)