From a6c78e3de39793513a94508564673efb14c4782f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 6 Nov 2026 06:58:11 -0800 Subject: [PATCH] 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 * 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 --- .../daemon/protocol/daemon-rpc-contract.ts | 10 +++ .../daemon-pty-adapter-focus-loss.test.ts | 73 ++++++++++++++++ .../daemon-pty-buffer-snapshots.ts | 20 +++++ .../providers/routing/daemon-pty-router.ts | 2 + .../routing/degraded-daemon-listeners.ts | 39 +++++++++ .../routing/degraded-daemon-pty-provider.ts | 39 +++------ .../degraded-daemon-pty-provider.test.ts | 18 ++++ .../daemon/server/daemon-request-router.ts | 3 + .../daemon/sessions/session-focus-report.ts | 27 ++++++ src/main/daemon/sessions/session.ts | 15 ++++ .../testing/session-focus-loss.test.ts | 87 +++++++++++++++++++ .../terminal-armed-input-modes.ts | 9 ++ .../terminal-shell-lifecycle-scanner.ts | 4 + .../terminal-shell-recovery-barrier.ts | 5 ++ .../daemon/terminal-host/terminal-host.ts | 5 ++ src/main/ipc/pty/runtime/controller.ts | 9 +- src/main/providers/pty-provider-contract.ts | 2 + .../remote-terminal-viewer-focus-loss.test.ts | 73 ++++++++++++++++ .../terminal/terminal-multiplex-connection.ts | 2 + .../terminal/terminal-multiplex-method.ts | 3 +- ...erminal-multiplex-stream-initialization.ts | 3 +- .../terminal/terminal-subscribe-binary.ts | 6 +- .../terminal-subscribe-json-and-lease.ts | 8 +- .../terminal/terminal-subscribe-method.ts | 4 +- .../terminal/terminal-subscribe-types.ts | 2 + .../rpc/terminal-subscribe-buffer.test.ts | 8 +- .../runtime-pty-controller-contract.ts | 1 + .../layout/terminal-client-disconnect.ts | 2 + .../terminal/layout/terminal-view-presence.ts | 24 ++++- .../layout/terminal-viewer-devices.ts | 16 ++++ .../view/runtime-terminal-view-subscribers.ts | 10 +++ 31 files changed, 494 insertions(+), 35 deletions(-) create mode 100644 src/main/daemon/providers/adapter/testing/daemon-pty-adapter-focus-loss.test.ts create mode 100644 src/main/daemon/providers/routing/degraded-daemon-listeners.ts create mode 100644 src/main/daemon/sessions/session-focus-report.ts create mode 100644 src/main/daemon/sessions/testing/session-focus-loss.test.ts create mode 100644 src/main/runtime/remote-terminal-viewer-focus-loss.test.ts create mode 100644 src/main/runtime/terminal/layout/terminal-viewer-devices.ts diff --git a/src/main/daemon/protocol/daemon-rpc-contract.ts b/src/main/daemon/protocol/daemon-rpc-contract.ts index 0a7664da512..e5d5c088ccd 100644 --- a/src/main/daemon/protocol/daemon-rpc-contract.ts +++ b/src/main/daemon/protocol/daemon-rpc-contract.ts @@ -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 diff --git a/src/main/daemon/providers/adapter/testing/daemon-pty-adapter-focus-loss.test.ts b/src/main/daemon/providers/adapter/testing/daemon-pty-adapter-focus-loss.test.ts new file mode 100644 index 00000000000..dbcc32fec7e --- /dev/null +++ b/src/main/daemon/providers/adapter/testing/daemon-pty-adapter-focus-loss.test.ts @@ -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 + + 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 { + 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) + }) +}) diff --git a/src/main/daemon/providers/checkpointing/daemon-pty-buffer-snapshots.ts b/src/main/daemon/providers/checkpointing/daemon-pty-buffer-snapshots.ts index 1c6a9d6261d..c353e9bb36f 100644 --- a/src/main/daemon/providers/checkpointing/daemon-pty-buffer-snapshots.ts +++ b/src/main/daemon/providers/checkpointing/daemon-pty-buffer-snapshots.ts @@ -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 { + 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 {} } diff --git a/src/main/daemon/providers/routing/daemon-pty-router.ts b/src/main/daemon/providers/routing/daemon-pty-router.ts index ea232fde507..4ba51fe9edf 100644 --- a/src/main/daemon/providers/routing/daemon-pty-router.ts +++ b/src/main/daemon/providers/routing/daemon-pty-router.ts @@ -225,6 +225,8 @@ export class DaemonPtyRouter implements IPtyProvider { await this.adapterFor(id).resetInputModes(id) } + reportFocusLost = async (id: string): Promise => this.adapterFor(id).reportFocusLost?.(id) + async closeStartupQueryAuthority(id: string): Promise { return (await this.adapterFor(id).closeStartupQueryAuthority?.(id)) ?? 0 } diff --git a/src/main/daemon/providers/routing/degraded-daemon-listeners.ts b/src/main/daemon/providers/routing/degraded-daemon-listeners.ts new file mode 100644 index 00000000000..8a6d78818d3 --- /dev/null +++ b/src/main/daemon/providers/routing/degraded-daemon-listeners.ts @@ -0,0 +1,39 @@ +import type { IPtyProvider, PtyDataEvent } from '../../../providers/pty-provider-contract' + +type ExitListener = Parameters[0] +type ExitEvent = Parameters[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(listeners: T[], callback: T): () => void { + listeners.push(callback) + return () => { + const idx = listeners.indexOf(callback) + if (idx !== -1) { + listeners.splice(idx, 1) + } + } +} diff --git a/src/main/daemon/providers/routing/degraded-daemon-pty-provider.ts b/src/main/daemon/providers/routing/degraded-daemon-pty-provider.ts index 76281af0352..58164a96f2b 100644 --- a/src/main/daemon/providers/routing/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/providers/routing/degraded-daemon-pty-provider.ts @@ -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[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 => this.providerFor(id).clearBuffer(id) resetInputModes = (id: string): Promise => this.providerFor(id).resetInputModes(id) + async reportFocusLost(id: string): Promise { + await this.providerFor(id).reportFocusLost?.(id) + } + async closeStartupQueryAuthority(id: string): Promise { 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[0]): () => void { - this.exitListeners.push(callback) - return () => { - const idx = this.exitListeners.indexOf(callback) - if (idx !== -1) { - this.exitListeners.splice(idx, 1) - } - } - } + onExit = (callback: Parameters[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 }) } } diff --git a/src/main/daemon/providers/routing/testing/degraded-daemon-pty-provider.test.ts b/src/main/daemon/providers/routing/testing/degraded-daemon-pty-provider.test.ts index 017b5db74a6..5ca07c087d0 100644 --- a/src/main/daemon/providers/routing/testing/degraded-daemon-pty-provider.test.ts +++ b/src/main/daemon/providers/routing/testing/degraded-daemon-pty-provider.test.ts @@ -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') diff --git a/src/main/daemon/server/daemon-request-router.ts b/src/main/daemon/server/daemon-request-router.ts index c05d5629ecf..3955908162e 100644 --- a/src/main/daemon/server/daemon-request-router.ts +++ b/src/main/daemon/server/daemon-request-router.ts @@ -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': diff --git a/src/main/daemon/sessions/session-focus-report.ts b/src/main/daemon/sessions/session-focus-report.ts new file mode 100644 index 00000000000..2cb28d68bee --- /dev/null +++ b/src/main/daemon/sessions/session-focus-report.ts @@ -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 + } +} diff --git a/src/main/daemon/sessions/session.ts b/src/main/daemon/sessions/session.ts index dca4909cf34..a816dcb863e 100644 --- a/src/main/daemon/sessions/session.ts +++ b/src/main/daemon/sessions/session.ts @@ -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() } } diff --git a/src/main/daemon/sessions/testing/session-focus-loss.test.ts b/src/main/daemon/sessions/testing/session-focus-loss.test.ts new file mode 100644 index 00000000000..4cda36b00a9 --- /dev/null +++ b/src/main/daemon/sessions/testing/session-focus-loss.test.ts @@ -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 => { + 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() + }) +}) diff --git a/src/main/daemon/shell-readiness/terminal-armed-input-modes.ts b/src/main/daemon/shell-readiness/terminal-armed-input-modes.ts index 765828a7587..6d7b851a276 100644 --- a/src/main/daemon/shell-readiness/terminal-armed-input-modes.ts +++ b/src/main/daemon/shell-readiness/terminal-armed-input-modes.ts @@ -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') { diff --git a/src/main/daemon/shell-readiness/terminal-shell-lifecycle-scanner.ts b/src/main/daemon/shell-readiness/terminal-shell-lifecycle-scanner.ts index 349f4980755..9ead7ec87e7 100644 --- a/src/main/daemon/shell-readiness/terminal-shell-lifecycle-scanner.ts +++ b/src/main/daemon/shell-readiness/terminal-shell-lifecycle-scanner.ts @@ -58,6 +58,10 @@ export class TerminalShellLifecycleScanner { return this.generationState } + get focusReportingArming(): number | null { + return this.inputModes.focusReportingArming + } + get isAlternateScreenActive(): boolean { return this.altActive } diff --git a/src/main/daemon/shell-readiness/terminal-shell-recovery-barrier.ts b/src/main/daemon/shell-readiness/terminal-shell-recovery-barrier.ts index e06e81d39d0..fbe89b59d88 100644 --- a/src/main/daemon/shell-readiness/terminal-shell-recovery-barrier.ts +++ b/src/main/daemon/shell-readiness/terminal-shell-recovery-barrier.ts @@ -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 } diff --git a/src/main/daemon/terminal-host/terminal-host.ts b/src/main/daemon/terminal-host/terminal-host.ts index e38dd18e058..6e4934f514f 100644 --- a/src/main/daemon/terminal-host/terminal-host.ts +++ b/src/main/daemon/terminal-host/terminal-host.ts @@ -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 { return await resolveTerminalHostSessionCwd( getAliveTerminalHostSession(this.sessions, sessionId) diff --git a/src/main/ipc/pty/runtime/controller.ts b/src/main/ipc/pty/runtime/controller.ts index 02601b0c663..07b9d89b0eb 100644 --- a/src/main/ipc/pty/runtime/controller.ts +++ b/src/main/ipc/pty/runtime/controller.ts @@ -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), diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index 641f6ed10b8..9922a3d7aea 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -193,6 +193,8 @@ export type IPtyProvider = { clearBuffer(id: string): Promise /** Grounds the host's own terminal models (Reset Terminal); renderers ground themselves. */ resetInputModes(id: string): Promise + /** 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 /** Ends the startup Kitty-query window; OSC 10/11 authority stays with the owner for life. */ closeStartupQueryAuthority?: (id: string) => Promise | number /** Host-wide viewer colours the PTY owner answers OSC 10/11 from. */ diff --git a/src/main/runtime/remote-terminal-viewer-focus-loss.test.ts b/src/main/runtime/remote-terminal-viewer-focus-loss.test.ts new file mode 100644 index 00000000000..d9617300a4b --- /dev/null +++ b/src/main/runtime/remote-terminal-viewer-focus-loss.test.ts @@ -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() + }) +}) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-connection.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-connection.ts index 52fa5c120d3..2a57b8015d9 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-connection.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-connection.ts @@ -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 registerBinaryStreamHandler: NonNullable signal: RpcContext['signal'] diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-method.ts index d515740d348..059db87373b 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-method.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-method.ts @@ -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, diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-stream-initialization.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-stream-initialization.ts index 771d88bd115..c956008b89b 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-stream-initialization.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-stream-initialization.ts @@ -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 diff --git a/src/main/runtime/rpc/methods/terminal/terminal-subscribe-binary.ts b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-binary.ts index e0b68468db0..b23c065fd54 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-subscribe-binary.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-binary.ts @@ -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 { - 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() diff --git a/src/main/runtime/rpc/methods/terminal/terminal-subscribe-json-and-lease.ts b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-json-and-lease.ts index 41fbcd89723..8d1fd4a7428 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-subscribe-json-and-lease.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-json-and-lease.ts @@ -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() diff --git a/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts index eedd7177618..802b7add72d 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts @@ -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, diff --git a/src/main/runtime/rpc/methods/terminal/terminal-subscribe-types.ts b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-types.ts index d541bb1e5ad..111df5547dd 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-subscribe-types.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-types.ts @@ -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 diff --git a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts index 3a11600a570..8b4fbedbcb0 100644 --- a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts @@ -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() diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index 55c464816ec..4e2e1bd3fa3 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -174,6 +174,7 @@ export type RuntimePtyController = { hasChildProcesses?(ptyId: string): Promise clearBuffer?(ptyId: string): Promise resetInputModes?(ptyId: string): Promise + reportFocusLost?(ptyId: string): Promise resize?(ptyId: string, cols: number, rows: number): boolean // Exact-id mobile polls must not enumerate every local/SSH PTY. hasPty?(ptyId: string): boolean | null diff --git a/src/main/runtime/terminal/layout/terminal-client-disconnect.ts b/src/main/runtime/terminal/layout/terminal-client-disconnect.ts index b345b2f0c2a..e802fd64b02 100644 --- a/src/main/runtime/terminal/layout/terminal-client-disconnect.ts +++ b/src/main/runtime/terminal/layout/terminal-client-disconnect.ts @@ -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) } } diff --git a/src/main/runtime/terminal/layout/terminal-view-presence.ts b/src/main/runtime/terminal/layout/terminal-view-presence.ts index 630058c24ea..b00f3f637a8 100644 --- a/src/main/runtime/terminal/layout/terminal-view-presence.ts +++ b/src/main/runtime/terminal/layout/terminal-view-presence.ts @@ -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 + 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 diff --git a/src/main/runtime/terminal/layout/terminal-viewer-devices.ts b/src/main/runtime/terminal/layout/terminal-viewer-devices.ts new file mode 100644 index 00000000000..32b4c6726b9 --- /dev/null +++ b/src/main/runtime/terminal/layout/terminal-viewer-devices.ts @@ -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>() + + record(deviceClientId: string, ptyId: string): void { + const ptyIds = this.ptyIdsByDevice.get(deviceClientId) ?? new Set() + 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] : [] + } +} diff --git a/src/main/runtime/terminal/view/runtime-terminal-view-subscribers.ts b/src/main/runtime/terminal/view/runtime-terminal-view-subscribers.ts index 76603e8fdf7..977ea86fc55 100644 --- a/src/main/runtime/terminal/view/runtime-terminal-view-subscribers.ts +++ b/src/main/runtime/terminal/view/runtime-terminal-view-subscribers.ts @@ -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)