diff --git a/docs/reference/omp-startup-keyboard-query.md b/docs/reference/omp-startup-keyboard-query.md index d3797961f9f..ec68635e3d5 100644 --- a/docs/reference/omp-startup-keyboard-query.md +++ b/docs/reference/omp-startup-keyboard-query.md @@ -2,9 +2,9 @@ OMP's ProcessTerminal sends `CSI ? u` and then a DA1 sentinel before selecting its keyboard encoding. A fresh desktop terminal already advertises Kitty support, but a direct New Tab launch can query before that renderer owns replies. Previously startup ingress answered only OSC color queries. -The renderer now supplies optional `terminalKittyKeyboardProtocol: true` from its actual xterm `vtExtensions.kittyKeyboard` setting. The existing local/SSH/paired spawn route places it in startup ingress as optional `kittyKeyboardProtocol`. Missing or false capability leaves behavior unchanged, including panes that deliberately withhold Kitty on native Windows. Old peers ignore these optional fields; the ingress version and stream opcodes do not change. New clients with old hosts retain the old renderer fallback. +The renderer now supplies optional `terminalKittyKeyboardProtocol: true` from its actual xterm `vtExtensions.kittyKeyboard` setting. The existing local/SSH/paired spawn route places it in startup ingress as optional `kittyKeyboardProtocol`. Missing or false capability leaves behavior unchanged, including panes that deliberately withhold Kitty on native Windows. The ingress version and stream opcodes do not change. Terminal creation accepts additive fields, but host-authoritative `terminal.createAgentSession` and `terminal.ensureAgentSession` use strict schemas. Clients send the keyboard flag on those methods only after the host advertises `agent-session.keyboard.v1`. The negotiated payload stays fixed across launch retries; old hosts receive the original payload and retain the renderer fallback. New hosts accept older clients that omit the flag. -Source ingress answers only the exact first `CSI ? u` before its deadline/renderer handoff. It uses the existing mode tracker for preceding flag pushes and the existing reply-delivery echo guard. Its transformed source span consumes the query once, while the following DA1 and Kitty mode-setting bytes retain their sequence ranges and reach the renderer. Color and Kitty authority end independently: answering both colors does not end Kitty handling, and ConPTY's persistent color ownership does not retain Kitty ownership after handoff. +Source ingress answers only the exact first `CSI ? u` before its deadline/renderer handoff. It uses the existing mode tracker for preceding flag pushes and the existing reply-delivery echo guard. Its transformed source span consumes the query once, while the following DA1 and Kitty mode-setting bytes retain their sequence ranges and reach the renderer. Keyboard intent does not require theme colors. Color and Kitty authority end independently: answering both colors does not end Kitty handling, and ConPTY's persistent color ownership does not retain Kitty ownership after handoff. Run the actual OMP protocol smoke with a read-only reference checkout: diff --git a/src/main/runtime/orca-runtime-agent-session-operation.test.ts b/src/main/runtime/orca-runtime-agent-session-operation.test.ts index e99560c7b3a..8d7d6fa6812 100644 --- a/src/main/runtime/orca-runtime-agent-session-operation.test.ts +++ b/src/main/runtime/orca-runtime-agent-session-operation.test.ts @@ -1,3 +1,7 @@ +import { + CreateAgentSessionParams, + EnsureAgentSessionParams +} from '../../shared/rpc-contract/agent-session-params' import { describe, expect, it, vi } from 'vitest' import type { RuntimeCreateAgentSessionRequest, @@ -111,6 +115,40 @@ async function fenceRemoteAgentSessionSpawn(runtime: OrcaRuntimeService) { } describe('agent-session create operation ledger', () => { + it.each([true, false, undefined])( + 'forwards renderer keyboard support on create and resume: %s', + async (terminalKittyKeyboardProtocol) => { + const runtime = createRuntime() + const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue(terminal()) + await runtime.createAgentSession( + CreateAgentSessionParams.parse(request(operationId(), { terminalKittyKeyboardProtocol })) + ) + await runtime.ensureAgentSession( + EnsureAgentSessionParams.parse({ + kind: 'explicit', + worktree: 'id:worktree-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'provider-session-1' }, + terminalKittyKeyboardProtocol + }) + ) + expect(createTerminal).toHaveBeenCalledTimes(2) + for (const call of createTerminal.mock.calls) { + expect(call[1]?.terminalKittyKeyboardProtocol).toBe(terminalKittyKeyboardProtocol) + } + } + ) + it('refuses a changed keyboard capability under the same create operation', async () => { + const runtime = createRuntime() + const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue(terminal()) + const id = operationId() + await runtime.createAgentSession(request(id, { terminalKittyKeyboardProtocol: true })) + await expect(runtime.createAgentSession(request(id))).rejects.toThrow( + 'agent_session_operation_conflict' + ) + expect(createTerminal).toHaveBeenCalledOnce() + }) + it('selects legacy before trust, spawn, or ledger state for an old daemon', async () => { const provider = { supportsAgentSessionClaims: vi.fn(() => false), diff --git a/src/main/runtime/orca-runtime-create-agent-session.ts b/src/main/runtime/orca-runtime-create-agent-session.ts index 2ac34f4a920..2fffd41af38 100644 --- a/src/main/runtime/orca-runtime-create-agent-session.ts +++ b/src/main/runtime/orca-runtime-create-agent-session.ts @@ -66,7 +66,8 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe request.presentation ?? null, request.placement?.tabId ?? null, request.placement?.leafId ?? null, - request.viewMode ?? null + request.viewMode ?? null, + ...(request.terminalKittyKeyboardProtocol === true ? ['kitty-keyboard'] : []) ]) ) .digest('base64url') @@ -142,7 +143,8 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe request.presentation ?? null, request.placement?.tabId ?? null, request.placement?.leafId ?? null, - request.viewMode ?? null + request.viewMode ?? null, + ...(request.terminalKittyKeyboardProtocol === true ? ['kitty-keyboard'] : []) ]) ) .digest('base64url') @@ -213,6 +215,7 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe env: startup.env, launchConfig: startup.launchConfig, launchAgent: request.agent, + terminalKittyKeyboardProtocol: request.terminalKittyKeyboardProtocol, startupCommandDelivery: startup.startupCommandDelivery, cwd: startupCwd, presentation: request.presentation ?? 'background', diff --git a/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts b/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts index f70cf033718..76d67061f4d 100644 --- a/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts +++ b/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts @@ -176,6 +176,7 @@ export class OrcaRuntimeWithGetAgentSessionExecutionNamespace extends OrcaRuntim launchConfig: startup.launchConfig, startupCommandDelivery: startup.startupCommandDelivery, launchAgent: request.agent, + terminalKittyKeyboardProtocol: request.terminalKittyKeyboardProtocol, presentation: request.presentation ?? 'background', tabId: request.placement?.tabId, leafId: request.placement?.leafId, diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts index 3067b9e986c..b512254b5ee 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts @@ -20,8 +20,57 @@ const { runtimeCall, refreshSessionTabsSnapshot, resetRemoteRuntimeTransport } = }) describe('createRemoteRuntimePtyTransport', () => { - beforeEach(() => { + it.each([ + { supported: true, resume: false }, + { supported: true, resume: true }, + { supported: false, resume: false }, + { supported: false, resume: true } + ])( + 'gates keyboard fields for host support $supported, resume $resume', + async ({ supported, resume }) => { + runtimeCall.mockImplementation(async (args: { method?: string }) => + args.method === 'status.get' + ? { + ok: true, + result: { + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: [ + 'agent-session.host-authority.v1', + ...(supported ? ['agent-session.keyboard.v1'] : []) + ] + } + } + : { ok: true, result: { terminal: { handle: 'terminal-1' } } } + ) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1', + launchAgent: 'codex', + terminalKittyKeyboardProtocol: true, + ...(resume + ? { resumeProviderSession: { key: 'session_id' as const, id: 'session-1' } } + : {}) + }) + await transport.connect({ url: '', callbacks: {} }) + const method = resume ? 'terminal.ensureAgentSession' : 'terminal.createAgentSession' + const call = runtimeCall.mock.calls.find(([args]) => args.method === method)?.[0] + expect(call).toBeDefined() + if (supported) { + expect(call?.params).toHaveProperty('terminalKittyKeyboardProtocol', true) + } else { + expect(call?.params).not.toHaveProperty('terminalKittyKeyboardProtocol') + } + transport.destroy?.() + } + ) + + beforeEach(async () => { resetRemoteRuntimeTransport() + // Charge the cold module transform to setup, not the launch deadline. + await import('./remote-runtime-pty-transport') }) it('closes a remote terminal created after the pane was destroyed', async () => { diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 4669212bf87..fc9cd4181d1 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -1,3 +1,4 @@ +import { createAgentSessionKeyboardOptions } from '@/runtime/agent-session-keyboard-capability' /* eslint-disable max-lines -- Why: remote PTY transport keeps lifecycle, JSON fallback, and binary stream wiring together so reconnect/destroy ordering stays testable as one behavior surface. */ import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' import { @@ -406,6 +407,7 @@ export function createRemoteRuntimePtyTransport( // Why: reconnect retries must replay one host operation instead of creating // another fresh agent when the first response was lost. const agentCreateOperation = createAgentSessionCreateOperation() + const agentKeyboardOptions = createAgentSessionKeyboardOptions(terminalKittyKeyboardProtocol) const outputProcessor = createPtyOutputProcessor({ onTitleChange, onBell, @@ -2250,8 +2252,9 @@ export function createRemoteRuntimePtyTransport( createEnvironmentId, connectLifecycleEpoch ) - const hostAuthorityCreate = () => - createWithUnknownOutcomeRecovery( + const hostAuthorityCreate = async () => { + const keyboardOptions = await agentKeyboardOptions(createEnvironmentId) + return createWithUnknownOutcomeRecovery( 'agent-session', (timeoutMs) => resumeProviderSessionToSend @@ -2260,6 +2263,7 @@ export function createRemoteRuntimePtyTransport( 'terminal.ensureAgentSession', { kind: 'explicit', + ...keyboardOptions, worktree: toRuntimeTerminalWorktreeSelector(worktreeId), agent: launchAgentToSend!, providerSession: resumeProviderSessionToSend, @@ -2280,6 +2284,7 @@ export function createRemoteRuntimePtyTransport( 'terminal.createAgentSession', withAgentSessionCreateOperationId( { + ...keyboardOptions, worktree: toRuntimeTerminalWorktreeSelector(worktreeId), agent: launchAgentToSend!, ...(agentPrompt ? { prompt: agentPrompt } : {}), @@ -2300,6 +2305,7 @@ export function createRemoteRuntimePtyTransport( createEnvironmentId, connectLifecycleEpoch ) + } const resumeHostAuthorityCapability = resumeProviderSessionToSend ? agentResumeHostAuthorityCapability(launchAgentToSend) : undefined diff --git a/src/renderer/src/runtime/agent-session-keyboard-capability.test.ts b/src/renderer/src/runtime/agent-session-keyboard-capability.test.ts new file mode 100644 index 00000000000..67df60b6d4e --- /dev/null +++ b/src/renderer/src/runtime/agent-session-keyboard-capability.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { createAgentSessionKeyboardOptions } from './agent-session-keyboard-capability' +import { runtimeEnvironmentSupportsCapability } from './runtime-rpc-client' + +vi.mock('./runtime-rpc-client', () => ({ runtimeEnvironmentSupportsCapability: vi.fn() })) +const probe = vi.mocked(runtimeEnvironmentSupportsCapability) +beforeEach(() => { + probe.mockReset() +}) + +it.each([true, false])( + 'negotiates once and freezes the payload across reconnects: %s', + async (supported) => { + probe.mockResolvedValueOnce(supported).mockResolvedValue(!supported) + const resolve = createAgentSessionKeyboardOptions(true) + const first = resolve('env-1') + expect(resolve('env-1')).toBe(first) + expect(await first).toEqual(supported ? { terminalKittyKeyboardProtocol: true } : {}) + expect(await resolve('env-1')).toEqual(await first) + expect(probe).toHaveBeenCalledExactlyOnceWith('env-1', 'agent-session.keyboard.v1') + } +) +it.each([undefined, false])( + 'does not probe or advertise disabled renderer support: %s', + async (enabled) => { + expect(await createAgentSessionKeyboardOptions(enabled)('env-1')).toEqual({}) + expect(probe).not.toHaveBeenCalled() + } +) +it('keeps the legacy payload after an unavailable read-only capability probe', async () => { + probe.mockRejectedValueOnce(new Error('disconnected')).mockResolvedValue(true) + const resolve = createAgentSessionKeyboardOptions(true) + expect(await resolve('env-1')).toEqual({}) + expect(await resolve('env-1')).toEqual({}) + expect(probe).toHaveBeenCalledOnce() +}) + +it('preserves an incompatible runtime refusal instead of degrading the launch', async () => { + const error = Object.assign(new Error('update required'), { code: 'runtime_compat_block' }) + probe.mockRejectedValue(error) + await expect(createAgentSessionKeyboardOptions(true)('env-1')).rejects.toBe(error) +}) diff --git a/src/renderer/src/runtime/agent-session-keyboard-capability.ts b/src/renderer/src/runtime/agent-session-keyboard-capability.ts new file mode 100644 index 00000000000..b12311bc044 --- /dev/null +++ b/src/renderer/src/runtime/agent-session-keyboard-capability.ts @@ -0,0 +1,30 @@ +import { AGENT_SESSION_KEYBOARD_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { runtimeEnvironmentSupportsCapability } from './runtime-rpc-client' +import { isRuntimeCompatBlockError } from './runtime-protocol-compat' + +type KeyboardOptions = { terminalKittyKeyboardProtocol?: true } + +export function createAgentSessionKeyboardOptions(enabled: boolean | undefined) { + let negotiated: Promise | undefined + return (environmentId: string): Promise => { + // A replay must keep its original payload even after a host upgrade or reconnect. + negotiated ??= (async () => { + if (enabled !== true) { + return {} + } + try { + const supported = await runtimeEnvironmentSupportsCapability( + environmentId, + AGENT_SESSION_KEYBOARD_RUNTIME_CAPABILITY + ) + return supported ? { terminalKittyKeyboardProtocol: true as const } : {} + } catch (error) { + if (isRuntimeCompatBlockError(error)) { + throw error + } + return {} + } + })() + return negotiated + } +} diff --git a/src/shared/agent-session-host-authority.ts b/src/shared/agent-session-host-authority.ts index e994c9f5a0e..ca9dc046275 100644 --- a/src/shared/agent-session-host-authority.ts +++ b/src/shared/agent-session-host-authority.ts @@ -110,6 +110,7 @@ export type RuntimeEnsureAgentSessionRequest = agent: ResumableTuiAgent providerSession: AgentProviderSessionMetadata ompResumeFilePath?: string + terminalKittyKeyboardProtocol?: boolean /** Explicit client override. Omission keeps launch defaults host-owned. */ agentArgs?: string | null launchPreferences?: AgentLaunchPreferences @@ -124,6 +125,7 @@ export type RuntimeEnsureAgentSessionResult = { export type RuntimeCreateAgentSessionRequest = { clientOperationId: string + terminalKittyKeyboardProtocol?: boolean worktree: string agent: TuiAgent prompt?: string diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 99908de9b84..08e63c14838 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -132,6 +132,8 @@ export const AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY = export { REMOTE_SERVER_UPDATE_CAPABILITY } from './remote-server-update' export const AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY = 'agent-session.host-authority.v1' as const +// Older launch schemas reject unknown fields; advertise before clients send keyboard support. +export const AGENT_SESSION_KEYBOARD_RUNTIME_CAPABILITY = 'agent-session.keyboard.v1' as const export const AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY = 'agent-session.omp-resume-path.v1' as const // Why: structured sessions are journal-backed, not PTY-backed, so an incapable client must not @@ -286,6 +288,7 @@ export const RUNTIME_CAPABILITIES = [ REMOTE_SERVER_UPDATE_CAPABILITY, AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY, AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, + AGENT_SESSION_KEYBOARD_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, diff --git a/src/shared/rpc-contract/agent-session-params.ts b/src/shared/rpc-contract/agent-session-params.ts index 6a2aa23635d..d82f0c4d2b4 100644 --- a/src/shared/rpc-contract/agent-session-params.ts +++ b/src/shared/rpc-contract/agent-session-params.ts @@ -126,6 +126,7 @@ export const ExplicitEnsure = z agent: z.enum(RESUMABLE_TUI_AGENTS), providerSession: ProviderSession, ompResumeFilePath: OmpResumeFilePath.optional(), + terminalKittyKeyboardProtocol: z.boolean().optional(), agentArgs: AgentArgs.optional(), launchPreferences: LaunchPreferences.optional(), presentation: Presentation.optional(), @@ -154,6 +155,7 @@ export const EnsureAgentSessionParams: z.ZodType = z .object({ + terminalKittyKeyboardProtocol: z.boolean().optional(), clientOperationId: z .string() .refine(