mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 02:17:31 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user