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)