diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index aa20710b6a4..2b3f3a8d951 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -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>(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] diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 46bdaec6972..9e3931c9d8b 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -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 diff --git a/src/main/runtime/mobile-subscribe-integration.test.ts b/src/main/runtime/mobile-subscribe-integration.test.ts index 1ed849911b6..f5eed223526 100644 --- a/src/main/runtime/mobile-subscribe-integration.test.ts +++ b/src/main/runtime/mobile-subscribe-integration.test.ts @@ -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() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 7744234b146..eb444697cdf 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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() + + // 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 } diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 868d1cb8d54..cd59a170586 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -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()