fix mobile terminal geometry sync

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-04-30 18:03:23 -04:00
co-authored by Orca
parent 1c8f23f1e5
commit 4fe89e656a
9 changed files with 161 additions and 32 deletions
+50 -24
View File
@@ -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]
)
+12
View File
@@ -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)
+2 -2
View File
@@ -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
View File
@@ -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)
})
+12
View File
@@ -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) {
+11 -2
View File
@@ -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
+7
View File
@@ -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: {
+5 -2
View File
@@ -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)
})
}