mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 16:03:47 +00:00
fix(terminal): answer live OSC 10/11 queries on the PTY host
gh auth login probes background color with OSC 11, then survey-reads stdin. A renderer-side reply over SSH arrives after that read starts, so survey treats ESC ] as an unexpected key and aborts. Answer those queries on the POSIX PTY host for the whole session, strip them from forwarded output so the renderer cannot send a late duplicate, and leave Windows ConPTY on its existing consume-only path.
This commit is contained in:
@@ -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 }
|
||||
: {})
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -86,6 +86,10 @@ export type CreateOrAttachRequest = {
|
||||
shellReadySupported?: boolean
|
||||
shellReadyTimeoutMs?: number
|
||||
startupIngress?: PtyStartupIngressIntent
|
||||
oscColorQueryReplies?: {
|
||||
foreground?: string
|
||||
background?: string
|
||||
}
|
||||
agentSessionEnsure?: {
|
||||
claim: AgentSessionExecutionClaim
|
||||
surface: AgentSessionSurfaceBinding
|
||||
|
||||
+12
-1
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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<typeof parsePtyStartupIngressIntent>
|
||||
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)
|
||||
? {
|
||||
|
||||
@@ -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<TerminalOscColorQuerySlot>
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user