Fix mobile display-mode toggle corrupting split-pane terminal sizes

When toggling a terminal to desktop mode on mobile, the server restores
the PTY to its correct size and sends a terminalFitOverrideChanged IPC.
This triggers the desktop renderer's React re-render cascade, which
runs safeFit on ALL terminal panes — including background tabs measured
at incorrect widths. Those stale pty:resize IPCs would overwrite the
correct PTY size and corrupt previousCols for future toggles.

Key changes:
- Add global 500ms suppress window that blocks all desktop renderer
  pty:resize events after a mode change (the cascade takes ~360ms to
  propagate through IPC → React → rAF → DOM → IPC)
- Track desktop renderer pane geometry in lastRendererSizes map so
  handleMobileSubscribe uses actual split-pane width instead of stale
  full-width PTY size
- Clear lastRendererSizes on desktop restore to prevent stale values
  from persisting across subscribe cycles
- Register mobile subscriber even in desktop mode so toggling to
  auto/phone can find the viewport
- Add onExternalPtyResize to update previousCols live when the desktop
  renderer corrects pane geometry after restore
- Add comprehensive debug logging to /tmp/mobile-fit-debug.log
- Guard toggleDisplayMode against concurrent taps
- Add integration tests for onExternalPtyResize scenarios

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-04-30 21:23:26 -04:00
co-authored by Orca
parent 2c1f817fcf
commit c18c4df829
5 changed files with 285 additions and 26 deletions
@@ -312,16 +312,21 @@ export default function SessionScreen() {
// Why: toggles between phone and desktop mode via server RPC. The server
// handles the actual resize and emits a 'resized' event on the existing
// subscription stream — no client-side state tracking needed.
const toggleInFlightRef = useRef<Set<string>>(new Set())
const toggleDisplayMode = useCallback(
async (handle: string) => {
if (!client) return
if (toggleInFlightRef.current.has(handle)) return
const current = terminalModes.get(handle) ?? 'auto'
const next: MobileDisplayMode = current === 'auto' || current === 'phone' ? 'desktop' : 'auto'
console.log(`[mobile-fit] toggleDisplayMode handle=${handle} current=${current} next=${next}`)
toggleInFlightRef.current.add(handle)
try {
await client.sendRequest('terminal.setDisplayMode', { terminal: handle, mode: next })
} catch {
// Mode change failed — server state unchanged, UI stays in sync.
} catch (err) {
console.log(`[mobile-fit] toggleDisplayMode FAILED handle=${handle}`, err)
} finally {
toggleInFlightRef.current.delete(handle)
}
},
[client, terminalModes]
+22
View File
@@ -5,6 +5,15 @@ boundary. Splitting it by line count would scatter tightly coupled terminal
process behavior across files without a cleaner ownership seam. */
import { join, delimiter } from 'path'
import { randomUUID } from 'crypto'
import { appendFileSync } from 'fs'
function mfLog(msg: string): void {
try {
appendFileSync('/tmp/mobile-fit-debug.log', `[${new Date().toISOString()}] [pty-ipc] ${msg}\n`)
} catch {
/* ignore */
}
}
import { type BrowserWindow, ipcMain, app } from 'electron'
export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-ready'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
@@ -825,8 +834,21 @@ 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 }) => {
// Why: after a desktop-fit override change, the desktop renderer's
// re-render cascade runs safeFit on ALL panes (not just the affected
// one). Background-tab panes get measured at full-width (214) instead
// of their correct split width. Suppressing ALL pty:resize during
// this window prevents the cascade from corrupting PTY dimensions.
if (runtime?.isResizeSuppressed()) {
mfLog(
`pty:resize SUPPRESSED (global window) id=${args.id} cols=${args.cols} rows=${args.rows}`
)
return
}
mfLog(`pty:resize id=${args.id} cols=${args.cols} rows=${args.rows}`)
ptySizes.set(args.id, { cols: args.cols, rows: args.rows })
getProviderForPty(args.id).resize(args.id, args.cols, args.rows)
runtime?.onExternalPtyResize(args.id, args.cols, args.rows)
})
// Why: fire-and-forget — clears the DaemonPtyAdapter's sticky cold restore
@@ -1,3 +1,4 @@
/* oxlint-disable max-lines -- Why: integration tests cover the full mobile subscribe lifecycle across many scenarios; splitting would scatter related assertions. */
/**
* Integration tests for the server-authoritative mobile subscribe lifecycle.
* Tests handleMobileSubscribe, handleMobileUnsubscribe, applyMobileDisplayMode,
@@ -396,6 +397,62 @@ describe('mobile subscribe integration', () => {
})
})
describe('onExternalPtyResize', () => {
it('updates previousCols when desktop renderer resizes PTY after desktop restore', () => {
const { runtime, ptySizes } = createRuntime()
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
// Toggle to desktop — restores to previousCols (150x40)
runtime.setMobileDisplayMode('pty-1', 'desktop')
runtime.applyMobileDisplayMode('pty-1')
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
// Simulate desktop renderer's safeFit correcting to split-pane width
runtime.onExternalPtyResize('pty-1', 105, 40)
// Toggle back to auto — should capture previousCols=105 (not 150)
runtime.setMobileDisplayMode('pty-1', 'auto')
runtime.applyMobileDisplayMode('pty-1')
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
// Toggle to desktop again — should restore to 105 (the corrected value)
runtime.setMobileDisplayMode('pty-1', 'desktop')
runtime.applyMobileDisplayMode('pty-1')
expect(ptySizes.get('pty-1')).toEqual({ cols: 105, rows: 40 })
})
it('uses lastRendererSize for previousCols on first subscribe', () => {
const { runtime, ptySizes } = createRuntime()
// Simulate: PTY spawned at 214 (ptySizes), but renderer already fit to 105
ptySizes.set('pty-1', { cols: 214, rows: 72 })
runtime.onExternalPtyResize('pty-1', 105, 40)
// First mobile subscribe — should use rendererSize (105) not ptySizes (214)
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
// Toggle to desktop — should restore to 105, not 214
runtime.setMobileDisplayMode('pty-1', 'desktop')
runtime.applyMobileDisplayMode('pty-1')
expect(ptySizes.get('pty-1')).toEqual({ cols: 105, rows: 40 })
})
it('does not update previousCols when PTY is phone-fitted', () => {
const { runtime, ptySizes } = createRuntime()
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
// PTY is phone-fitted (wasResizedToPhone=true) — external resize should not
// overwrite previousCols with phone dims
runtime.onExternalPtyResize('pty-1', 45, 20)
// Toggle to desktop — should still restore to original 150x40
runtime.setMobileDisplayMode('pty-1', 'desktop')
runtime.applyMobileDisplayMode('pty-1')
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
})
})
describe('backward compatibility', () => {
it('old resizeForClient still works alongside new system', () => {
const { runtime, ptySizes } = createRuntime()
+164 -24
View File
@@ -11,6 +11,18 @@ import { gitExecFileAsync, gitExecFileSync } from '../git/runner'
import { isWslPath, parseWslPath, getWslHome } from '../wsl'
import { randomUUID } from 'crypto'
import { join } from 'path'
import { appendFileSync } from 'fs'
const MOBILE_FIT_LOG = '/tmp/mobile-fit-debug.log'
function mfLog(msg: string): void {
const ts = new Date().toISOString()
const line = `[${ts}] ${msg}\n`
try {
appendFileSync(MOBILE_FIT_LOG, line)
} catch {
/* ignore */
}
}
import { rm } from 'fs/promises'
import { OrchestrationDb } from './orchestration/db'
import { formatMessagesForInjection } from './orchestration/formatter'
@@ -350,6 +362,24 @@ export class OrcaRuntimeService {
}
>()
// Why: tracks the last PTY size set by the desktop renderer (via pty:resize
// IPC). Unlike ptySizes (which is overwritten by server-side phone-fit
// resizes), this map preserves the actual pane geometry. Used as the
// preferred source for previousCols so desktop restore uses the correct
// split-pane width instead of a stale full-width value.
private lastRendererSizes = new Map<string, { cols: number; rows: number }>()
// Why: when a desktop-fit override change fires, the desktop renderer's
// re-render cascade (triggered by setOverrideTick) runs safeFit on ALL
// panes — not just the affected one. Background tab panes get measured at
// full-width (214) instead of their correct split width (105). The stale
// pty:resize IPCs overwrite both the actual PTY size and lastRendererSizes.
// This global window suppresses ALL pty:resize for 200ms after any
// desktop-fit notification. The server has already set the correct PTY
// size via ptyController.resize(), so desktop renderer resizes during
// this window are redundant (for the restored pane) or wrong (collateral).
private resizeSuppressedUntil = 0
// Why: delays PTY restore by 300ms after mobile unsubscribe so rapid tab
// switches don't cause unnecessary resize thrashing. Keyed by clientId
// Why: keyed by ptyId so each PTY gets its own independent restore timer.
@@ -1016,6 +1046,7 @@ export class OrcaRuntimeService {
this.mobileSubscribers.delete(ptyId)
this.mobileDisplayModes.delete(ptyId)
this.resizeListeners.delete(ptyId)
this.lastRendererSizes.delete(ptyId)
const pendingRestore = this.pendingRestoreTimers.get(ptyId)
if (pendingRestore) {
clearTimeout(pendingRestore.timer)
@@ -1075,13 +1106,12 @@ export class OrcaRuntimeService {
viewport?: { cols: number; rows: number }
): boolean {
const mode = this.mobileDisplayModes.get(ptyId) ?? 'auto'
mfLog(
`handleMobileSubscribe ptyId=${ptyId} mode=${mode} viewport=${viewport ? `${viewport.cols}x${viewport.rows}` : 'none'}`
)
console.log(
`[mobile-fit] handleMobileSubscribe ptyId=${ptyId} mode=${mode} viewport=${viewport ? `${viewport.cols}x${viewport.rows}` : 'none'}`
)
if (mode === 'desktop') {
console.log('[mobile-fit] → skipped (desktop mode)')
return false
}
if (!viewport) {
console.log('[mobile-fit] → skipped (no viewport)')
return false
@@ -1095,13 +1125,49 @@ export class OrcaRuntimeService {
this.pendingRestoreTimers.delete(ptyId)
}
// Preserve existing previous dims if re-subscribing to an already phone-fitted
// terminal. getTerminalSize would return phone dims, so we use the existing
// subscriber's previousCols/previousRows to avoid losing the original desktop size.
// Why: prefer lastRendererSizes (the actual pane geometry reported by the
// desktop renderer's safeFit via pty:resize IPC) over getTerminalSize (the
// server-side PTY size, which may be stale — e.g. 214 full-width when the
// pane is actually in a split at ~105). Fall back to existing subscriber's
// previousCols (re-subscribe case) then currentSize (first subscribe).
const existing = this.mobileSubscribers.get(ptyId)
const currentSize = this.getTerminalSize(ptyId)
const previousCols = existing?.previousCols ?? currentSize?.cols ?? null
const previousRows = existing?.previousRows ?? currentSize?.rows ?? null
const rendererSize = this.lastRendererSizes.get(ptyId)
const previousCols = existing?.previousCols ?? rendererSize?.cols ?? currentSize?.cols ?? null
const previousRows = existing?.previousRows ?? rendererSize?.rows ?? currentSize?.rows ?? null
mfLog(
` existing.previousCols=${existing?.previousCols ?? 'null'} existing.previousRows=${existing?.previousRows ?? 'null'} existing.wasResized=${existing?.wasResizedToPhone ?? 'N/A'}`
)
mfLog(
` currentSize=${currentSize ? `${currentSize.cols}x${currentSize.rows}` : 'null'} rendererSize=${rendererSize ? `${rendererSize.cols}x${rendererSize.rows}` : 'null'}`
)
mfLog(` → previousCols=${previousCols} previousRows=${previousRows}`)
mfLog(
` allRendererSizes: ${JSON.stringify([...this.lastRendererSizes.entries()].map(([k, v]) => [k.slice(-12), `${v.cols}x${v.rows}`]))}`
)
// Why: always register the subscriber so applyMobileDisplayMode can find
// the viewport when the user later toggles from desktop to auto/phone.
// Without this, toggling to auto after subscribing in desktop mode sees
// hasSubscriber=false and can't perform the phone resize.
if (mode === 'desktop') {
mfLog(` → SKIP (desktop mode), registering with null prev`)
console.log('[mobile-fit] → registered subscriber but skipped resize (desktop mode)')
// Why: set previousCols/Rows to null so we don't capture a stale PTY
// size that may not match the actual pane geometry (e.g. 214 when the
// pane is in a split at 105). When the user later toggles to auto/phone,
// handleMobileSubscribe will capture currentSize at that point, which
// will be correct because safeFit has had time to adjust the PTY.
this.mobileSubscribers.set(ptyId, {
clientId,
viewport,
wasResizedToPhone: false,
previousCols: null,
previousRows: null
})
return false
}
this.mobileSubscribers.set(ptyId, {
clientId,
@@ -1118,8 +1184,13 @@ export class OrcaRuntimeService {
// to a terminal that was left at phone dims (no restore on tab switch)
// should not trigger another SIGWINCH → shell prompt redraw.
const alreadyAtTarget = currentSize?.cols === clampedCols && currentSize?.rows === clampedRows
const prevSource =
existing?.previousCols != null ? 'existing' : rendererSize ? 'renderer' : 'pty'
mfLog(
` → ${alreadyAtTarget ? 'ALREADY AT' : 'RESIZE TO'} ${clampedCols}x${clampedRows} src=${prevSource}`
)
console.log(
`[mobile-fit] → ${alreadyAtTarget ? 'already at' : 'resizing PTY to'} ${clampedCols}x${clampedRows} (prev=${previousCols}x${previousRows}) notifier=${!!this.notifier}`
`[mobile-fit] → ${alreadyAtTarget ? 'already at' : 'resizing PTY to'} ${clampedCols}x${clampedRows} (prev=${previousCols}x${previousRows} src=${prevSource}) notifier=${!!this.notifier}`
)
if (!alreadyAtTarget) {
this.ptyController?.resize?.(ptyId, clampedCols, clampedRows)
@@ -1146,15 +1217,20 @@ export class OrcaRuntimeService {
// intermediate terminals keep their current dims harmlessly.
handleMobileUnsubscribe(ptyId: string, clientId: string): void {
const subscriber = this.mobileSubscribers.get(ptyId)
mfLog(
`handleMobileUnsubscribe ptyId=${ptyId} hasSub=${!!subscriber} wasResized=${subscriber?.wasResizedToPhone} prev=${subscriber?.previousCols}x${subscriber?.previousRows}`
)
console.log(
`[mobile-fit] handleMobileUnsubscribe ptyId=${ptyId} hasSubscriber=${!!subscriber} wasResized=${subscriber?.wasResizedToPhone}`
)
if (!subscriber || subscriber.clientId !== clientId) {
mfLog(` → SKIP (no subscriber or clientId mismatch)`)
return
}
const mode = this.mobileDisplayModes.get(ptyId) ?? 'auto'
this.mobileSubscribers.delete(ptyId)
mfLog(` mode=${mode} deleted subscriber`)
if (mode === 'auto' && subscriber.wasResizedToPhone) {
const existing = this.pendingRestoreTimers.get(ptyId)
@@ -1163,15 +1239,20 @@ export class OrcaRuntimeService {
}
const { previousCols, previousRows } = subscriber
mfLog(` scheduling delayed restore to ${previousCols}x${previousRows} in 300ms`)
const timer = setTimeout(() => {
this.pendingRestoreTimers.delete(ptyId)
if (this.mobileSubscribers.has(ptyId)) {
mfLog(` delayed restore CANCELLED — new subscriber exists`)
return
}
mfLog(` delayed restore FIRING: ptyId=${ptyId} resize to ${previousCols}x${previousRows}`)
if (previousCols != null && previousRows != null) {
this.ptyController?.resize?.(ptyId, previousCols, previousRows)
this.resizeHeadlessTerminal(ptyId, previousCols, previousRows)
}
this.lastRendererSizes.delete(ptyId)
this.suppressResizesForMs(500)
this.terminalFitOverrides.delete(ptyId)
this.notifier?.terminalFitOverrideChanged(
ptyId,
@@ -1194,6 +1275,11 @@ export class OrcaRuntimeService {
applyMobileDisplayMode(ptyId: string): void {
const mode = this.mobileDisplayModes.get(ptyId) ?? 'auto'
const subscriber = this.mobileSubscribers.get(ptyId)
const rendererSize = this.lastRendererSizes.get(ptyId)
const currentSize = this.getTerminalSize(ptyId)
mfLog(
`applyMobileDisplayMode ptyId=${ptyId} mode=${mode} hasSub=${!!subscriber} wasResized=${subscriber?.wasResizedToPhone} sub.prev=${subscriber?.previousCols}x${subscriber?.previousRows} rendererSize=${rendererSize ? `${rendererSize.cols}x${rendererSize.rows}` : 'null'} currentSize=${currentSize ? `${currentSize.cols}x${currentSize.rows}` : 'null'}`
)
console.log(
`[mobile-fit] applyMobileDisplayMode ptyId=${ptyId} mode=${mode} hasSubscriber=${!!subscriber} wasResized=${subscriber?.wasResizedToPhone}`
)
@@ -1201,11 +1287,21 @@ export class OrcaRuntimeService {
if (mode === 'desktop') {
if (subscriber?.wasResizedToPhone) {
const { previousCols, previousRows } = subscriber
mfLog(` DESKTOP RESTORE: previousCols=${previousCols} previousRows=${previousRows}`)
if (previousCols != null && previousRows != null) {
this.ptyController?.resize?.(ptyId, previousCols, previousRows)
this.resizeHeadlessTerminal(ptyId, previousCols, previousRows)
}
subscriber.wasResizedToPhone = false
// Why: clear stale renderer size so the next mobile subscribe falls
// through to currentSize (which is correct after the server restore).
// Without this, a polluted 214 from a prior collateral safeFit cascade
// persists in lastRendererSizes and gets used as previousCols.
this.lastRendererSizes.delete(ptyId)
// Why: 500ms not 200ms — the desktop renderer's collateral safeFit
// cascade (IPC → React re-render → rAF → DOM measure → IPC back)
// takes ~360ms to propagate to background-tab terminals.
this.suppressResizesForMs(500)
this.terminalFitOverrides.delete(ptyId)
this.notifier?.terminalFitOverrideChanged(
ptyId,
@@ -1215,30 +1311,71 @@ export class OrcaRuntimeService {
)
}
const size = this.getTerminalSize(ptyId)
mfLog(` NOTIFY desktop resize: ${size?.cols}x${size?.rows}`)
this.notifyTerminalResize(ptyId, {
cols: size?.cols ?? 0,
rows: size?.rows ?? 0,
displayMode: 'desktop',
reason: 'mode-change'
})
} else if (
(mode === 'phone' || mode === 'auto') &&
subscriber &&
!subscriber.wasResizedToPhone
) {
const viewport = subscriber.viewport
if (viewport) {
this.handleMobileSubscribe(ptyId, subscriber.clientId, viewport)
this.notifyTerminalResize(ptyId, {
cols: viewport.cols,
rows: viewport.rows,
displayMode: mode,
reason: 'mode-change'
})
} else if (mode === 'phone' || mode === 'auto') {
if (subscriber && !subscriber.wasResizedToPhone) {
const viewport = subscriber.viewport
if (viewport) {
this.handleMobileSubscribe(ptyId, subscriber.clientId, viewport)
}
}
// Why: always emit the mode change even when no resize occurred (e.g.
// subscriber missing, wasResizedToPhone already true, or no viewport).
// Without this the mobile client never learns the mode changed and its
// toggle button gets stuck showing the old state.
const size = this.getTerminalSize(ptyId)
this.notifyTerminalResize(ptyId, {
cols: size?.cols ?? 0,
rows: size?.rows ?? 0,
displayMode: mode,
reason: 'mode-change'
})
}
}
// Why: called from the pty:resize IPC handler whenever the desktop renderer
// resizes a PTY (e.g. via safeFit after window resize, split, or desktop-mode
// restore). Stores the renderer-reported size so handleMobileSubscribe can use
// the actual pane geometry instead of a stale PTY size for previousCols.
onExternalPtyResize(ptyId: string, cols: number, rows: number): void {
this.lastRendererSizes.set(ptyId, { cols, rows })
mfLog(`onExternalPtyResize ptyId=${ptyId} cols=${cols} rows=${rows}`)
const subscriber = this.mobileSubscribers.get(ptyId)
if (!subscriber) {
mfLog(` no subscriber, stored in lastRendererSizes only`)
return
}
if (!subscriber.wasResizedToPhone) {
mfLog(
` updated subscriber.previousCols ${subscriber.previousCols}→${cols} previousRows ${subscriber.previousRows}→${rows}`
)
subscriber.previousCols = cols
subscriber.previousRows = rows
} else {
mfLog(
` SKIPPED (wasResizedToPhone=true, keeping prev=${subscriber.previousCols}x${subscriber.previousRows})`
)
}
}
// Why: the pty:resize IPC handler calls this to check if the global
// suppress window is active. During this window, all desktop renderer
// pty:resize events are ignored to prevent collateral safeFit corruption.
isResizeSuppressed(): boolean {
return Date.now() < this.resizeSuppressedUntil
}
private suppressResizesForMs(ms: number): void {
this.resizeSuppressedUntil = Date.now() + ms
}
subscribeToTerminalResize(
ptyId: string,
listener: (event: { cols: number; rows: number; displayMode: string; reason: string }) => void
@@ -1262,6 +1399,9 @@ export class OrcaRuntimeService {
event: { cols: number; rows: number; displayMode: string; reason: string }
): void {
const listeners = this.resizeListeners.get(ptyId)
mfLog(
`notifyTerminalResize ptyId=${ptyId} cols=${event.cols} rows=${event.rows} mode=${event.displayMode} reason=${event.reason} listenerCount=${listeners?.size ?? 0}`
)
if (!listeners) {
return
}
+35
View File
@@ -1,8 +1,17 @@
/* oxlint-disable max-lines -- Why: terminal RPC methods are co-located for discoverability; splitting would scatter related handlers across files. */
import { z } from 'zod'
import { appendFileSync } from 'fs'
import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core'
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
function mfLog(msg: string): void {
try {
appendFileSync('/tmp/mobile-fit-debug.log', `[${new Date().toISOString()}] [rpc] ${msg}\n`)
} catch {
/* ignore */
}
}
const TerminalHandle = z.object({
terminal: requiredString('Missing terminal handle')
})
@@ -238,6 +247,12 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
params: TerminalSetDisplayMode,
handler: async (params, { runtime }) => {
const leaf = runtime.resolveLeafForHandle(params.terminal)
mfLog(
`setDisplayMode handle=${params.terminal} mode=${params.mode} ptyId=${leaf?.ptyId ?? 'none'}`
)
console.log(
`[mobile-fit] setDisplayMode handle=${params.terminal} mode=${params.mode} ptyId=${leaf?.ptyId ?? 'none'}`
)
if (!leaf?.ptyId) {
throw new Error('no_connected_pty')
}
@@ -265,6 +280,12 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
handler: async (params, { runtime }, emit) => {
let leaf = runtime.resolveLeafForHandle(params.terminal)
const isMobile = params.client?.type === 'mobile'
if (isMobile) {
mfLog(`\n========== MOBILE SUBSCRIBE ==========`)
}
mfLog(
`terminal.subscribe handle=${params.terminal} ptyId=${leaf?.ptyId ?? 'none'} client=${params.client?.type ?? 'none'} viewport=${params.viewport ? `${params.viewport.cols}x${params.viewport.rows}` : 'none'}`
)
console.log(
`[mobile-fit] terminal.subscribe handle=${params.terminal} ptyId=${leaf?.ptyId ?? 'none'} client=${params.client?.type ?? 'none'} viewport=${params.viewport ? `${params.viewport.cols}x${params.viewport.rows}` : 'none'}`
)
@@ -277,10 +298,14 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
try {
const ptyId = await runtime.waitForLeafPtyId(params.terminal)
leaf = { ptyId }
mfLog(
` terminal.subscribe handle=${params.terminal} ptyId resolved to ${ptyId} after wait`
)
console.log(
`[mobile-fit] terminal.subscribe handle=${params.terminal} ptyId resolved to ${ptyId} after wait`
)
} catch {
mfLog(` terminal.subscribe handle=${params.terminal} PTY wait timed out`)
console.log(
`[mobile-fit] terminal.subscribe handle=${params.terminal} PTY wait timed out, falling back`
)
@@ -288,6 +313,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
}
if (!leaf?.ptyId) {
mfLog(` terminal.subscribe handle=${params.terminal} no ptyId, sending scrollback+end`)
const read = await runtime.readTerminal(params.terminal)
emit({
type: 'scrollback',
@@ -303,6 +329,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
const ptyId = leaf.ptyId
const clientId = params.client?.id
mfLog(
` terminal.subscribe resolved ptyId=${ptyId} clientId=${clientId ?? 'none'} isMobile=${isMobile}`
)
// Server-side auto-fit: resize PTY to phone dims before serializing scrollback
if (isMobile && clientId) {
@@ -332,6 +361,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
// mobile clients. They include fresh serialized scrollback so the client
// can reinitialize xterm without resubscribing.
const unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, async (event) => {
mfLog(
` terminal.subscribe RESIZE event ptyId=${ptyId} cols=${event.cols} rows=${event.rows} displayMode=${event.displayMode} reason=${event.reason}`
)
const fresh = await runtime.serializeTerminalBuffer(ptyId)
emit({
type: 'resized',
@@ -357,6 +389,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
const subscriptionId = params.terminal
runtime.registerSubscriptionCleanup(subscriptionId, () => {
mfLog(
` terminal.subscribe CLEANUP ptyId=${ptyId} isMobile=${isMobile} clientId=${clientId ?? 'none'}`
)
unsubscribeData()
unsubscribeResize()
unsubscribeFit()