fix(app): terminal replay (#12991)
This commit is contained in:
@@ -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
|
||||||
|
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 : ""
|
const data = typeof event.data === "string" ? event.data : ""
|
||||||
if (!data) return
|
if (!data) return
|
||||||
|
t.write(data)
|
||||||
const next = (() => {
|
cursor += data.length
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
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(),
|
||||||
|
|||||||
@@ -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__"
|
||||||
|
|||||||
@@ -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 start = session.bufferCursor
|
||||||
const buffer = session.buffer.length <= BUFFER_LIMIT ? session.buffer : session.buffer.slice(-BUFFER_LIMIT)
|
const end = session.cursor
|
||||||
session.buffer = ""
|
|
||||||
|
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) {
|
for (let i = 0; i < data.length; i += BUFFER_CHUNK) {
|
||||||
ws.send(buffer.slice(i, 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))
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user