diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index a66ec323848..582ba59dc5a 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -533,6 +533,9 @@ export class DaemonPtyAdapter implements IPtyProvider { ...(this.supportsStartupIngress && !attachOnly && opts.startupIngress ? { startupIngress: opts.startupIngress } : {}), + ...(!attachOnly && opts.oscColorQueryReplies + ? { oscColorQueryReplies: opts.oscColorQueryReplies } + : {}), ...(!attachOnly && opts.agentSessionEnsure ? { agentSessionEnsure: opts.agentSessionEnsure } : {}) diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 731da9eefd5..7c2b2c30112 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -22,6 +22,7 @@ import { checkPtySpawnHealth } from './pty-subprocess' import { createNoopDaemonFileLog, type DaemonFileLog } from './daemon-file-log' import { isTuiAgent } from '../../shared/tui-agent-config' import { parsePtyStartupIngressIntent } from '../../shared/pty-startup-ingress' +import { parseTerminalOscColorQueryReplyColors } from '../../shared/terminal-osc-color-reply' import { unlinkOwnedDaemonPidFile, unlinkOwnedDaemonTokenFile } from './daemon-spawner' import { DAEMON_ENDPOINT_LOST_MESSAGE, @@ -1028,6 +1029,7 @@ export class DaemonServer { shellReadySupported: p.shellReadySupported, historySeedChunks, startupIngress: parsePtyStartupIngressIntent(p.startupIngress), + oscColorQueryReplies: parseTerminalOscColorQueryReplyColors(p.oscColorQueryReplies), ...(p.shellReadyTimeoutMs !== undefined ? { shellReadyTimeoutMs: p.shellReadyTimeoutMs } : {}), diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 377f0fee184..0c3f6cd8dc1 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -34,6 +34,7 @@ import { type PtyStartupIngressIntent } from '../../shared/pty-startup-ingress' import { extractOnlyCookedEchoSafeQueryReplies } from '../../shared/terminal-query-reply' +import type { TerminalOscColorQueryReplyColors } from '../../shared/terminal-osc-color-reply' import type { PendingOutputRecord, SessionState, @@ -109,6 +110,7 @@ export type SessionOptions = { // a reaper, dead sessions and their scrollback emulators accumulate for the daemon's lifetime. onExit?: (code: number) => void startupIngress?: PtyStartupIngressIntent + oscColorQueryReplies?: TerminalOscColorQueryReplyColors ownerBackend?: PtyOwnerBackend } @@ -206,6 +208,7 @@ export class Session { const echoProbe = createPtySlaveEchoProbe(this.subprocess.slavePath) this.startupIngress = new PtyStartupIngress({ ...(opts.startupIngress ? { intent: opts.startupIngress } : {}), + ...(opts.oscColorQueryReplies ? { liveOscColors: opts.oscColorQueryReplies } : {}), ...(opts.ownerBackend ? { ownerBackend: opts.ownerBackend } : {}), write: (data) => this.subprocess.write(data), onEmission: (emission) => this.emitSubprocessOutput(emission), diff --git a/src/main/daemon/terminal-host-create-contract.ts b/src/main/daemon/terminal-host-create-contract.ts index 114aae91ad2..0a174e352e1 100644 --- a/src/main/daemon/terminal-host-create-contract.ts +++ b/src/main/daemon/terminal-host-create-contract.ts @@ -2,6 +2,7 @@ import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery import type { TuiAgent } from '../../shared/types' import type { ShellReadyState, TerminalSnapshot } from './types' import type { PtyStartupIngressIntent } from '../../shared/pty-startup-ingress' +import type { TerminalOscColorQueryReplyColors } from '../../shared/terminal-osc-color-reply' import type { AgentSessionClaimedSpawnResult, AgentSessionExecutionClaim, @@ -29,6 +30,7 @@ export type CreateOrAttachOptions = { shellReadyTimeoutMs?: number historySeedChunks?: readonly string[] startupIngress?: PtyStartupIngressIntent + oscColorQueryReplies?: TerminalOscColorQueryReplyColors agentSessionEnsure?: { claim: AgentSessionExecutionClaim surface: AgentSessionSurfaceBinding diff --git a/src/main/daemon/terminal-host-session-create.ts b/src/main/daemon/terminal-host-session-create.ts index dcc88fada05..935d31aa97c 100644 --- a/src/main/daemon/terminal-host-session-create.ts +++ b/src/main/daemon/terminal-host-session-create.ts @@ -109,6 +109,7 @@ export async function createOrAttachTerminalSession( shellReadySupported, historySeedChunks: opts.historySeedChunks, ...(opts.startupIngress ? { startupIngress: opts.startupIngress } : {}), + ...(opts.oscColorQueryReplies ? { oscColorQueryReplies: opts.oscColorQueryReplies } : {}), wslDistro, onExit: () => deps.onSessionExit(opts.sessionId, opts.agentSessionGeneration), ...(opts.shellReadyTimeoutMs !== undefined diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 1f067885467..c465336a5cb 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -86,6 +86,10 @@ export type CreateOrAttachRequest = { shellReadySupported?: boolean shellReadyTimeoutMs?: number startupIngress?: PtyStartupIngressIntent + oscColorQueryReplies?: { + foreground?: string + background?: string + } agentSessionEnsure?: { claim: AgentSessionExecutionClaim surface: AgentSessionSurfaceBinding diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index ddc829d2d13..3bedea26245 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -226,7 +226,10 @@ import { } from '../../shared/setup-agent-sequencing' import { dropAgentResumeArgvFromCommand } from '../../shared/agent-resume-argv-drop' import { parseWorkspaceKey } from '../../shared/workspace-scope' -import { getStartupTerminalColorQueryReplyColors } from './terminal-startup-color-query-replies' +import { + getLiveTerminalOscColorQueryReplyColors, + getStartupTerminalColorQueryReplyColors +} from './terminal-startup-color-query-replies' import { assertFolderWorkspacePathUsable, getFolderWorkspacePathStatus @@ -4704,6 +4707,10 @@ export function registerPtyHandlers( deadlineMs: 5_000 } } + const liveOscColorQueryReplyColors = getLiveTerminalOscColorQueryReplyColors(args) + if (liveOscColorQueryReplyColors) { + spawnOptions.oscColorQueryReplies = liveOscColorQueryReplyColors + } let ptySpawnCommitReported = false const reportPtySpawnCommitted = (): void => { if (ptySpawnCommitReported) { @@ -6432,6 +6439,10 @@ export function registerPtyHandlers( deadlineMs: 5_000 } } + const liveOscColorQueryReplyColors = getLiveTerminalOscColorQueryReplyColors(args) + if (liveOscColorQueryReplyColors) { + spawnOptions.oscColorQueryReplies = liveOscColorQueryReplyColors + } const resolvedPaneSpawnReservationKey = makePaneSpawnReservationKey( args.worktreeId, args.connectionId, diff --git a/src/main/ipc/terminal-startup-color-query-replies.ts b/src/main/ipc/terminal-startup-color-query-replies.ts index 6d6b8ebff42..4f35f18d137 100644 --- a/src/main/ipc/terminal-startup-color-query-replies.ts +++ b/src/main/ipc/terminal-startup-color-query-replies.ts @@ -3,6 +3,7 @@ import { isTuiAgent } from '../../shared/tui-agent-config' import { agentKindSchema } from '../../shared/telemetry-events' import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' import { + parseTerminalOscColorQueryReplyColors, terminalOscColorQueryReply, type TerminalOscColorQueryReplyColors } from '../../shared/terminal-osc-color-reply' @@ -56,3 +57,9 @@ export function getStartupTerminalColorQueryReplyColors(args: { } return normalizeTerminalColorQueryReplyColors(args.terminalColorQueryReplies) } + +export function getLiveTerminalOscColorQueryReplyColors(args: { + terminalColorQueryReplies?: unknown +}): TerminalOscColorQueryReplyColors | null { + return parseTerminalOscColorQueryReplyColors(args.terminalColorQueryReplies) ?? null +} diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 6601a56ac31..a4b4d0ef513 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -945,6 +945,7 @@ export class LocalPtyProvider implements IPtyProvider { const startupEchoProbe = createPtySlaveEchoProbe(readPtySlavePath(proc)) const startupIngress = new PtyStartupIngress({ ...(args.startupIngress ? { intent: args.startupIngress } : {}), + ...(args.oscColorQueryReplies ? { liveOscColors: args.oscColorQueryReplies } : {}), ownerBackend: resolvePtyOwnerBackend({ platform: process.platform, shellPath, diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index 09f3cda62fd..a90b81bf7c7 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -1,5 +1,6 @@ import type { TuiAgent } from '../../shared/types' import type { PtyStartupIngressIntent } from '../../shared/pty-startup-ingress' +import type { TerminalOscColorQueryReplyColors } from '../../shared/terminal-osc-color-reply' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' import type { PtyBackgroundStreamEvent, PtyDataEvent } from './pty-provider-events' @@ -92,6 +93,8 @@ export type PtySpawnOptions = { terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' /** Fresh-spawn-only source authority installed before any PTY output is released. */ startupIngress?: PtyStartupIngressIntent + /** Theme colors for live OSC 10/11 answers after startup query authority closes. */ + oscColorQueryReplies?: TerminalOscColorQueryReplyColors agentSessionEnsure?: { claim: AgentSessionExecutionClaim surface: AgentSessionSurfaceBinding diff --git a/src/main/providers/ssh-pty-spawn-request.ts b/src/main/providers/ssh-pty-spawn-request.ts index 889c0813ee9..0012fa9945d 100644 --- a/src/main/providers/ssh-pty-spawn-request.ts +++ b/src/main/providers/ssh-pty-spawn-request.ts @@ -39,6 +39,7 @@ export function buildSshPtySpawnRequest(args: { startupIngress: options.startupIngress } : {}), + ...(options.oscColorQueryReplies ? { oscColorQueryReplies: options.oscColorQueryReplies } : {}), ...(options.agentSessionEnsure ? { agentSessionEnsure: options.agentSessionEnsure } : {}), ...(args.supportsCreateOperation ? { agentSessionCreateOperationId: options.agentSessionCreateOperationId } diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index b97c1bd0847..c017c497027 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -1866,6 +1866,40 @@ describe('PtyHandler', () => { } }) + it('answers live OSC 11 on a POSIX SSH relay and strips the query', async () => { + vi.useFakeTimers() + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + try { + let dataCallback: ((data: string) => void) | undefined + const term = { + ...mockPtyInstance, + onData: vi.fn((cb: (data: string) => void) => { + dataCallback = cb + }), + onExit: vi.fn() + } + mockPtySpawn.mockReturnValue(term) + await dispatcher.callRequest('pty.spawn', { + shellOverride: '/bin/bash', + oscColorQueryReplies: { foreground: '#2e3434', background: '#ffffff' } + }) + + dataCallback!('\x1b]11;?\x1b\\\x1b[6n') + await vi.advanceTimersByTimeAsync(8) + + expect(term.write).toHaveBeenCalledWith('\x1b]11;rgb:ffff/ffff/ffff\x1b\\') + const dataNotifies = dispatcher.notify.mock.calls + .filter((call) => call[0] === 'pty.data') + .map((call) => call[1] as { data: string }) + expect(dataNotifies.map((frame) => frame.data).join('')).toBe('\x1b[6n') + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + it('forwards color queries from a POSIX SSH relay owner', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 6dd3cd8a7fc..986acce5bd4 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -59,6 +59,10 @@ import { parsePtyStartupIngressIntent, type PtyIngressEmission } from '../shared/pty-startup-ingress' +import { + parseTerminalOscColorQueryReplyColors, + type TerminalOscColorQueryReplyColors +} from '../shared/terminal-osc-color-reply' import { extractOnlyCookedEchoSafeQueryReplies } from '../shared/terminal-query-reply' import { resolvePtyOwnerBackend, type PtyOwnerBackend } from '../shared/pty-owner-backend' import { RecentPtyOutputBuffer } from '../main/runtime/recent-pty-output-buffer' @@ -169,6 +173,7 @@ type ManagedPty = { gracefulKillSent?: boolean startupIngress?: PtyStartupIngress startupIngressIntent?: ReturnType + liveOscColors?: TerminalOscColorQueryReplyColors ownerBackend: PtyOwnerBackend agentSessionOwners?: AgentSessionOwnerBinding[] } @@ -743,6 +748,7 @@ export class PtyHandler { const echoProbe = createPtySlaveEchoProbe(readPtySlavePath(managed.pty)) managed.startupIngress ??= new PtyStartupIngress({ ...(managed.startupIngressIntent ? { intent: managed.startupIngressIntent } : {}), + ...(managed.liveOscColors ? { liveOscColors: managed.liveOscColors } : {}), ownerBackend: managed.ownerBackend, write: (data) => managed.pty.write(data), onEmission: emitIngressData, @@ -1611,6 +1617,7 @@ export class PtyHandler { params.startupIngressVersion === PTY_STARTUP_INGRESS_VERSION ? parsePtyStartupIngressIntent(params.startupIngress) : undefined + const liveOscColors = parseTerminalOscColorQueryReplyColors(params.oscColorQueryReplies) const managed: ManagedPty = { id, incarnationId: randomUUID(), @@ -1636,6 +1643,7 @@ export class PtyHandler { wslDistro: terminalWindowsWslDistro }), ...(startupIngressIntent ? { startupIngressIntent } : {}), + ...(liveOscColors ? { liveOscColors } : {}), ...(terminalHandle ? { terminalHandle } : {}), ...(managedStartupCommand && (shouldProviderDeliverCommand || rendererShellReadySupported) ? { diff --git a/src/shared/pty-startup-ingress-color-answer.ts b/src/shared/pty-startup-ingress-color-answer.ts new file mode 100644 index 00000000000..1ce370b3fc8 --- /dev/null +++ b/src/shared/pty-startup-ingress-color-answer.ts @@ -0,0 +1,69 @@ +import { + terminalOscColorQueryReplies, + type TerminalOscColorQueryReplyColors, + type TerminalOscColorQuerySlot +} from './terminal-osc-color-reply' +import type { PtyOwnerBackend } from './pty-owner-backend' +import type { PtyStartupIngressIntent } from './pty-startup-ingress-intent' + +type ColorReplyDelivery = { + answer: (reply: string, onFailed?: () => void) => boolean +} + +export function answerLiveOscColorQuery(args: { + slots: readonly TerminalOscColorQuerySlot[] + colors: TerminalOscColorQueryReplyColors | undefined + ownerBackend: PtyOwnerBackend + delivery: ColorReplyDelivery +}): boolean { + if (!args.colors || args.ownerBackend === 'windows-conpty') { + return false + } + const replies = terminalOscColorQueryReplies(args.colors, args.slots) + if (!replies) { + return false + } + for (const reply of replies) { + args.delivery.answer(reply) + } + return true +} + +export function answerStartupOscColorQuery(args: { + slots: readonly TerminalOscColorQuerySlot[] + intent: PtyStartupIngressIntent | undefined + answeredSlots: Set + delivery: ColorReplyDelivery + onBothSlotsAnswered: () => void +}): boolean { + if (args.slots.some((slot) => args.answeredSlots.has(slot)) || !args.intent) { + return false + } + const replies = terminalOscColorQueryReplies(args.intent.colors, args.slots) + if (!replies) { + return false + } + + let wroteAny = false + for (const [index, reply] of replies.entries()) { + const slot = args.slots[index] + if (slot === undefined) { + return wroteAny + } + args.answeredSlots.add(slot) + // Why per slot: the replies to one query are written independently, so a + // deferred write that fails after reporting success invalidates only its own + // claim. Dropping every claim would let a slot that did land be answered a + // second time, and a duplicate reply corrupts a parser already mid-read. + if (!args.delivery.answer(reply, () => args.answeredSlots.delete(slot))) { + args.answeredSlots.delete(slot) + return wroteAny + } + wroteAny = true + } + + if (args.answeredSlots.has(10) && args.answeredSlots.has(11)) { + args.onBothSlotsAnswered() + } + return wroteAny +} diff --git a/src/shared/pty-startup-ingress-contract.ts b/src/shared/pty-startup-ingress-contract.ts index 461c6d6c836..d07f0fa2faf 100644 --- a/src/shared/pty-startup-ingress-contract.ts +++ b/src/shared/pty-startup-ingress-contract.ts @@ -1,6 +1,7 @@ import type { PtyOwnerBackend } from './pty-owner-backend' import type { PtyStartupIngressIntent } from './pty-startup-ingress-intent' import type { PtySlaveEchoProbe } from './pty-slave-line-discipline-echo' +import type { TerminalOscColorQueryReplyColors } from './terminal-osc-color-reply' export type PtyIngressEmission = { data: string @@ -11,6 +12,13 @@ export type PtyIngressEmission = { export type PtyStartupIngressOptions = { intent?: PtyStartupIngressIntent + /** + * Live OSC 10/11 answers for the whole PTY lifetime. Distinct from startup + * `intent`: that path is once-per-slot and expires. Survey-style readers + * (gh auth login) query after that window; a late renderer reply is `\x1b]` + * on stdin. + */ + liveOscColors?: TerminalOscColorQueryReplyColors ownerBackend?: PtyOwnerBackend write: (data: string) => void onEmission: (emission: PtyIngressEmission) => void diff --git a/src/shared/pty-startup-ingress-live-osc.test.ts b/src/shared/pty-startup-ingress-live-osc.test.ts new file mode 100644 index 00000000000..2fff41665e1 --- /dev/null +++ b/src/shared/pty-startup-ingress-live-osc.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { PtyStartupIngress, type PtyIngressEmission } from './pty-startup-ingress' + +const COLORS = { foreground: '#2e3434', background: '#ffffff' } +const FOREGROUND_REPLY = '\x1b]10;rgb:2e2e/3434/3434\x1b\\' +const BACKGROUND_REPLY = '\x1b]11;rgb:ffff/ffff/ffff\x1b\\' + +function visible(emissions: readonly PtyIngressEmission[]): string { + return emissions.map((emission) => emission.data).join('') +} + +describe('PtyStartupIngress live OSC 10/11 answers', () => { + afterEach(() => vi.useRealTimers()) + + it('answers a post-startup OSC 11 query and strips it from forwarded output', async () => { + vi.useFakeTimers() + const emissions: PtyIngressEmission[] = [] + const writes: string[] = [] + const ingress = new PtyStartupIngress({ + liveOscColors: COLORS, + write: (data) => writes.push(data), + onEmission: (emission) => emissions.push(emission) + }) + // gh auth login writes OSC 11 (ST) plus CPR, then survey-reads stdin. + ingress.accept('\x1b]11;?\x1b\\\x1b[6n') + await vi.advanceTimersByTimeAsync(0) + expect(writes).toEqual([BACKGROUND_REPLY]) + expect(visible(emissions)).toBe('\x1b[6n') + ingress.accept('\x1b]11;?\x07prompt') + await vi.advanceTimersByTimeAsync(0) + expect(writes).toEqual([BACKGROUND_REPLY, BACKGROUND_REPLY]) + expect(visible(emissions)).toBe('\x1b[6nprompt') + ingress.drainAndClose() + }) + + it('still answers live OSC 11 after startup query authority expires', async () => { + vi.useFakeTimers() + const emissions: PtyIngressEmission[] = [] + const writes: string[] = [] + const ingress = new PtyStartupIngress({ + intent: { colors: COLORS, deadlineMs: 5_000 }, + liveOscColors: COLORS, + write: (data) => writes.push(data), + onEmission: (emission) => emissions.push(emission) + }) + ingress.accept('\x1b]10;?\x07\x1b]11;?\x07') + await vi.advanceTimersByTimeAsync(0) + expect(writes).toEqual([FOREGROUND_REPLY, BACKGROUND_REPLY]) + await vi.advanceTimersByTimeAsync(5_000) + writes.length = 0 + ingress.accept('\x1b]11;?\x1b\\') + await vi.advanceTimersByTimeAsync(0) + expect(writes).toEqual([BACKGROUND_REPLY]) + expect(visible(emissions)).toBe('') + ingress.drainAndClose() + }) +}) diff --git a/src/shared/pty-startup-ingress.ts b/src/shared/pty-startup-ingress.ts index fe1aa041752..f8df0a29391 100644 --- a/src/shared/pty-startup-ingress.ts +++ b/src/shared/pty-startup-ingress.ts @@ -1,8 +1,12 @@ import { parseTerminalOscColorQuery, - terminalOscColorQueryReplies, + type TerminalOscColorQueryReplyColors, type TerminalOscColorQuerySlot } from './terminal-osc-color-reply' +import { + answerLiveOscColorQuery, + answerStartupOscColorQuery +} from './pty-startup-ingress-color-answer' import type { PtyStartupIngressIntent } from './pty-startup-ingress-intent' import type { PtyOwnerBackend } from './pty-owner-backend' import { PtyStartupReplyDelivery } from './pty-startup-reply-delivery' @@ -38,6 +42,7 @@ const ECHO_CONTINUATION_HOLD_MS = 500 */ export class PtyStartupIngress { private readonly intent: PtyStartupIngressIntent | undefined + private readonly liveOscColors: TerminalOscColorQueryReplyColors | undefined private readonly ownerBackend: PtyOwnerBackend private readonly delivery: PtyStartupReplyDelivery private readonly onEmission: (emission: PtyIngressEmission) => void @@ -54,6 +59,7 @@ export class PtyStartupIngress { constructor(options: PtyStartupIngressOptions) { this.intent = options.intent + this.liveOscColors = options.liveOscColors this.ownerBackend = options.ownerBackend ?? 'posix-pty' this.delivery = new PtyStartupReplyDelivery(this.ownerBackend, options.write, options.echoProbe) this.onEmission = options.onEmission @@ -242,7 +248,8 @@ export class PtyStartupIngress { const input = combinePtyIngressSourceSpans(this.queryPending, span) this.queryPending = null const suppressConptyQuery = this.ownerBackend === 'windows-conpty' - if ((!this.queryOpen || !this.intent) && !suppressConptyQuery) { + const canAnswerColorQuery = Boolean(this.liveOscColors || (this.queryOpen && this.intent)) + if (!canAnswerColorQuery && !suppressConptyQuery) { this.emit(input, false) return } @@ -277,7 +284,23 @@ export class PtyStartupIngress { this.emit(slicePtyIngressSourceSpan(input, emittedOffset, candidateIndex), false) } const querySpan = slicePtyIngressSourceSpan(input, candidateIndex, query.endIndex) - const answered = this.queryOpen && this.intent && this.answerQuery(query.slots) + const answered = + this.queryOpen && this.intent + ? answerStartupOscColorQuery({ + slots: query.slots, + intent: this.intent, + answeredSlots: this.answeredSlots, + delivery: this.delivery, + onBothSlotsAnswered: () => { + this.queryOpen = false + } + }) + : answerLiveOscColorQuery({ + slots: query.slots, + colors: this.liveOscColors, + ownerBackend: this.ownerBackend, + delivery: this.delivery + }) if (answered || suppressConptyQuery) { this.emit(querySpan, true, '') } else { @@ -288,39 +311,6 @@ export class PtyStartupIngress { } } - private answerQuery(slots: readonly TerminalOscColorQuerySlot[]): boolean { - if (slots.some((slot) => this.answeredSlots.has(slot)) || !this.intent) { - return false - } - const replies = terminalOscColorQueryReplies(this.intent.colors, slots) - if (!replies) { - return false - } - - let wroteAny = false - for (const [index, reply] of replies.entries()) { - const slot = slots[index] - if (slot === undefined) { - return wroteAny - } - this.answeredSlots.add(slot) - // Why per slot: the replies to one query are written independently, so a - // deferred write that fails after reporting success invalidates only its own - // claim. Dropping every claim would let a slot that did land be answered a - // second time, and a duplicate reply corrupts a parser already mid-read. - if (!this.delivery.answer(reply, () => this.answeredSlots.delete(slot))) { - this.answeredSlots.delete(slot) - return wroteAny - } - wroteAny = true - } - - if (this.answeredSlots.has(10) && this.answeredSlots.has(11)) { - this.queryOpen = false - } - return wroteAny - } - private releaseQueryPending(): void { if (!this.queryPending) { return diff --git a/src/shared/terminal-osc-color-reply.test.ts b/src/shared/terminal-osc-color-reply.test.ts index 065c4a6f551..f176203e43a 100644 --- a/src/shared/terminal-osc-color-reply.test.ts +++ b/src/shared/terminal-osc-color-reply.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { parseTerminalOscColorQuery } from './terminal-osc-color-reply' +import { + parseTerminalOscColorQuery, + parseTerminalOscColorQueryReplyColors +} from './terminal-osc-color-reply' describe('parseTerminalOscColorQuery', () => { it('matches exact OSC color queries terminated by ST or BEL', () => { @@ -43,6 +46,17 @@ describe('parseTerminalOscColorQuery', () => { expect(parseTerminalOscColorQuery('\x1b]11;?;?\x1b\\', 0)).toEqual({ kind: 'none' }) }) + it('accepts theme colors that can produce OSC 10 and OSC 11 replies', () => { + expect( + parseTerminalOscColorQueryReplyColors({ + foreground: '#2e3434', + background: '#ffffff' + }) + ).toEqual({ foreground: '#2e3434', background: '#ffffff' }) + expect(parseTerminalOscColorQueryReplyColors({ foreground: '#2e3434' })).toBeUndefined() + expect(parseTerminalOscColorQueryReplyColors(null)).toBeUndefined() + }) + it('rejects unsupported query-shaped bodies without waiting for a terminator', () => { expect(parseTerminalOscColorQuery(`\x1b]10;?${'x'.repeat(10_000)}`, 0)).toEqual({ kind: 'none' diff --git a/src/shared/terminal-osc-color-reply.ts b/src/shared/terminal-osc-color-reply.ts index cd79b2fd3db..6e11a8c97ee 100644 --- a/src/shared/terminal-osc-color-reply.ts +++ b/src/shared/terminal-osc-color-reply.ts @@ -100,6 +100,23 @@ function clampByte(value: number): number { return Math.min(255, Math.max(0, Math.round(value))) } +export function parseTerminalOscColorQueryReplyColors( + value: unknown +): TerminalOscColorQueryReplyColors | undefined { + if (!value || typeof value !== 'object') { + return undefined + } + const record = value as { foreground?: unknown; background?: unknown } + const colors: TerminalOscColorQueryReplyColors = { + ...(typeof record.foreground === 'string' ? { foreground: record.foreground } : {}), + ...(typeof record.background === 'string' ? { background: record.background } : {}) + } + if (!terminalOscColorQueryReply(colors, 10) || !terminalOscColorQueryReply(colors, 11)) { + return undefined + } + return colors +} + export function terminalOscColorQueryReply( colors: TerminalOscColorQueryReplyColors, slot: TerminalOscColorQuerySlot