fix(terminal): report focus-out to ?1004 programs when their last viewer leaves (#38730)

* fix(terminal): report focus-out to ?1004 programs when their last viewer leaves

A viewer that vanishes (laptop sleep, dropped link, app quit) never sends the
ESC [ O its xterm would send on blur, so programs that armed focus reporting
kept believing they were focused. The daemon now remembers the last focus
report each PTY heard and sends one ESC [ O when its last client detaches.
orcad asks it to when the last device that viewed a PTY disconnects; a
resubscribe from a still-connected device is not a departure. A daemon that
predates the request rejects it, and the adapter stops asking that client.

Fixes #28350

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(terminal): keep the daemon router under its line cap and justify focus-loss test casts

Refs #28350

The subscribe-buffer test now expects the viewer device argument.

* fix(terminal): let a hidden remaining pane yield focus-out on device departure

Refs #28350

Build on main's pane-visibility signal: a remaining remote view whose
pane is hidden already blurred, so it no longer holds back the focus-out
for a departed device. Views that cannot report a hidden pane still do.

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Neil
2026-11-06 06:58:11 -08:00
committed by GitHub
co-authored by Claude
parent 0f6448adf6
commit a6c78e3de3
31 changed files with 494 additions and 35 deletions
@@ -174,6 +174,15 @@ export type TerminalBufferActionRequest = {
}
}
// Viewers live above the daemon on orcad; an older daemon rejects this, which callers ignore.
export type ReportFocusLostRequest = {
id: string
type: 'reportFocusLost'
payload: {
sessionId: string
}
}
export type ShutdownRequest = {
id: string
type: 'shutdown'
@@ -249,6 +258,7 @@ export type DaemonRequest =
| ConfirmForegroundProcessRequest
| ConfirmShellForegroundRequest
| TerminalBufferActionRequest
| ReportFocusLostRequest
| ShutdownRequest
| PingRequest
| SystemResolverHealthRequest
@@ -0,0 +1,73 @@
import '../../../pty-subprocess/testing/mock-descendant-sweep'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { rmSync } from 'node:fs'
import type { DaemonServer } from '../../../server/daemon-server'
import type { DaemonPtyAdapter } from '../daemon-pty-adapter'
import { createMockSubprocess, startDaemonAdapterHarness } from './daemon-pty-adapter-test-harness'
const FOCUS_IN = '\x1b[I'
const FOCUS_OUT = '\x1b[O'
describe('DaemonPtyAdapter.reportFocusLost', () => {
let dir: string
let server: DaemonServer
let adapter: DaemonPtyAdapter
let subprocess: ReturnType<typeof createMockSubprocess>
beforeEach(async () => {
const harness = await startDaemonAdapterHarness(() => {
subprocess = createMockSubprocess()
return subprocess
})
;({ dir, server, adapter } = harness)
})
afterEach(async () => {
adapter?.dispose()
await server?.shutdown()
rmSync(dir, { recursive: true, force: true })
})
async function focusedSession(): Promise<string> {
const { id } = await adapter.spawn({ cols: 80, rows: 24 })
subprocess._simulateData('\x1b[?1004h')
adapter.write(id, FOCUS_IN)
await vi.waitFor(() => expect(subprocess.write).toHaveBeenCalledWith(FOCUS_IN))
return id
}
it('tells the program focus is gone', async () => {
const id = await focusedSession()
await adapter.reportFocusLost(id)
expect(subprocess.write).toHaveBeenLastCalledWith(FOCUS_OUT)
})
it('leaves a daemon that predates the request, and its session, working', async () => {
const id = await focusedSession()
const router =
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: stands in for an older daemon's router, whose default branch rejects unknown types.
(server as unknown as { requestRouter: { route: (...args: never[]) => unknown } })
.requestRouter
const route = router.route.bind(router)
const rejected = vi.fn()
router.route = (clientId: never, request: never) => {
const { type } = request as { type: string }
if (type === 'reportFocusLost') {
rejected()
throw new Error(`Unknown request type: ${type}`)
}
return route(clientId, request)
}
await expect(adapter.reportFocusLost(id)).resolves.toBeUndefined()
await expect(adapter.reportFocusLost(id)).resolves.toBeUndefined()
adapter.write(id, 'ls\n')
await vi.waitFor(() => expect(subprocess.write).toHaveBeenLastCalledWith('ls\n'))
expect(subprocess.write).not.toHaveBeenCalledWith(FOCUS_OUT)
// The rejection is remembered for this client, so no further requests reach the old daemon.
expect(rejected).toHaveBeenCalledTimes(1)
})
})
@@ -2,6 +2,8 @@ import { normalizeDesktopTerminalSnapshotRows } from '../../../../shared/termina
import { parseTerminalKittyKeyboardFlags } from '../../../../shared/terminal/protocol/input-modes/terminal-kitty-keyboard-flags'
import { boundSnapshot } from './daemon-durable-history-snapshot'
import { DaemonPtySessionControl } from '../adapter/daemon-pty-session-control'
import { isUnknownRequestTypeError } from '../../endpoint/identity/daemon-endpoint-errors'
import type { DaemonClient } from '../../transport/client/daemon-client'
import { DAEMON_SESSION_SCROLLBACK_ROWS } from '../../sessions/daemon-session-scrollback-window'
import type { GetSnapshotResult } from '../../protocol/daemon-rpc-contract'
import type { PtyProviderBufferSnapshot } from '../../../providers/pty-provider-contract'
@@ -9,6 +11,8 @@ import type { PtyProviderBufferSnapshot } from '../../../providers/pty-provider-
const DURABLE_HISTORY_OVERLAY_DEADLINE_MS = 5_000
export abstract class DaemonPtyBufferSnapshots extends DaemonPtySessionControl {
private focusLossUnsupportedBy: DaemonClient | null = null
async getBufferSnapshot(
id: string,
opts: { scrollbackRows?: number } = {}
@@ -181,5 +185,21 @@ export abstract class DaemonPtyBufferSnapshots extends DaemonPtySessionControl {
this.markSessionDirty(id)
}
async reportFocusLost(id: string): Promise<void> {
const client = this.client
if (this.focusLossUnsupportedBy === client) {
return
}
try {
await client.request('reportFocusLost', { sessionId: id })
} catch (error) {
if (!isUnknownRequestTypeError(error)) {
throw error
}
// Shipped without a protocol bump; recheck once a new client reaches a replacement daemon.
this.focusLossUnsupportedBy = client
}
}
acknowledgeDataEvent(_id: string, _charCount: number): void {}
}
@@ -225,6 +225,8 @@ export class DaemonPtyRouter implements IPtyProvider {
await this.adapterFor(id).resetInputModes(id)
}
reportFocusLost = async (id: string): Promise<void> => this.adapterFor(id).reportFocusLost?.(id)
async closeStartupQueryAuthority(id: string): Promise<number> {
return (await this.adapterFor(id).closeStartupQueryAuthority?.(id)) ?? 0
}
@@ -0,0 +1,39 @@
import type { IPtyProvider, PtyDataEvent } from '../../../providers/pty-provider-contract'
type ExitListener = Parameters<IPtyProvider['onExit']>[0]
type ExitEvent = Parameters<ExitListener>[0]
/** The degraded provider's own data and exit listeners, fed by every routed provider. */
export class DegradedDaemonListeners {
private readonly dataListeners: ((payload: PtyDataEvent) => void)[] = []
private readonly exitListeners: ExitListener[] = []
onData(callback: (payload: PtyDataEvent) => void): () => void {
return addListener(this.dataListeners, callback)
}
onExit(callback: ExitListener): () => void {
return addListener(this.exitListeners, callback)
}
emitData(payload: PtyDataEvent): void {
this.dataListeners.forEach((listener) => listener(payload))
}
emitExit(payload: ExitEvent): void {
// Listeners may unsubscribe during iteration.
for (const listener of Array.from(this.exitListeners)) {
listener(payload)
}
}
}
function addListener<T>(listeners: T[], callback: T): () => void {
listeners.push(callback)
return () => {
const idx = listeners.indexOf(callback)
if (idx !== -1) {
listeners.splice(idx, 1)
}
}
}
@@ -3,6 +3,7 @@ import { terminateResolvedDaemonSession } from '../../server/daemon-fenced-termi
import type { PtyFencedTerminationRequest } from '../../../../shared/terminal/pty/lifecycle/pty-fenced-termination'
import type { DaemonPtyAdapter } from '../adapter/daemon-pty-adapter'
import { combineUnsubscribes } from './combine-unsubscribes'
import { DegradedDaemonListeners } from './degraded-daemon-listeners'
import { shutdownDegradedFallbackSessions } from './degraded-daemon-fallback-shutdown'
import { inspectPtyProviderProcess } from '../../../providers/pty-process-inspection'
import type {
@@ -35,8 +36,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
private freshSpawns: DegradedDaemonFreshSpawnRouter
private ownerRecovery: DegradedDaemonOwnerRecovery
private unsubscribers: (() => void)[] = []
private dataListeners: ((payload: PtyDataEvent) => void)[] = []
private exitListeners: Parameters<IPtyProvider['onExit']>[0][] = []
private readonly listeners = new DegradedDaemonListeners()
constructor(opts: {
current: DaemonPtyAdapter
@@ -62,10 +62,10 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
for (const provider of this.allProviders()) {
this.unsubscribers.push(
provider.onData((payload) => this.dataListeners.forEach((listener) => listener(payload))),
provider.onData((payload) => this.listeners.emitData(payload)),
provider.onExit((payload) => {
this.ownerRecovery.forgetRoute(payload.id)
this.exitListeners.forEach((listener) => listener(payload))
this.listeners.emitExit(payload)
})
)
}
@@ -174,6 +174,10 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
clearBuffer = (id: string): Promise<void> => this.providerFor(id).clearBuffer(id)
resetInputModes = (id: string): Promise<void> => this.providerFor(id).resetInputModes(id)
async reportFocusLost(id: string): Promise<void> {
await this.providerFor(id).reportFocusLost?.(id)
}
async closeStartupQueryAuthority(id: string): Promise<number> {
return (await this.providerFor(id).closeStartupQueryAuthority?.(id)) ?? 0
}
@@ -229,15 +233,8 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
return this.fallback.getProfiles()
}
onData(callback: (payload: PtyDataEvent) => void): () => void {
this.dataListeners.push(callback)
return () => {
const idx = this.dataListeners.indexOf(callback)
if (idx !== -1) {
this.dataListeners.splice(idx, 1)
}
}
}
onData = (callback: (payload: PtyDataEvent) => void): (() => void) =>
this.listeners.onData(callback)
onBackgroundStreamEvent(callback: (payload: PtyBackgroundStreamEvent) => void): () => void {
return combineUnsubscribes(
@@ -270,15 +267,8 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
return trackedUnsubscribe
}
onExit(callback: Parameters<IPtyProvider['onExit']>[0]): () => void {
this.exitListeners.push(callback)
return () => {
const idx = this.exitListeners.indexOf(callback)
if (idx !== -1) {
this.exitListeners.splice(idx, 1)
}
}
}
onExit = (callback: Parameters<IPtyProvider['onExit']>[0]): (() => void) =>
this.listeners.onExit(callback)
ackColdRestore(sessionId: string): void {
findDaemonAdapter(this.sessionProviders, this.allDaemonAdapters(), sessionId)?.ackColdRestore(
@@ -323,10 +313,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
for (const id of this.getSessionIdsRoutedTo('current-daemon')) {
this.ownerRecovery.forgetRoute(id)
// Restart also kills listed sessions the adapter never tracked as active.
// Why: listeners may unsubscribe during iteration.
for (const listener of Array.from(this.exitListeners)) {
listener({ id, code, ...exit })
}
this.listeners.emitExit({ id, code, ...exit })
}
}
@@ -129,6 +129,7 @@ function createDaemonAdapter(
label: string,
sessions: string[] = []
): DaemonPtyAdapter & ProviderMock {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the degraded router reaches only these adapter members.
return {
...createProvider(label, sessions, true),
protocolVersion: 13,
@@ -137,6 +138,7 @@ function createDaemonAdapter(
listSessions: vi.fn(async () => []),
ackColdRestore: vi.fn(),
clearTombstone: vi.fn(),
reportFocusLost: vi.fn(async () => {}),
reconcileOnStartup: vi.fn(async () => ({ alive: sessions, killed: [] })),
dispose: vi.fn(),
disconnectOnly: vi.fn(async () => {}),
@@ -145,6 +147,22 @@ function createDaemonAdapter(
} as unknown as DaemonPtyAdapter & ProviderMock
}
it('reports focus lost to the daemon that owns the session', async () => {
const current = createDaemonAdapter('daemon')
const legacy = createDaemonAdapter('legacy', ['legacy-session'])
const provider = new DegradedDaemonPtyProvider({
current,
legacy: [legacy],
fallback: createProvider('fallback')
})
await provider.discoverDaemonSessions()
await provider.reportFocusLost('legacy-session')
expect(legacy.reportFocusLost).toHaveBeenCalledWith('legacy-session')
expect(current.reportFocusLost).not.toHaveBeenCalled()
})
it('confirms repeated stale-binding absence without falling back or spawning', async () => {
const current = createDaemonAdapter('daemon-current')
const legacy = createDaemonAdapter('daemon-legacy')
@@ -152,6 +152,9 @@ export class DaemonRequestRouter {
case 'resetInputModes':
this.options.host.resetInputModes(request.payload.sessionId)
return {}
case 'reportFocusLost':
this.options.host.reportFocusLost(request.payload.sessionId)
return {}
case 'listSessions':
return { sessions: this.options.host.listSessions() }
case 'shutdownIfIdle':
@@ -0,0 +1,27 @@
const FOCUS_IN = '\x1b[I'
const FOCUS_OUT = '\x1b[O'
/** Tracks the focus report a ?1004 program last received, so losing every viewer reports focus-out once. */
export class SessionFocusReport {
// The arming that last heard ESC [ O; null once ESC [ I follows or nothing was reported to it.
private focusOutArming: number | null = null
observeInput(data: string, arming: number | null): void {
const focusOutAt = data.lastIndexOf(FOCUS_OUT)
const focusInAt = data.lastIndexOf(FOCUS_IN)
if (focusOutAt > focusInAt) {
this.focusOutArming = arming
} else if (focusInAt > focusOutAt) {
this.focusOutArming = null
}
}
/** The focus-out a vanished viewer never sent, or null when the program is unarmed or already told. */
takeFocusLoss(arming: number | null): string | null {
if (arming === null || this.focusOutArming === arming) {
return null
}
this.focusOutArming = arming
return FOCUS_OUT
}
}
+15
View File
@@ -3,6 +3,7 @@ import { isValidPtySize } from '../protocol/daemon-pty-size'
import type { SessionOutputPlane, AttachedClient } from './session-output-plane'
import { createSessionOutputPipeline, createSessionShellReady } from './session-output-pipeline'
import { SessionProducerPause } from './session-producer-pause'
import { SessionFocusReport } from './session-focus-report'
import type { SessionShellReadyBarrier } from './session-shell-ready-barrier'
import type { TerminalShellRecoveryBarrier } from '../shell-readiness/terminal-shell-recovery-barrier'
import { SessionTerminationController } from './session-termination-controller'
@@ -41,6 +42,7 @@ export class Session extends SessionProcessInspection {
private readonly termination: SessionTerminationController
readonly startupIngress: PtyStartupIngress
private readonly recoveryBarrier: TerminalShellRecoveryBarrier
private readonly focusReport = new SessionFocusReport()
constructor(opts: SessionOptions) {
super(opts.subprocess)
@@ -139,10 +141,22 @@ export class Session extends SessionProcessInspection {
write(data: string): void {
// Human input must reach rc-file readers while automatic startup commands wait for readiness.
if (this.isAlive && !this._disposed && !this.startupIngress.answerLiveQueryReply(data)) {
this.focusReport.observeInput(data, this.recoveryBarrier.focusReportingArming)
this.subprocess.write(data)
}
}
/** No viewer is left to blur, so tell a ?1004 program focus is gone unless it already heard so. */
reportFocusLost(): void {
if (!this.isAlive || this._disposed) {
return
}
const focusOut = this.focusReport.takeFocusLoss(this.recoveryBarrier.focusReportingArming)
if (focusOut) {
this.subprocess.write(focusOut)
}
}
/** Orca's own startup command: held by the shell-ready barrier until the shell can read it. */
writeStartupCommand(data: string): void {
if (this._state !== 'exited' && !this._disposed && !this.shellReady.tryEnqueue(data)) {
@@ -197,6 +211,7 @@ export class Session extends SessionProcessInspection {
// With no viewer left to resume the producer, release its pause eagerly.
if (!this.output.hasAttachedClients) {
this.producerPause.release({ resume: true })
this.reportFocusLost()
}
}
@@ -0,0 +1,87 @@
import { describe, expect, it, vi } from 'vitest'
import { Session } from '../session'
import type { SubprocessHandle } from '../session-subprocess-handle'
const FOCUS_IN = '\x1b[I'
const FOCUS_OUT = '\x1b[O'
function createSession() {
let onData: ((data: string) => void) | null = null
const written: string[] = []
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Session reads only these members.
const handle = {
pid: 999,
getForegroundProcess: () => null,
confirmShellForeground: vi.fn(async () => false),
write: (data: string) => written.push(data),
resize: () => {},
pause: () => {},
resume: () => {},
kill: () => {},
forceKill: () => {},
signal: () => {},
terminateOwnedTree: () => 'unavailable' as const,
onData(cb: (data: string) => void) {
onData = cb
},
onExit() {},
dispose: () => {}
} as unknown as SubprocessHandle
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Session reads only the options given here.
const session = new Session({
sessionId: 'focus',
cols: 80,
rows: 24,
subprocess: handle,
shellReadySupported: false
} as never)
const emit = async (data: string): Promise<void> => {
onData?.(data)
await session.settleShellOwnershipConfirmation()
}
const attach = () => session.attachClient({ onData: () => {}, onExit: () => {} })
return { session, written, emit, attach }
}
describe('Session focus loss', () => {
it('tells a ?1004 program focus is gone when its last viewer detaches, once', async () => {
const { session, written, emit, attach } = createSession()
const first = attach()
const second = attach()
await emit('\x1b[?1004h')
session.write(FOCUS_IN)
session.detachClient(first)
expect(written).toEqual([FOCUS_IN])
session.detachClient(second)
session.reportFocusLost()
expect(written).toEqual([FOCUS_IN, FOCUS_OUT])
session.dispose()
})
it('does not repeat a blur the viewer already sent', async () => {
const { session, written, emit, attach } = createSession()
const token = attach()
await emit('\x1b[?1004h')
session.write(FOCUS_OUT)
session.detachClient(token)
expect(written).toEqual([FOCUS_OUT])
session.dispose()
})
it('tells a newly armed program even after its predecessor heard the blur', async () => {
const { session, written, emit, attach } = createSession()
const token = attach()
await emit('\x1b[?1004h')
session.write(FOCUS_OUT)
await emit('\x1b[?1004l\x1b[?1004h')
session.detachClient(token)
expect(written).toEqual([FOCUS_OUT, FOCUS_OUT])
session.dispose()
})
})
@@ -19,11 +19,17 @@ export class TerminalArmedInputModes {
private kittyMainStack: number[] = []
private kittyAltStack: number[] = []
private onAlternateScreen = false
private focusReportingArmings = 0
get hostOwnsFocusReporting(): boolean {
return this.owners.get(HOST_ARMABLE_MODE) === 'host'
}
/** Identifies the current ?1004 arming, so a re-arming program counts as unreported; null when off. */
get focusReportingArming(): number | null {
return this.owners.has(HOST_ARMABLE_MODE) ? this.focusReportingArmings : null
}
applyPrivateMode(param: number, enabled: boolean): void {
if (TRACKED_PRIVATE_MODES.has(param)) {
this.setArmed(param, enabled)
@@ -141,6 +147,9 @@ export class TerminalArmedInputModes {
}
private setArmed(key: ModeKey, armed: boolean): void {
if (armed && key === HOST_ARMABLE_MODE && !this.owners.has(key)) {
this.focusReportingArmings += 1
}
if (!armed) {
this.owners.delete(key)
} else if (this.owners.get(key) !== 'host') {
@@ -58,6 +58,10 @@ export class TerminalShellLifecycleScanner {
return this.generationState
}
get focusReportingArming(): number | null {
return this.inputModes.focusReportingArming
}
get isAlternateScreenActive(): boolean {
return this.altActive
}
@@ -64,6 +64,11 @@ export class TerminalShellRecoveryBarrier {
this.scanAndRelease(emission)
}
/** The barrier scans every byte before any consumer, so this is the host's view of ?1004. */
get focusReportingArming(): number | null {
return this.scanner.focusReportingArming
}
getOwner(): TerminalOwner | undefined {
return this.scanner.owner
}
@@ -215,6 +215,11 @@ export class TerminalHost {
}
}
// An exited session has no program left to tell.
reportFocusLost(sessionId: string): void {
this.sessions.get(sessionId)?.reportFocusLost()
}
async getCwd(sessionId: string): Promise<string | null> {
return await resolveTerminalHostSessionCwd(
getAliveTerminalHostSession(this.sessions, sessionId)
+8 -1
View File
@@ -1,6 +1,6 @@
import { adoptMaterializedRuntimePtySpawn } from './spawn-early'
import { createRuntimePtySpawnState } from './spawn-state'
import { localProvider } from '../provider/registry'
import { getProviderForPty, localProvider } from '../provider/registry'
import {
getLocalAutomaticWorkStatus,
localTerminalLimit,
@@ -86,6 +86,13 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi
hasChildProcesses: (ptyId) => hasChildProcessesFromRuntimeController(ptyId),
clearBuffer: (ptyId) => clearBufferFromRuntimeController(deps, ptyId),
resetInputModes: (ptyId) => resetInputModesFromRuntimeController(deps, ptyId),
reportFocusLost: async (ptyId) => {
try {
await getProviderForPty(ptyId).reportFocusLost?.(ptyId)
} catch {
/* best effort: an older daemon rejects the request */
}
},
hasPty: (ptyId) => hasPtyFromRuntimeController(deps, ptyId),
getPtyAvailability: (ptyId) => getPtyAvailabilityFromRuntimeController(deps, ptyId),
listProcesses: (hostId, opts) => listProcessesFromRuntimeController(deps, hostId, opts),
@@ -193,6 +193,8 @@ export type IPtyProvider = {
clearBuffer(id: string): Promise<void>
/** Grounds the host's own terminal models (Reset Terminal); renderers ground themselves. */
resetInputModes(id: string): Promise<void>
/** Sends ESC [ O to a ?1004 program once no viewer is left to blur; hosts that own no viewers omit it. */
reportFocusLost?(id: string): Promise<void>
/** Ends the startup Kitty-query window; OSC 10/11 authority stays with the owner for life. */
closeStartupQueryAuthority?: (id: string) => Promise<number> | number
/** Host-wide viewer colours the PTY owner answers OSC 10/11 from. */
@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from './orca-runtime'
function createRuntime(hasLocalPane = false) {
const runtime = new OrcaRuntimeService()
const reportFocusLost = vi.fn(async () => {})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: viewer presence and client disconnect reach only these controller members.
runtime.setPtyController({
write: () => true,
kill: () => true,
attach: vi.fn(async () => {}),
hasRendererSerializer: () => hasLocalPane,
reportFocusLost
} as never)
const view = (deviceId: string, paneHidden?: () => boolean) =>
runtime.registerRemoteTerminalViewSubscriber(
'pty-1',
paneHidden,
undefined,
paneHidden !== undefined,
deviceId
)
return { runtime, reportFocusLost, view }
}
describe('remote terminal viewer focus loss', () => {
it('reports focus lost when the last device viewing a PTY disconnects', () => {
const { runtime, reportFocusLost, view } = createRuntime()
const laptop = view('laptop')
const otherLaptop = view('other-laptop')
laptop()
runtime.onClientDisconnected('laptop')
expect(reportFocusLost).not.toHaveBeenCalled()
otherLaptop()
runtime.onClientDisconnected('other-laptop')
expect(reportFocusLost).toHaveBeenCalledTimes(1)
expect(reportFocusLost).toHaveBeenCalledWith('pty-1')
})
it('reports focus lost while the only remaining view has its pane hidden', () => {
const { runtime, reportFocusLost, view } = createRuntime()
let otherPaneHidden = true
view('other-laptop', () => otherPaneHidden)
view('laptop')()
runtime.onClientDisconnected('laptop')
expect(reportFocusLost).toHaveBeenCalledWith('pty-1')
reportFocusLost.mockClear()
otherPaneHidden = false
view('laptop')()
runtime.onClientDisconnected('laptop')
expect(reportFocusLost).not.toHaveBeenCalled()
})
it('treats a resubscribe from a still-connected device as no departure', () => {
const { runtime, reportFocusLost, view } = createRuntime()
view('laptop')()
view('laptop')
runtime.onClientDisconnected('phone')
expect(reportFocusLost).not.toHaveBeenCalled()
})
it('leaves focus to a mounted local pane', () => {
const { runtime, reportFocusLost, view } = createRuntime(true)
view('phone')()
runtime.onClientDisconnected('phone')
expect(reportFocusLost).not.toHaveBeenCalled()
})
})
@@ -21,6 +21,8 @@ export type MultiplexEmit = (result: unknown) => void
export type TerminalMultiplexConnectionBase = {
runtime: RpcContext['runtime']
connectionId: string
// The authenticated device, whose departure ends this connection's views.
viewerDeviceId: string | undefined
sendBinary: NonNullable<RpcContext['sendBinary']>
registerBinaryStreamHandler: NonNullable<RpcContext['registerBinaryStreamHandler']>
signal: RpcContext['signal']
@@ -22,7 +22,7 @@ export const TERMINAL_MULTIPLEX_METHODS = [
params: TerminalMultiplex,
handler: async (
_params,
{ runtime, connectionId, sendBinary, registerBinaryStreamHandler, signal },
{ runtime, connectionId, sendBinary, registerBinaryStreamHandler, signal, clientId },
emit
) => {
if (!sendBinary || !registerBinaryStreamHandler || !connectionId) {
@@ -37,6 +37,7 @@ export const TERMINAL_MULTIPLEX_METHODS = [
const state = {
runtime,
connectionId,
viewerDeviceId: clientId,
sendBinary,
registerBinaryStreamHandler,
signal,
@@ -110,7 +110,8 @@ export async function initializeMultiplexStream(
ptyId,
() => stream.outputPaused,
isMobile ? request.client?.id : undefined,
stream.supportsOutputPause && request.capabilities?.paneVisibility === 1
stream.supportsOutputPause && request.capabilities?.paneVisibility === 1,
state.viewerDeviceId
)
// Report daemon death now, before the next refused keystroke.
const unsubscribeEndpointLoss = stream.supportsEndpointLossNotice
@@ -25,7 +25,7 @@ import { activateTerminalSubscribeBinaryLive } from './terminal-subscribe-binary
import { registerTerminalSubscribeBinaryControlFrames } from './terminal-subscribe-binary-control-frames'
const TERMINAL_QUERY_REPLAY_MAX_CHARS = 16 * 1024
export async function runTerminalBinarySubscription(args: TerminalSubscriptionArgs): Promise<void> {
const { runtime, registration, sendBinary, ptyId, clientId, isMobile } = args
const { runtime, registration, sendBinary, ptyId, clientId, viewerDeviceId, isMobile } = args
if (!sendBinary) {
throw new Error('binary_terminal_stream_required')
}
@@ -165,7 +165,9 @@ export async function runTerminalBinarySubscription(args: TerminalSubscriptionAr
const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(
ptyId,
undefined,
isMobile ? clientId : undefined
isMobile ? clientId : undefined,
false,
viewerDeviceId
)
unsubscribeData = () => {
releaseViewSubscriber()
@@ -92,7 +92,13 @@ export async function runTerminalJsonSubscription(args: TerminalSubscriptionArgs
outputBatcher?.push(data)
})
// JSON streams may feed xterm; register a view because withholding model replies is safer than duplicate replies.
const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId)
const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(
ptyId,
undefined,
undefined,
false,
args.viewerDeviceId
)
unsubscribeData = () => {
releaseViewSubscriber()
unsubscribeStreamData()
@@ -25,7 +25,8 @@ export const TERMINAL_SUBSCRIBE_METHODS = [
registerBinaryStreamHandler,
signal,
clientKind,
pairedDeviceId
pairedDeviceId,
clientId: viewerDeviceId
},
emit
) => {
@@ -99,6 +100,7 @@ export const TERMINAL_SUBSCRIBE_METHODS = [
emit,
ptyId,
clientId,
viewerDeviceId,
isMobile,
supportsDesktopViewportClaims: params.capabilities?.desktopViewportClaims === 1,
supportsWriteUnavailable: params.capabilities?.writeUnavailable === 1,
@@ -19,6 +19,8 @@ export type TerminalSubscriptionArgs = {
emit: TerminalSubscriptionEmit
ptyId: string
clientId: string | undefined
// The authenticated device, whose departure ends this view; request ids are client-declared.
viewerDeviceId: string | undefined
isMobile: boolean
supportsDesktopViewportClaims: boolean
supportsWriteUnavailable: boolean
@@ -143,7 +143,13 @@ describe('terminal subscribe buffering', () => {
await vi.waitFor(() => expect(runtime.handleMobileSubscribe).toHaveBeenCalled())
expect(dataListener).toBeDefined()
expect(registerRemoteTerminalViewSubscriber).toHaveBeenCalledWith('pty-1', undefined, 'phone-1')
expect(registerRemoteTerminalViewSubscriber).toHaveBeenCalledWith(
'pty-1',
undefined,
'phone-1',
false,
undefined
)
dataListener?.('\x1b[6n', { seq: 4, rawLength: 4 })
resolveMobileSubscribe()
@@ -174,6 +174,7 @@ export type RuntimePtyController = {
hasChildProcesses?(ptyId: string): Promise<boolean>
clearBuffer?(ptyId: string): Promise<void>
resetInputModes?(ptyId: string): Promise<void>
reportFocusLost?(ptyId: string): Promise<void>
resize?(ptyId: string, cols: number, rows: number): boolean
// Exact-id mobile polls must not enumerate every local/SSH PTY.
hasPty?(ptyId: string): boolean | null
@@ -153,5 +153,7 @@ export abstract class OrcaRuntimeWithOnClientDisconnected extends OrcaRuntimeWit
const rows = override.previousRows ?? fallback.rows
void this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows })
}
// Last: the device's streams and phone presence are released above.
this.reportFocusLostForDepartedDevice(clientId)
}
}
@@ -12,6 +12,7 @@ import type { RuntimeTerminalAttention } from '../../../../shared/runtime-termin
import type { RuntimeTerminalBufferSnapshot } from '../records/runtime-terminal-state-records'
import { AUTHORITATIVE_TERMINAL_SNAPSHOT_TIMEOUT_MS } from '../output/provider-snapshot-budget'
import { assertTerminalInputWithinLimitWithYield } from '../../terminal-send-payload'
import { TerminalViewerDevices } from './terminal-viewer-devices'
// Covers a daemon reconnect plus the attach; past it the write proceeds and is refused as before.
const ENDPOINT_REATTACH_INPUT_WAIT_MS = 10_000
@@ -46,6 +47,23 @@ export abstract class OrcaRuntimeWithNotifyRemoteTerminalViewPresenceChanged ext
options: RuntimeTerminalWriteOptions
): Promise<WriteSettlement | undefined>
private readonly terminalViewerDevices = new TerminalViewerDevices()
/**
* A departed device's xterm never blurred; tell PTYs that no remote viewer or local pane still
* shows. A remaining view whose pane is hidden already blurred, so it does not hold focus.
*/
protected reportFocusLostForDepartedDevice(deviceClientId: string): void {
for (const ptyId of this.terminalViewerDevices.takeDeparted(deviceClientId)) {
if (
!this.terminalViewSubscribers.mayBeShownRemotely(ptyId) &&
this.ptyController?.hasRendererSerializer?.(ptyId) !== true
) {
void this.ptyController?.reportFocusLost?.(ptyId)
}
}
}
protected notifyRemoteTerminalViewPresenceChanged(ptyId: string): void {
try {
this.onRemoteTerminalViewPresenceChanged?.(ptyId)
@@ -60,8 +78,12 @@ export abstract class OrcaRuntimeWithNotifyRemoteTerminalViewPresenceChanged ext
isOutputPaused?: () => boolean,
mobileClientId?: string,
/** Only for views that pause output while their pane is hidden, so "unpaused" means on screen. */
reportsHiddenPane = false
reportsHiddenPane = false,
viewerDeviceId?: string
): () => void {
if (viewerDeviceId) {
this.terminalViewerDevices.record(viewerDeviceId, ptyId)
}
const isSilent =
mobileClientId === undefined
? isOutputPaused
@@ -0,0 +1,16 @@
/** Which PTYs each connected client device has viewed, so its departure can stand in for the blur it never sent. */
export class TerminalViewerDevices {
private readonly ptyIdsByDevice = new Map<string, Set<string>>()
record(deviceClientId: string, ptyId: string): void {
const ptyIds = this.ptyIdsByDevice.get(deviceClientId) ?? new Set<string>()
ptyIds.add(ptyId)
this.ptyIdsByDevice.set(deviceClientId, ptyIds)
}
takeDeparted(deviceClientId: string): string[] {
const ptyIds = this.ptyIdsByDevice.get(deviceClientId)
this.ptyIdsByDevice.delete(deviceClientId)
return ptyIds ? [...ptyIds] : []
}
}
@@ -63,6 +63,16 @@ export class RuntimeTerminalViewSubscribers {
return false
}
/** A remote view could be displaying this PTY: one is on screen, or one cannot report a hidden pane. */
mayBeShownRemotely(ptyId: string): boolean {
const paneReporting = this.paneReportingRemoteViews.get(ptyId)?.size ?? 0
return (
this.isShownRemotely(ptyId) ||
(this.remoteCounts.get(ptyId) ?? 0) > paneReporting ||
this.deps.hasMobileSubscribers(ptyId)
)
}
registerRaw(ptyId: string): () => void {
this.rawCounts.set(ptyId, (this.rawCounts.get(ptyId) ?? 0) + 1)
this.deps.notifyPresenceChanged(ptyId)