mirror of
https://github.com/stablyai/orca.git
synced 2026-10-14 00:02:42 +00:00
fix mobile terminal geometry sync
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -34,6 +34,7 @@ export default function SessionScreen() {
|
||||
const termRef = useRef<TerminalWebViewHandle>(null)
|
||||
const unsubRef = useRef<(() => void) | null>(null)
|
||||
const activeHandleRef = useRef<string | null>(null)
|
||||
const subscribeSeqRef = useRef(0)
|
||||
const terminalSizeRef = useRef({ cols: 80, rows: 24 })
|
||||
|
||||
useEffect(() => {
|
||||
@@ -79,33 +80,58 @@ export default function SessionScreen() {
|
||||
if (!client) return
|
||||
|
||||
termRef.current?.clear()
|
||||
const seq = subscribeSeqRef.current + 1
|
||||
subscribeSeqRef.current = seq
|
||||
|
||||
const unsub = client.subscribe('terminal.subscribe', { terminal: handle }, (result) => {
|
||||
const data = result as Record<string, unknown>
|
||||
if (data.type === 'scrollback') {
|
||||
// Why: init xterm at the desktop's exact cols/rows so escape
|
||||
// codes with absolute cursor positioning render correctly.
|
||||
// CSS transform: scale() in TerminalWebView shrinks the canvas
|
||||
// to fit the phone viewport, producing a 1:1 miniature.
|
||||
const cols = (data.cols as number) || 80
|
||||
const rows = (data.rows as number) || 24
|
||||
terminalSizeRef.current = { cols, rows }
|
||||
termRef.current?.init(cols, rows)
|
||||
// Why: prefer serialized xterm buffer (ANSI escape string that
|
||||
// reconstructs the exact screen state) over line-based tail
|
||||
// because TUI apps use absolute cursor positioning that only
|
||||
// works at the original terminal dimensions.
|
||||
if (typeof data.serialized === 'string' && data.serialized.length > 0) {
|
||||
termRef.current?.write(data.serialized)
|
||||
} else {
|
||||
writeLines(data.lines as string[] | string | undefined)
|
||||
}
|
||||
} else if (data.type === 'data') {
|
||||
termRef.current?.write(data.chunk as string)
|
||||
void (async () => {
|
||||
try {
|
||||
// Why: hidden/new desktop panes can still be at their 80x24 spawn
|
||||
// geometry. Focusing first lets the desktop renderer fit xterm,
|
||||
// resize the PTY, and register a serializer before mobile subscribes.
|
||||
await client.sendRequest('terminal.focus', { terminal: handle })
|
||||
await new Promise((resolve) => setTimeout(resolve, 150))
|
||||
} catch {
|
||||
// Continue with best-effort streaming if desktop focus is unavailable.
|
||||
}
|
||||
})
|
||||
|
||||
unsubRef.current = unsub
|
||||
if (subscribeSeqRef.current !== seq || activeHandleRef.current !== handle) {
|
||||
return
|
||||
}
|
||||
|
||||
const unsub = client.subscribe('terminal.subscribe', { terminal: handle }, (result) => {
|
||||
if (subscribeSeqRef.current !== seq || activeHandleRef.current !== handle) {
|
||||
return
|
||||
}
|
||||
const data = result as Record<string, unknown>
|
||||
if (data.type === 'scrollback') {
|
||||
// Why: init xterm at the desktop's exact cols/rows so escape
|
||||
// codes with absolute cursor positioning render correctly.
|
||||
// CSS transform: scale() in TerminalWebView shrinks the canvas
|
||||
// to fit the phone viewport, producing a 1:1 miniature.
|
||||
const cols = (data.cols as number) || 80
|
||||
const rows = (data.rows as number) || 24
|
||||
terminalSizeRef.current = { cols, rows }
|
||||
termRef.current?.init(cols, rows)
|
||||
// Why: prefer serialized xterm buffer (ANSI escape string that
|
||||
// reconstructs the exact screen state) over line-based tail
|
||||
// because TUI apps use absolute cursor positioning that only
|
||||
// works at the original terminal dimensions.
|
||||
if (typeof data.serialized === 'string' && data.serialized.length > 0) {
|
||||
termRef.current?.write(data.serialized)
|
||||
} else {
|
||||
writeLines(data.lines as string[] | string | undefined)
|
||||
}
|
||||
} else if (data.type === 'data') {
|
||||
termRef.current?.write(data.chunk as string)
|
||||
}
|
||||
})
|
||||
|
||||
if (subscribeSeqRef.current === seq && activeHandleRef.current === handle) {
|
||||
unsubRef.current = unsub
|
||||
} else {
|
||||
unsub()
|
||||
}
|
||||
})()
|
||||
},
|
||||
[client, writeLines]
|
||||
)
|
||||
|
||||
@@ -41,6 +41,9 @@ let streamSawMarker = false
|
||||
let readSawMarker = false
|
||||
let activeHandle: string | null = null
|
||||
let runtimeId = ''
|
||||
let scrollbackCols: number | null = null
|
||||
let scrollbackRows: number | null = null
|
||||
let serializedLength = 0
|
||||
|
||||
function nextId(): string {
|
||||
reqId += 1
|
||||
@@ -168,6 +171,9 @@ async function run(ws: WebSocket): Promise<void> {
|
||||
}
|
||||
|
||||
console.log(`runtime: ${runtimeId || '(unknown)'}`)
|
||||
console.log(`scrollbackCols: ${scrollbackCols ?? '(none)'}`)
|
||||
console.log(`scrollbackRows: ${scrollbackRows ?? '(none)'}`)
|
||||
console.log(`serializedLength: ${serializedLength}`)
|
||||
console.log(`streamSawMarker: ${streamSawMarker}`)
|
||||
console.log(`readSawMarker: ${readSawMarker}`)
|
||||
|
||||
@@ -207,9 +213,15 @@ ws.on('message', (data) => {
|
||||
|
||||
if (response.streaming && result?.type === 'scrollback') {
|
||||
const lines = Array.isArray(result.lines) ? result.lines.join('\n') : String(result.lines ?? '')
|
||||
scrollbackCols = typeof result.cols === 'number' ? result.cols : null
|
||||
scrollbackRows = typeof result.rows === 'number' ? result.rows : null
|
||||
serializedLength = typeof result.serialized === 'string' ? result.serialized.length : 0
|
||||
if (lines.includes(marker)) {
|
||||
streamSawMarker = true
|
||||
}
|
||||
if (typeof result.serialized === 'string' && result.serialized.includes(marker)) {
|
||||
streamSawMarker = true
|
||||
}
|
||||
}
|
||||
|
||||
const pendingRequest = pending.get(response.id)
|
||||
|
||||
@@ -28,7 +28,7 @@ const XTERM_HTML = `<!DOCTYPE html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=0.5, maximum-scale=5, user-scalable=yes">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@6.1.0-beta.198/css/xterm.min.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
@@ -46,7 +46,7 @@ const XTERM_HTML = `<!DOCTYPE html>
|
||||
</head>
|
||||
<body>
|
||||
<div id="terminal-container"></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@6.1.0-beta.198/lib/xterm.min.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
var FONT_SIZE = 13;
|
||||
|
||||
+61
-1
@@ -35,6 +35,9 @@ const sshProviders = new Map<string, IPtyProvider>()
|
||||
// post-spawn operations to the correct provider without the renderer needing
|
||||
// to track connectionId per-PTY.
|
||||
const ptyOwnership = new Map<string, string | null>()
|
||||
// Why: mobile clients must mirror desktop PTY geometry even when the renderer
|
||||
// cannot provide an xterm snapshot yet, such as immediately after tab creation.
|
||||
const ptySizes = new Map<string, { cols: number; rows: number }>()
|
||||
// Why: the agent-hooks server caches per-paneKey state (last prompt, last
|
||||
// tool) that otherwise grows unbounded as panes come and go. Track the
|
||||
// spawn-time paneKey so clearProviderPtyState can clear that cache on PTY
|
||||
@@ -280,6 +283,7 @@ export function clearProviderPtyState(id: string): void {
|
||||
// new teardown path forgets to remove one provider's overlay/hook state.
|
||||
openCodeHookService.clearPty(id)
|
||||
piTitlebarExtensionService.clearPty(id)
|
||||
ptySizes.delete(id)
|
||||
// Why: drop the memory-collector registration so a dead PTY does not keep
|
||||
// trying to resolve its (now-dead) pid on every snapshot. Safe no-op for
|
||||
// PTYs that were never registered (SSH-owned).
|
||||
@@ -448,6 +452,54 @@ export function registerPtyHandlers(
|
||||
}
|
||||
})
|
||||
|
||||
function requestSerializedBuffer(
|
||||
ptyId: string
|
||||
): Promise<{ data: string; cols: number; rows: number } | null> {
|
||||
if (mainWindow.isDestroyed()) {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
|
||||
const requestId = randomUUID()
|
||||
return new Promise((resolve) => {
|
||||
const cleanup = (): void => {
|
||||
clearTimeout(timeout)
|
||||
ipcMain.removeListener('pty:serializeBuffer:response', onResponse)
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup()
|
||||
resolve(null)
|
||||
}, 750)
|
||||
|
||||
const onResponse = (
|
||||
_event: Electron.IpcMainEvent,
|
||||
args: {
|
||||
requestId?: string
|
||||
snapshot?: { data?: unknown; cols?: unknown; rows?: unknown } | null
|
||||
}
|
||||
): void => {
|
||||
if (args.requestId !== requestId) {
|
||||
return
|
||||
}
|
||||
cleanup()
|
||||
const snapshot = args.snapshot
|
||||
if (
|
||||
snapshot &&
|
||||
typeof snapshot.data === 'string' &&
|
||||
typeof snapshot.cols === 'number' &&
|
||||
typeof snapshot.rows === 'number'
|
||||
) {
|
||||
resolve({ data: snapshot.data, cols: snapshot.cols, rows: snapshot.rows })
|
||||
} else {
|
||||
resolve(null)
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.on('pty:serializeBuffer:response', onResponse)
|
||||
mainWindow.webContents.send('pty:serializeBuffer:request', { requestId, ptyId })
|
||||
})
|
||||
}
|
||||
|
||||
// Kill orphaned PTY processes from previous page loads when the renderer reloads.
|
||||
// Why: only applies to LocalPtyProvider where PTYs live in the Electron main
|
||||
// process and can become orphaned on page reload. Daemon-backed sessions
|
||||
@@ -499,7 +551,13 @@ export function registerPtyHandlers(
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
},
|
||||
serializeBuffer: (ptyId) => {
|
||||
// Why: mobile xterm must start from the desktop xterm's exact screen
|
||||
// state and dimensions before live TUI chunks can render correctly.
|
||||
return requestSerializedBuffer(ptyId)
|
||||
},
|
||||
getSize: (ptyId) => ptySizes.get(ptyId) ?? null
|
||||
})
|
||||
|
||||
// ─── IPC Handlers (thin dispatch layer) ─────────────────────────
|
||||
@@ -678,6 +736,7 @@ export function registerPtyHandlers(
|
||||
if (preAllocatedHandle) {
|
||||
runtime?.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle)
|
||||
}
|
||||
ptySizes.set(result.id, { cols: args.cols, rows: args.rows })
|
||||
if (isClaudeLaunch) {
|
||||
markClaudePtySpawned(result.id)
|
||||
}
|
||||
@@ -744,6 +803,7 @@ export function registerPtyHandlers(
|
||||
// empty acknowledgement message back to the renderer.
|
||||
ipcMain.removeAllListeners('pty:resize')
|
||||
ipcMain.on('pty:resize', (_event, args: { id: string; cols: number; rows: number }) => {
|
||||
ptySizes.set(args.id, { cols: args.cols, rows: args.rows })
|
||||
getProviderForPty(args.id).resize(args.id, args.cols, args.rows)
|
||||
})
|
||||
|
||||
|
||||
@@ -142,6 +142,8 @@ type RuntimePtyController = {
|
||||
write(ptyId: string, data: string): boolean
|
||||
kill(ptyId: string): boolean
|
||||
getForegroundProcess(ptyId: string): Promise<string | null>
|
||||
serializeBuffer?(ptyId: string): Promise<{ data: string; cols: number; rows: number } | null>
|
||||
getSize?(ptyId: string): { cols: number; rows: number } | null
|
||||
}
|
||||
|
||||
type RuntimeNotifier = {
|
||||
@@ -507,6 +509,16 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
serializeTerminalBuffer(
|
||||
ptyId: string
|
||||
): Promise<{ data: string; cols: number; rows: number } | null> {
|
||||
return this.ptyController?.serializeBuffer?.(ptyId) ?? Promise.resolve(null)
|
||||
}
|
||||
|
||||
getTerminalSize(ptyId: string): { cols: number; rows: number } | null {
|
||||
return this.ptyController?.getSize?.(ptyId) ?? null
|
||||
}
|
||||
|
||||
resolveLeafForHandle(handle: string): { ptyId: string | null } | null {
|
||||
const record = this.handles.get(handle)
|
||||
if (!record) {
|
||||
|
||||
@@ -185,9 +185,18 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
params: TerminalSubscribe,
|
||||
handler: async (params, { runtime }, emit) => {
|
||||
const read = await runtime.readTerminal(params.terminal)
|
||||
emit({ type: 'scrollback', lines: read.tail, truncated: read.truncated })
|
||||
|
||||
const leaf = runtime.resolveLeafForHandle(params.terminal)
|
||||
const serialized = leaf?.ptyId ? await runtime.serializeTerminalBuffer(leaf.ptyId) : null
|
||||
const size = leaf?.ptyId ? runtime.getTerminalSize(leaf.ptyId) : null
|
||||
emit({
|
||||
type: 'scrollback',
|
||||
lines: read.tail,
|
||||
truncated: read.truncated,
|
||||
serialized: serialized?.data,
|
||||
cols: serialized?.cols ?? size?.cols,
|
||||
rows: serialized?.rows ?? size?.rows
|
||||
})
|
||||
|
||||
if (!leaf?.ptyId) {
|
||||
emit({ type: 'end' })
|
||||
return
|
||||
|
||||
@@ -392,6 +392,13 @@ export type PreloadApi = {
|
||||
onData: (callback: (data: { id: string; data: string }) => void) => () => void
|
||||
onReplay: (callback: (data: { id: string; data: string }) => void) => () => void
|
||||
onExit: (callback: (data: { id: string; code: number }) => void) => () => void
|
||||
onSerializeBufferRequest: (
|
||||
callback: (data: { requestId: string; ptyId: string }) => void
|
||||
) => () => void
|
||||
sendSerializedBuffer: (
|
||||
requestId: string,
|
||||
snapshot: { data: string; cols: number; rows: number } | null
|
||||
) => void
|
||||
}
|
||||
feedback: {
|
||||
submit: (args: {
|
||||
|
||||
@@ -392,8 +392,11 @@ const api = {
|
||||
return () => ipcRenderer.removeListener('pty:serializeBuffer:request', listener)
|
||||
},
|
||||
|
||||
sendSerializedBuffer: (requestId: string, data: string | null): void => {
|
||||
ipcRenderer.send('pty:serializeBuffer:response', { requestId, data })
|
||||
sendSerializedBuffer: (
|
||||
requestId: string,
|
||||
snapshot: { data: string; cols: number; rows: number } | null
|
||||
): void => {
|
||||
ipcRenderer.send('pty:serializeBuffer:response', { requestId, snapshot })
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -25,6 +25,6 @@ function ensureSerializerListener(): void {
|
||||
window.api.pty.onSerializeBufferRequest((request) => {
|
||||
const serializer = serializersByPtyId.get(request.ptyId)
|
||||
const result = serializer?.()
|
||||
window.api.pty.sendSerializedBuffer(request.requestId, result?.data ?? null)
|
||||
window.api.pty.sendSerializedBuffer(request.requestId, result ?? null)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user