fix(mobile): stop Claude TUI remounts on Linux resize restreams

Linux daemon-backed PTYs can sit in provider-preferred mode without a
proven alt-screen tracker, so every apply-layout restreamed scrollback
and mobile init()'d xterm. Treat unknown screens as non-restreamable
and ignore empty resized snapshots so Claude Code keeps its live TUI.

STA-3992
This commit is contained in:
Brennan Benson
2026-08-12 19:29:44 -07:00
parent f1ee3088a5
commit ca258976ec
9 changed files with 310 additions and 13 deletions
@@ -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)
}
@@ -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' })
})
})
@@ -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' }
}
+12
View File
@@ -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({
+15 -8
View File
@@ -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
+11 -2
View File
@@ -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<boolean> {
// 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)
@@ -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')
})
})
@@ -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'
}
@@ -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> = {}): 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<ArrayBufferLike>[] = []
const cleanups = new Map<string, () => 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<RuntimeTerminalWait>(() => {})),
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
})
})