mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 16:03:26 +00:00
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:
@@ -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' }
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user