diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index cf0f1dd7edb..4fb8b2428d3 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -244,6 +244,7 @@ import { readTerminalViewportDims, runTerminalViewportFitPass } from '../../../../src/session/mobile-terminal-viewport-resubscribe' +import { resolveMobileTerminalResizePaint } from '../../../../src/session/mobile-terminal-resize-paint' import { activateMobileSessionTab } from '../../../../src/session/mobile-session-tab-activation' import { MobileTerminalDiagnostics } from '../../../../src/session/mobile-terminal-diagnostics' import { runAcceptedMobileSessionTabsEffects } from '../../../../src/session/mobile-session-tabs-accepted-effects' @@ -1480,11 +1481,11 @@ export default function SessionScreen() { data, viewport ) - const serialized = typeof data.serialized === 'string' ? data.serialized : null + const resizePaint = resolveMobileTerminalResizePaint(data.serialized) diagnostics.streamResized(handle, seq, eventSeq, data, getTerminalRef(handle) != null) const oscLinks = isTerminalOscLinkRanges(data.oscLinks) ? data.oscLinks : undefined - if (serialized != null) { - getTerminalRef(handle)?.init(cols, rows, serialized, true, oscLinks) + if (resizePaint.kind === 'init') { + getTerminalRef(handle)?.init(cols, rows, resizePaint.data, true, oscLinks) } else { getTerminalRef(handle)?.resize(cols, rows) } diff --git a/mobile/src/session/mobile-terminal-resize-paint.test.ts b/mobile/src/session/mobile-terminal-resize-paint.test.ts new file mode 100644 index 00000000000..a7990433841 --- /dev/null +++ b/mobile/src/session/mobile-terminal-resize-paint.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { resolveMobileTerminalResizePaint } from './mobile-terminal-resize-paint' + +describe('resolveMobileTerminalResizePaint', () => { + it('inits only when the resized frame carries a real snapshot', () => { + expect(resolveMobileTerminalResizePaint('\x1b[2Jprompt')).toEqual({ + kind: 'init', + data: '\x1b[2Jprompt' + }) + }) + + it('resizes in place when serialized is missing', () => { + expect(resolveMobileTerminalResizePaint(undefined)).toEqual({ kind: 'resize' }) + expect(resolveMobileTerminalResizePaint(null)).toEqual({ kind: 'resize' }) + }) + + it('resizes in place when serialized is an empty string', () => { + expect(resolveMobileTerminalResizePaint('')).toEqual({ kind: 'resize' }) + }) +}) diff --git a/mobile/src/session/mobile-terminal-resize-paint.ts b/mobile/src/session/mobile-terminal-resize-paint.ts new file mode 100644 index 00000000000..b2c3cc64a00 --- /dev/null +++ b/mobile/src/session/mobile-terminal-resize-paint.ts @@ -0,0 +1,12 @@ +export type MobileTerminalResizePaint = + | { readonly kind: 'init'; readonly data: string } + | { readonly kind: 'resize' } + +/** A resized frame may carry a rewrapped scrollback snapshot. Empty serialized + * is the absence of that snapshot, not a blank screen — replaying it remounts + * xterm and wipes a live TUI (Claude Code on Linux hosts). */ +export function resolveMobileTerminalResizePaint(serialized: unknown): MobileTerminalResizePaint { + return typeof serialized === 'string' && serialized.length > 0 + ? { kind: 'init', data: serialized } + : { kind: 'resize' } +} diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index ec4d930ad79..167ccae49db 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -9941,6 +9941,18 @@ describe('OrcaRuntimeService', () => { expect(serializeProviderBuffer).toHaveBeenCalledOnce() }) + it('treats a provider-preferred PTY with no mode tracker as an unknown screen', () => { + const { runtime } = createSideEffectRuntime() + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-tui', + { value: 900, generation: 'continued' }, + 0 + ) + + expect(runtime.getTerminalScreenKind('pty-tui')).toBe('unknown') + expect(runtime.isTerminalAlternateScreen('pty-tui')).toBe(false) + }) + it('uses provider alternate-screen state while a partial model is unsafe', async () => { const { runtime } = createSideEffectRuntime() runtime.setPtyController({ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 7cad4ad94bc..4f0c2c76a85 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -41,6 +41,7 @@ import type { } from '../../shared/terminal-side-effect-facts' import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector' import { TerminalKittyKeyboardModeTracker } from '../../shared/terminal-kitty-keyboard-mode-tracker' +import { resolveTerminalScreenKind } from './rpc/mobile-terminal-resize-restream' import { parseTerminalKittyKeyboardFlags } from '../../shared/terminal-kitty-keyboard-flags' import { AGENT_STATUS_STALE_AFTER_MS, @@ -11210,14 +11211,20 @@ export class OrcaRuntimeService { // TUI's own redraw, so the resize re-stream must be skipped. Provider state // covers restored PTYs whose main-side emulator is only a partial suffix. isTerminalAlternateScreen(ptyId: string): boolean { - if (this.providerSnapshotPreferredPtys.has(ptyId)) { - return this.providerModeTrackersByPtyId.get(ptyId)?.isAlternateScreen ?? false - } - return ( - this.headlessTerminals.get(ptyId)?.emulator.isAlternateScreen ?? - this.providerModeTrackersByPtyId.get(ptyId)?.isAlternateScreen ?? - false - ) + return this.getTerminalScreenKind(ptyId) === 'alternate' + } + + // Why: mobile resize restream must not treat "no tracker yet" as a normal + // buffer. A provider-preferred suffix can miss the 1049h that entered a + // TUI, and restreaming that remounts Claude Code on every apply-layout. + getTerminalScreenKind(ptyId: string): 'alternate' | 'normal' | 'unknown' { + const tracked = this.providerModeTrackersByPtyId.get(ptyId)?.isAlternateScreen + const headless = this.headlessTerminals.get(ptyId)?.emulator.isAlternateScreen + return resolveTerminalScreenKind({ + providerSnapshotPreferred: this.providerSnapshotPreferredPtys.has(ptyId), + ...(tracked !== undefined ? { trackedAlternateScreen: tracked } : {}), + ...(headless !== undefined ? { headlessAlternateScreen: headless } : {}) + }) } // Why: daemon-backed PTYs that the runtime adopted after an Orca relaunch diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 69f4eb72190..d597151ee28 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -48,6 +48,7 @@ import { MOBILE_SNAPSHOT_BYTE_BUDGET, MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from '../../scrollback-limits' +import { shouldRestreamMobileResizeScrollback } from '../mobile-terminal-resize-restream' import { assertTerminalAgentSendable } from '../terminal-agent-send-guard' import { navigationTargetsHost, @@ -761,8 +762,16 @@ async function sendMobileResizeRestream( event: { cols: number; rows: number; displayMode: string; reason: string; seq?: number }, shouldSend?: () => boolean ): Promise { - // Why: only a true geometry reflow rewraps scrollback; a dimensionless mode-change would re-send the whole buffer for nothing. - if (event.reason !== 'apply-layout' || runtime.isTerminalAlternateScreen(ptyId)) { + // Why: only a known normal-buffer apply-layout rewraps scrollback. Unknown + // screen is the Linux daemon hole: a TUI can be live while the tracker still + // reads "not alt", and restreaming remounts it on every layout tick. + const screen = + typeof runtime.getTerminalScreenKind === 'function' + ? runtime.getTerminalScreenKind(ptyId) + : runtime.isTerminalAlternateScreen(ptyId) + ? 'alternate' + : 'normal' + if (!shouldRestreamMobileResizeScrollback({ reason: event.reason, screen })) { return false } const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, true) diff --git a/src/main/runtime/rpc/mobile-terminal-resize-restream.test.ts b/src/main/runtime/rpc/mobile-terminal-resize-restream.test.ts new file mode 100644 index 00000000000..19f9b9e0036 --- /dev/null +++ b/src/main/runtime/rpc/mobile-terminal-resize-restream.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { + resolveTerminalScreenKind, + shouldRestreamMobileResizeScrollback +} from './mobile-terminal-resize-restream' + +describe('shouldRestreamMobileResizeScrollback', () => { + it('restreams only a known normal-buffer apply-layout', () => { + expect(shouldRestreamMobileResizeScrollback({ reason: 'apply-layout', screen: 'normal' })).toBe( + true + ) + }) + + it('skips restream for an alternate-screen TUI', () => { + expect( + shouldRestreamMobileResizeScrollback({ reason: 'apply-layout', screen: 'alternate' }) + ).toBe(false) + }) + + it('skips restream when provider state cannot prove the screen', () => { + expect( + shouldRestreamMobileResizeScrollback({ reason: 'apply-layout', screen: 'unknown' }) + ).toBe(false) + }) + + it('skips restream for a dimensionless mode change', () => { + expect(shouldRestreamMobileResizeScrollback({ reason: 'display-mode', screen: 'normal' })).toBe( + false + ) + }) +}) + +describe('resolveTerminalScreenKind', () => { + it('treats a provider-preferred PTY with no tracker as unknown', () => { + expect( + resolveTerminalScreenKind({ + providerSnapshotPreferred: true, + headlessAlternateScreen: false + }) + ).toBe('unknown') + }) + + it('lets a live tracker beat stale headless evidence', () => { + expect( + resolveTerminalScreenKind({ + providerSnapshotPreferred: true, + trackedAlternateScreen: false, + headlessAlternateScreen: true + }) + ).toBe('normal') + expect( + resolveTerminalScreenKind({ + providerSnapshotPreferred: true, + trackedAlternateScreen: true, + headlessAlternateScreen: false + }) + ).toBe('alternate') + }) + + it('trusts positive headless evidence when the tracker is missing', () => { + expect( + resolveTerminalScreenKind({ + providerSnapshotPreferred: true, + headlessAlternateScreen: true + }) + ).toBe('alternate') + }) + + it('classifies a tracked or headless main buffer as normal', () => { + expect( + resolveTerminalScreenKind({ + providerSnapshotPreferred: false, + headlessAlternateScreen: false + }) + ).toBe('normal') + }) +}) diff --git a/src/main/runtime/rpc/mobile-terminal-resize-restream.ts b/src/main/runtime/rpc/mobile-terminal-resize-restream.ts new file mode 100644 index 00000000000..72c0f379046 --- /dev/null +++ b/src/main/runtime/rpc/mobile-terminal-resize-restream.ts @@ -0,0 +1,40 @@ +export type TerminalScreenKind = 'alternate' | 'normal' | 'unknown' + +/** Whether a mobile resize should replay the full scrollback snapshot. + * + * Alternate-screen TUIs own their repaint. An unknown screen is treated the + * same: a provider-preferred suffix can miss the 1049h that entered alt + * screen, and restreaming that as shell history remounts the TUI. */ +export function shouldRestreamMobileResizeScrollback(args: { + reason: string + screen: TerminalScreenKind +}): boolean { + return args.reason === 'apply-layout' && args.screen === 'normal' +} + +/** Classify alt-screen from the same evidence isTerminalAlternateScreen uses. + * Missing tracker on a provider-preferred PTY is unknown, not "normal". */ +export function resolveTerminalScreenKind(args: { + providerSnapshotPreferred: boolean + trackedAlternateScreen?: boolean + headlessAlternateScreen?: boolean +}): TerminalScreenKind { + if (args.trackedAlternateScreen === true) { + return 'alternate' + } + if (args.trackedAlternateScreen === false) { + return 'normal' + } + // Why: a live 1049l must beat a stale headless true; only consult headless + // when the provider tracker has not spoken. + if (args.headlessAlternateScreen === true) { + return 'alternate' + } + if (args.providerSnapshotPreferred) { + return 'unknown' + } + if (args.headlessAlternateScreen === false) { + return 'normal' + } + return 'unknown' +} diff --git a/src/main/runtime/rpc/terminal-subscribe-unknown-screen-restream.test.ts b/src/main/runtime/rpc/terminal-subscribe-unknown-screen-restream.test.ts new file mode 100644 index 00000000000..fc17abbf181 --- /dev/null +++ b/src/main/runtime/rpc/terminal-subscribe-unknown-screen-restream.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from './dispatcher' +import type { RpcRequest } from './core' +import type { OrcaRuntimeService } from '../orca-runtime' +import { TERMINAL_METHODS } from './methods/terminal' +import type { RuntimeTerminalWait } from '../../../shared/runtime-types' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame +} from '../../../shared/terminal-stream-protocol' + +function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { + return { + getRuntimeId: () => 'test-runtime', + registerRemoteTerminalViewSubscriber: () => () => {}, + requestRendererTerminalTabMount: () => false, + ...overrides + } as OrcaRuntimeService +} + +const makeRequest = (method: string, params?: unknown): RpcRequest => ({ + id: 'req-1', + authToken: 'tok', + method, + params +}) + +describe('terminal subscribe unknown-screen restream', () => { + it('does not restream scrollback when the screen kind is unknown', async () => { + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + let resizeListener: + | ((event: { + cols: number + rows: number + displayMode: string + reason: string + seq: number + }) => void) + | undefined + const serializeTerminalBuffer = vi.fn().mockResolvedValue({ + data: 'initial', + cols: 80, + rows: 24 + }) + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer, + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + getTerminalScreenKind: vi.fn().mockReturnValue('unknown'), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + handleMobileSubscribe: vi.fn().mockResolvedValue(undefined), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + subscribeToTerminalResize: vi.fn((_, listener) => { + resizeListener = listener as typeof resizeListener + return vi.fn() + }), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + updateMobileViewport: vi.fn().mockResolvedValue({ updated: true, applied: true }) + }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1 } + }), + () => {}, + { + connectionId: 'conn-unknown-screen', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + } + } + ) + + await vi.waitFor(() => expect(resizeListener).toBeDefined()) + serializeTerminalBuffer.mockClear() + binaryFrames.splice(0) + + resizeListener?.({ + cols: 90, + rows: 24, + displayMode: 'auto', + reason: 'apply-layout', + seq: 2 + }) + await Promise.resolve() + await Promise.resolve() + + expect(serializeTerminalBuffer).not.toHaveBeenCalled() + const opcodes = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)?.opcode) + .filter((opcode) => opcode != null) + expect(opcodes).toContain(TerminalStreamOpcode.Resized) + expect(opcodes).not.toContain(TerminalStreamOpcode.SnapshotStart) + + runtime.cleanupSubscription('terminal-1:phone-1') + await dispatchPromise + }) +})