From 3e3f48fe895413ffaa4d90ae2ce5670e6c795012 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:03:48 -0700 Subject: [PATCH] fix(native-chat): restore Codex model picker dispatch (#13669) * fix(native-chat): restore Codex model picker dispatch * fix(native-chat): type Codex effort picker command --- .../session/mobile-native-chat-send.test.ts | 38 +++++++++++++++- mobile/src/session/mobile-native-chat-send.ts | 22 ++++++++++ ...se-mobile-native-chat-message-send.test.ts | 17 ++++++++ .../use-mobile-native-chat-message-send.ts | 40 +++++++++++++++-- ...mobile-native-chat-session-options.test.ts | 21 ++++++--- .../use-mobile-native-chat-session-options.ts | 10 ++++- .../native-chat/NativeChatComposer.test.tsx | 15 ++++--- .../native-chat-pty-session-options.test.ts | 2 +- .../native-chat-runtime-send.test.ts | 30 +++++++++++++ .../native-chat/native-chat-runtime-send.ts | 19 ++++++++ .../native-chat-session-option-apply.ts | 4 +- ...ve-chat-session-option-command-dispatch.ts | 6 ++- .../use-native-chat-session-option-command.ts | 22 +++++++--- ...ent-session-option-catalog-claude-codex.ts | 11 ++--- .../agent-session-option-catalog-types.ts | 3 +- src/shared/agent-session-option-catalog.ts | 1 + src/shared/agent-tui-command-typing.ts | 43 +++++++++++++++++++ ...ative-chat-session-option-commands.test.ts | 9 +++- ...ative-chat-session-option-snapshot.test.ts | 4 +- 19 files changed, 276 insertions(+), 41 deletions(-) create mode 100644 src/shared/agent-tui-command-typing.ts diff --git a/mobile/src/session/mobile-native-chat-send.test.ts b/mobile/src/session/mobile-native-chat-send.test.ts index ccdacfd7b3e..1c7a661cda5 100644 --- a/mobile/src/session/mobile-native-chat-send.test.ts +++ b/mobile/src/session/mobile-native-chat-send.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' @@ -7,10 +7,13 @@ import { openMobileNativeChatSendBudget, clearMobileNativeChatInput, sendMobileNativeChatMessage, - sendMobileNativeChatMessageWithOutcome + sendMobileNativeChatMessageWithOutcome, + typeMobileNativeChatCommandWithOutcome } from './mobile-native-chat-send' import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear' +afterEach(() => vi.useRealTimers()) + function clientWithResponse(response: unknown): RpcClient { return { sendRequest: vi.fn().mockResolvedValue(response) @@ -290,6 +293,37 @@ describe('sendMobileNativeChatMessage', () => { }) }) +describe('typeMobileNativeChatCommandWithOutcome', () => { + it('writes the Codex picker command as keys instead of one pasted text write', async () => { + vi.useFakeTimers() + const client = clientWithResponse({ + id: 'request', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } + }) + const result = typeMobileNativeChatCommandWithOutcome({ + client, + terminal: 'term', + command: '/model' + }) + await vi.runAllTimersAsync() + + await expect(result).resolves.toBe('accepted') + expect( + vi.mocked(client.sendRequest).mock.calls.map((call) => { + const params = call[1] as { text: string; enter: boolean } + return { text: params.text, enter: params.enter } + }) + ).toEqual( + ['\x15', '/', 'm', 'o', 'd', 'e', 'l', '\r'].map((text) => ({ + text, + enter: false + })) + ) + }) +}) + describe('clearMobileNativeChatInput', () => { const accepted = { id: 'request', diff --git a/mobile/src/session/mobile-native-chat-send.ts b/mobile/src/session/mobile-native-chat-send.ts index ce432c3efb9..e7cb0e45beb 100644 --- a/mobile/src/session/mobile-native-chat-send.ts +++ b/mobile/src/session/mobile-native-chat-send.ts @@ -2,6 +2,7 @@ import type { RpcClient } from '../transport/rpc-client' import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response' +import { typeAgentTuiCommand } from '../../../src/shared/agent-tui-command-typing' type MobileTerminalClient = { id: string @@ -93,6 +94,27 @@ export async function sendMobileNativeChatMessage( return (await sendMobileNativeChatMessageWithOutcome(args)) === 'accepted' } +export async function typeMobileNativeChatCommandWithOutcome(args: { + client: RpcClient + terminal: string + command: string + mobileClient?: MobileTerminalClient + deadline?: number +}): Promise { + return typeAgentTuiCommand({ + command: args.command, + write: (key) => + sendMobileNativeChatMessageWithOutcome({ + client: args.client, + terminal: args.terminal, + text: key, + enter: false, + ...(args.mobileClient ? { mobileClient: args.mobileClient } : {}), + ...(args.deadline === undefined ? {} : { deadline: args.deadline }) + }) + }) +} + /** * Clear the agent's input line as its OWN write, before any body. * diff --git a/mobile/src/session/use-mobile-native-chat-message-send.test.ts b/mobile/src/session/use-mobile-native-chat-message-send.test.ts index 06b30f5cdb4..6e92169b819 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.test.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.test.ts @@ -7,8 +7,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const sendWithOutcome = vi.fn() const clearInputWrite = vi.fn() +const typeCommandWithOutcome = vi.fn() vi.mock('./mobile-native-chat-send', () => ({ sendMobileNativeChatMessageWithOutcome: (...args: unknown[]) => sendWithOutcome(...args), + typeMobileNativeChatCommandWithOutcome: (...args: unknown[]) => typeCommandWithOutcome(...args), clearMobileNativeChatInput: (...args: unknown[]) => clearInputWrite(...args), openMobileNativeChatSendBudget: () => Date.now() + 15_000, MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS: 15_000, @@ -84,6 +86,8 @@ describe('useMobileNativeChatMessageSend', () => { sendWithOutcome.mockResolvedValue('accepted') clearInputWrite.mockReset() clearInputWrite.mockResolvedValue(true) + typeCommandWithOutcome.mockReset() + typeCommandWithOutcome.mockResolvedValue('accepted') acceptSend.mockReset() holdUnconfirmedSend.mockReset() onCommandSend.mockReset() @@ -260,6 +264,19 @@ describe('useMobileNativeChatMessageSend', () => { expect(sentArgs().resolvedLaunchDraft).toBeUndefined() }) + it('routes typed picker commands around the pasted composer-text send', async () => { + mount(() => null, 'codex') + let outcome: string | undefined + await act(async () => { + outcome = await api!.dispatchCommand('/model', { delivery: 'type' }) + }) + expect(outcome).toBe('accepted') + expect(typeCommandWithOutcome).toHaveBeenCalledWith( + expect.objectContaining({ command: '/model', terminal: 'term' }) + ) + expect(sendWithOutcome).not.toHaveBeenCalled() + }) + it('binds classification to the agent that started the send', async () => { let resolveSend!: (outcome: MobileNativeChatSendOutcome) => void sendWithOutcome.mockReturnValue( diff --git a/mobile/src/session/use-mobile-native-chat-message-send.ts b/mobile/src/session/use-mobile-native-chat-message-send.ts index 338c800cecd..e3bc6c0cabd 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.ts @@ -4,8 +4,10 @@ import { clearMobileNativeChatInput, openMobileNativeChatSendBudget, sendMobileNativeChatMessageWithOutcome, + typeMobileNativeChatCommandWithOutcome, type MobileNativeChatSendOutcome } from './mobile-native-chat-send' +import type { CatalogCommandDelivery } from '../../../src/shared/agent-session-option-catalog' import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input' import { classifyMobileNativeChatSend } from './mobile-native-chat-send-classification' import { @@ -32,7 +34,10 @@ export type MobileNativeChatMessageSend = { answerQuestion: (text: string) => Promise /** Session-option command dispatch (e.g. `/model sonnet`) — never touches the * composer draft; callers need the outcome to track dispatched state. */ - dispatchCommand: (text: string) => Promise + dispatchCommand: ( + text: string, + options?: { delivery?: CatalogCommandDelivery } + ) => Promise } /** The native-chat send seam: one write path shared by composer sends, image @@ -269,12 +274,41 @@ export function useMobileNativeChatMessageSend(args: { // spaces a send's body and its Enter ~500ms apart — so without this lock an // apply lands between them and is submitted as part of the user's prompt. const dispatchCommand = useCallback( - async (text: string): Promise => { + async ( + text: string, + options?: { delivery?: CatalogCommandDelivery } + ): Promise => { const terminal = handleRef.current if (terminal && !acquireMobileNativeChatTerminalWrite(terminal)) { return 'rejected' } try { + if (options?.delivery === 'type') { + if (!client || !terminal || !enabled) { + return 'rejected' + } + const deadline = openMobileNativeChatSendBudget() + const mobileClient = deviceTokenRef.current + ? { id: deviceTokenRef.current, type: 'mobile' as const } + : undefined + if ( + !(await healMobileNativeChatStaleInput({ + client, + terminal, + deviceToken: deviceTokenRef.current, + deadline + })) + ) { + return 'rejected' + } + return typeMobileNativeChatCommandWithOutcome({ + client, + terminal, + command: text, + ...(mobileClient ? { mobileClient } : {}), + deadline + }) + } return await sendMessage(text, undefined, false, false) } finally { if (terminal) { @@ -282,7 +316,7 @@ export function useMobileNativeChatMessageSend(args: { } } }, - [handleRef, sendMessage] + [client, deviceTokenRef, enabled, handleRef, sendMessage] ) return { send, sendWithOutcome, answerQuestion, dispatchCommand } diff --git a/mobile/src/session/use-mobile-native-chat-session-options.test.ts b/mobile/src/session/use-mobile-native-chat-session-options.test.ts index d7cab9a4c37..a587f3c5747 100644 --- a/mobile/src/session/use-mobile-native-chat-session-options.test.ts +++ b/mobile/src/session/use-mobile-native-chat-session-options.test.ts @@ -14,7 +14,7 @@ describe('useMobileNativeChatSessionOptions', () => { let renderer: ReactTestRenderer | null = null let api: MobileNativeChatSessionOptionsController | null = null let hookArgs: HookArgs - const dispatchCommand = vi.fn<(command: string) => Promise>() + const dispatchCommand = vi.fn() const onAgentPicker = vi.fn() function Probe(): null { @@ -97,14 +97,23 @@ describe('useMobileNativeChatSessionOptions', () => { expect(api!.snapshot[0]).toMatchObject({ valueSource: 'unknown' }) }) - it('applies Codex model changes through the native command', async () => { + it('types the Codex picker command and switches to the terminal', async () => { mount({ agent: 'codex' }) - expect(api!.snapshot[0]?.action).toBeUndefined() + expect(api!.snapshot[0]?.action).toEqual({ type: 'agent-picker' }) await act(async () => { - await api!.setOption('model', 'gpt-5.5') + await api!.invokeAction('model') }) - expect(dispatchCommand).toHaveBeenCalledWith('/model gpt-5.5') - expect(onAgentPicker).not.toHaveBeenCalled() + expect(dispatchCommand).toHaveBeenCalledWith('/model', { delivery: 'type' }) + expect(onAgentPicker).toHaveBeenCalledOnce() + }) + + it('types the Codex effort picker command', async () => { + mount({ agent: 'codex', reportedModel: 'gpt-5.5' }) + await act(async () => { + await api!.invokeAction('effort') + }) + expect(dispatchCommand).toHaveBeenCalledWith('/model', { delivery: 'type' }) + expect(onAgentPicker).toHaveBeenCalledOnce() }) it('seeds the current model from a hook-reported provider model', () => { diff --git a/mobile/src/session/use-mobile-native-chat-session-options.ts b/mobile/src/session/use-mobile-native-chat-session-options.ts index 09779756631..66a929aeb56 100644 --- a/mobile/src/session/use-mobile-native-chat-session-options.ts +++ b/mobile/src/session/use-mobile-native-chat-session-options.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import { getAgentSessionOptionCatalog, type AgentSessionOptionCatalog, + type CatalogCommandDelivery, type CatalogModel } from '../../../src/shared/agent-session-option-catalog' import type { @@ -95,7 +96,10 @@ export function useMobileNativeChatSessionOptions(args: { scopeKey: string | null /** Provider model from live agent status, when the hook reported one. */ reportedModel: string | null - dispatchCommand: (command: string) => Promise + dispatchCommand: ( + command: string, + options?: { delivery?: CatalogCommandDelivery } + ) => Promise /** A model change that must happen in the agent's own TUI picker was * dispatched — bring the terminal view forward. */ onAgentPicker?: () => void @@ -297,7 +301,9 @@ export function useMobileNativeChatSessionOptions(args: { ?.options.find((option) => option.id === id)?.apply const midSession = apply?.midSession if (midSession?.kind === 'agent-picker') { - const outcome = await dispatchCommand(midSession.command) + const outcome = midSession.delivery + ? await dispatchCommand(midSession.command, { delivery: midSession.delivery }) + : await dispatchCommand(midSession.command) if (outcome === 'rejected') { return false } diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index 665f95449e9..90fa4380c77 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -34,6 +34,7 @@ const mocks = vi.hoisted(() => ({ sendHandle: { cancel: vi.fn(), settleAfterMs: 500 }, sendNativeChatMessage: vi.fn(), sendNativeChatMessageVerified: vi.fn(), + typeNativeChatCommand: vi.fn(), trackPendingSend: vi.fn(), setDraft: vi.fn(), draftScopeKeys: [] as string[], @@ -65,6 +66,7 @@ vi.mock('./native-chat-runtime-send', () => ({ sendNativeChatMessage: (...args: unknown[]) => mocks.sendNativeChatMessage(...args), sendNativeChatMessageVerified: (...args: unknown[]) => mocks.sendNativeChatMessageVerified(...args), + typeNativeChatCommand: (...args: unknown[]) => mocks.typeNativeChatCommand(...args), sendNativeChatMessageWithImageAttachments: vi.fn(), submitNativeChatPrompt: vi.fn() })) @@ -181,6 +183,7 @@ describe('NativeChatComposer', () => { }) mocks.sendNativeChatMessage.mockReturnValue(mocks.sendHandle) mocks.sendNativeChatMessageVerified.mockResolvedValue(true) + mocks.typeNativeChatCommand.mockResolvedValue(true) mocks.sendHandle.settleAfterMs = 500 Object.defineProperty(window, 'api', { configurable: true, @@ -550,7 +553,7 @@ describe('NativeChatComposer', () => { expect(onSwitchToTerminal).toHaveBeenCalledOnce() }) - it('applies a Codex model change without switching to the terminal', async () => { + it('types the Codex picker command and switches to the terminal', async () => { mocks.sendHandle.settleAfterMs = 0 const onSwitchToTerminal = vi.fn() render( @@ -563,17 +566,17 @@ describe('NativeChatComposer', () => { /> ) - // Codex model changes are value-bearing commands; only effort still uses the TUI picker. await act(async () => { - await mocks.fieldProps?.sessionOptionsSurface?.setOption('model', 'gpt-5.5') + await mocks.fieldProps?.sessionOptionsSurface?.invokeAction('model') }) - expect(mocks.sendNativeChatMessageVerified).toHaveBeenCalledWith( + expect(mocks.typeNativeChatCommand).toHaveBeenCalledWith( {}, 'pty-1', - '/model gpt-5.5', + '/model', expect.any(AbortSignal) ) - expect(onSwitchToTerminal).not.toHaveBeenCalled() + expect(mocks.sendNativeChatMessageVerified).not.toHaveBeenCalled() + expect(onSwitchToTerminal).toHaveBeenCalledOnce() }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts b/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts index 0dd5e19025d..37850bccaec 100644 --- a/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts @@ -565,7 +565,7 @@ describe('native chat PTY session options', () => { ) const result = await surface.invokeAction('effort') - expect(dispatch).toHaveBeenCalledWith('/model') + expect(dispatch).toHaveBeenCalledWith('/model', { delivery: 'type' }) expect(onAgentPicker).toHaveBeenCalledOnce() expect(result.snapshot).toHaveLength(1) expect(result.snapshot[0]).toMatchObject({ valueSource: 'unknown' }) diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts index 93a63765a46..d5e54f205fa 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts @@ -12,6 +12,7 @@ vi.mock('@/runtime/runtime-terminal-inspection', () => ({ import { sendNativeChatMessage, sendNativeChatMessageVerified, + typeNativeChatCommand, sendNativeChatMessageWithImageAttachments, submitNativeChatPrompt, sendNativeChatAskAnswer, @@ -252,6 +253,35 @@ describe('sendNativeChatMessageVerified', () => { }) }) +describe('typeNativeChatCommand', () => { + beforeEach(() => { + vi.useFakeTimers() + sendRuntimePtyInputVerified.mockReset().mockResolvedValue(true) + resetNativeChatPtySendQueuesForTests() + }) + afterEach(() => { + vi.useRealTimers() + resetNativeChatPtySendQueuesForTests() + }) + + it('writes the Codex picker command as keys instead of one pasted text write', async () => { + const result = typeNativeChatCommand(SETTINGS, PTY, '/model') + await vi.runAllTimersAsync() + + await expect(result).resolves.toBe(true) + expectWriteOrder(sendRuntimePtyInputVerified.mock.calls, [ + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + '/', + 'm', + 'o', + 'd', + 'e', + 'l', + NATIVE_CHAT_SUBMIT + ]) + }) +}) + describe('sendNativeChatMessageWithImageAttachments', () => { beforeEach(() => { vi.useFakeTimers() diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts index a2974474b2b..02bb53fbcdd 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts @@ -19,6 +19,7 @@ import { buildNativeChatPasteBytes, NATIVE_CHAT_SUBMIT } from './native-chat-send' +import { typeAgentTuiCommand } from '../../../../shared/agent-tui-command-typing' import { cancelNativeChatPtySends, enqueueNativeChatPtySend, @@ -213,6 +214,24 @@ export async function sendNativeChatMessageVerified( return sendRuntimePtyInputVerified(settings, ptyId, NATIVE_CHAT_SUBMIT) } +/** Types a slash command as individual keys so Codex opens its command palette. */ +export async function typeNativeChatCommand( + settings: RuntimeSettings, + ptyId: string, + command: string, + signal?: AbortSignal +): Promise { + cancelNativeChatPtySends(ptyId) + await waitForNativeChatPtyIdle(ptyId) + const outcome = await typeAgentTuiCommand({ + command, + signal, + write: async (key) => + (await sendRuntimePtyInputVerified(settings, ptyId, key)) ? 'accepted' : 'rejected' + }) + return outcome === 'accepted' +} + export function sendNativeChatMessageWithImageAttachments( settings: RuntimeSettings, ptyId: string, diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-apply.ts b/src/renderer/src/components/native-chat/native-chat-session-option-apply.ts index 98481e1035c..5585512633a 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-apply.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-apply.ts @@ -109,7 +109,9 @@ async function handleAgentPicker( ctx: SessionOptionApplyContext, midSession: Extract ): Promise { - await ctx.dispatchCommand(midSession.command) + await (midSession.delivery + ? ctx.dispatchCommand(midSession.command, { delivery: midSession.delivery }) + : ctx.dispatchCommand(midSession.command)) ctx.clearModelTruth() const snapshot = ctx.publish() ctx.onAgentPicker?.() diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-command-dispatch.ts b/src/renderer/src/components/native-chat/native-chat-session-option-command-dispatch.ts index c510990dc5e..d7b071c55e3 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-command-dispatch.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-command-dispatch.ts @@ -1,4 +1,7 @@ -import type { CatalogAgentInteractionDetection } from '../../../../shared/agent-session-option-catalog' +import type { + CatalogAgentInteractionDetection, + CatalogCommandDelivery +} from '../../../../shared/agent-session-option-catalog' import type { ClaudeModelSwitchOutcome } from './claude-model-switch-confirmation' export type NativeChatSessionOptionDispatchResult = { @@ -10,6 +13,7 @@ export type NativeChatSessionOptionDispatchCommand = ( options?: { detectAgentInteraction?: CatalogAgentInteractionDetection expectedChoiceLabel?: string + delivery?: CatalogCommandDelivery } ) => | Promise diff --git a/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts b/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts index 96d3cbdf479..5070b0230fe 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts @@ -6,7 +6,7 @@ import { type NativeChatResolvedTarget } from './native-chat-composer-target' import { pushHistory, type HistoryState } from './native-chat-composer-state' -import { sendNativeChatMessageVerified } from './native-chat-runtime-send' +import { sendNativeChatMessageVerified, typeNativeChatCommand } from './native-chat-runtime-send' import { cancelNativeChatPtySends, waitForNativeChatPtyIdle } from './native-chat-pty-send-queue' import { createClaudeModelSwitchConfirmationObserver, @@ -86,12 +86,20 @@ export function useNativeChatSessionOptionCommand(args: { // submit immediately so historical output cannot satisfy the match. observer.arm() } - const accepted = await sendNativeChatMessageVerified( - target.settings, - target.ptyId, - command, - sendController.signal - ) + const accepted = + options?.delivery === 'type' + ? await typeNativeChatCommand( + target.settings, + target.ptyId, + command, + sendController.signal + ) + : await sendNativeChatMessageVerified( + target.settings, + target.ptyId, + command, + sendController.signal + ) if (!accepted) { throw new Error('The terminal did not accept the command.') } diff --git a/src/shared/agent-session-option-catalog-claude-codex.ts b/src/shared/agent-session-option-catalog-claude-codex.ts index 3e2f0f57921..4e9396632c7 100644 --- a/src/shared/agent-session-option-catalog-claude-codex.ts +++ b/src/shared/agent-session-option-catalog-claude-codex.ts @@ -204,7 +204,7 @@ function codexEffort(includeExtraHigh: boolean): CatalogOption { launchArgs: (value) => ['-c', `model_reasoning_effort=${String(value)}`], agentArgsOverride: hasCodexEffortOverride, removeAgentArgs: removeCodexEffortOverride, - midSession: { kind: 'agent-picker', command: '/model' } + midSession: { kind: 'agent-picker', command: '/model', delivery: 'type' } } } } @@ -228,12 +228,9 @@ export const CODEX_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { launchArgs: (value) => ['-m', String(value)], agentArgsOverride: (tokens) => hasFlag(tokens, ['-m', '--model']), removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['-m', '--model']), - // Codex accepts a model argument in its live /model command. - midSession: { - kind: 'command', - build: (value) => `/model ${String(value)}`, - pickerCommand: '/model' - } + // Codex classifies multi-character writes as pasted prose; type the bare + // command and let its own picker apply the account-supported model. + midSession: { kind: 'agent-picker', command: '/model', delivery: 'type' } }, unknownModelOptions: [codexEffort(true)] } diff --git a/src/shared/agent-session-option-catalog-types.ts b/src/shared/agent-session-option-catalog-types.ts index bb08339e07e..ae2af3b450f 100644 --- a/src/shared/agent-session-option-catalog-types.ts +++ b/src/shared/agent-session-option-catalog-types.ts @@ -6,6 +6,7 @@ import type { } from './native-chat-session-options' export type CatalogAgentInteractionDetection = 'claude-model-switch-confirmation' +export type CatalogCommandDelivery = 'type' export type CatalogMidSessionApply = | { @@ -15,7 +16,7 @@ export type CatalogMidSessionApply = detectAgentInteraction?: CatalogAgentInteractionDetection } | { kind: 'toggle-command'; command: string } - | { kind: 'agent-picker'; command: string } + | { kind: 'agent-picker'; command: string; delivery?: CatalogCommandDelivery } | { kind: 'unsupported' } export type CatalogOptionApply = { diff --git a/src/shared/agent-session-option-catalog.ts b/src/shared/agent-session-option-catalog.ts index e5e254c45de..b242b6b27c9 100644 --- a/src/shared/agent-session-option-catalog.ts +++ b/src/shared/agent-session-option-catalog.ts @@ -20,6 +20,7 @@ import type { SessionOptionValue } from './native-chat-session-options' export type { AgentSessionOptionCatalog, CatalogAgentInteractionDetection, + CatalogCommandDelivery, CatalogMidSessionApply, CatalogModel, CatalogOption, diff --git a/src/shared/agent-tui-command-typing.ts b/src/shared/agent-tui-command-typing.ts new file mode 100644 index 00000000000..389d7e03141 --- /dev/null +++ b/src/shared/agent-tui-command-typing.ts @@ -0,0 +1,43 @@ +import { AGENT_TUI_CLEAR_INPUT_LINE } from './agent-tui-input-clear' + +export type AgentTuiCommandWriteOutcome = 'accepted' | 'rejected' | 'unknown' + +export const AGENT_TUI_COMMAND_KEY_INTERVAL_MS = 16 + +function waitForNextKey(signal?: AbortSignal): Promise { + if (signal?.aborted) { + return Promise.resolve(false) + } + return new Promise((resolve) => { + const timer = setTimeout(() => finish(true), AGENT_TUI_COMMAND_KEY_INTERVAL_MS) + const finish = (completed: boolean): void => { + clearTimeout(timer) + signal?.removeEventListener('abort', onAbort) + resolve(completed) + } + const onAbort = (): void => finish(false) + signal?.addEventListener('abort', onAbort, { once: true }) + }) +} + +/** Sends one PTY write per key so slash-command TUIs do not classify it as pasted prose. */ +export async function typeAgentTuiCommand(args: { + command: string + write: (key: string) => Promise + signal?: AbortSignal +}): Promise { + const keys = [AGENT_TUI_CLEAR_INPUT_LINE, ...args.command, '\r'] + for (let index = 0; index < keys.length; index += 1) { + if (args.signal?.aborted) { + return 'rejected' + } + const outcome = await args.write(keys[index]!) + if (outcome !== 'accepted') { + return outcome + } + if (index < keys.length - 1 && !(await waitForNextKey(args.signal))) { + return 'rejected' + } + } + return 'accepted' +} diff --git a/src/shared/native-chat-session-option-commands.test.ts b/src/shared/native-chat-session-option-commands.test.ts index 2583192d34b..77205f6ba5c 100644 --- a/src/shared/native-chat-session-option-commands.test.ts +++ b/src/shared/native-chat-session-option-commands.test.ts @@ -68,7 +68,7 @@ describe('buildNativeChatSessionOptionCommand', () => { ).toBe('/fast') }) - it('builds an absolute command for live Codex model changes', () => { + it('does not turn a Codex model pick into pasted slash-command prose', () => { expect( buildNativeChatSessionOptionCommand({ optionId: 'model', @@ -79,7 +79,12 @@ describe('buildNativeChatSessionOptionCommand', () => { models: CODEX_SESSION_OPTION_CATALOG.models, record: createNativeChatSessionOptionRecord('codex') }) - ).toBe('/model gpt-5.5') + ).toBeNull() + expect(CODEX_SESSION_OPTION_CATALOG.modelApply.midSession).toEqual({ + kind: 'agent-picker', + command: '/model', + delivery: 'type' + }) }) }) diff --git a/src/shared/native-chat-session-option-snapshot.test.ts b/src/shared/native-chat-session-option-snapshot.test.ts index c6436f9aad4..961701b7415 100644 --- a/src/shared/native-chat-session-option-snapshot.test.ts +++ b/src/shared/native-chat-session-option-snapshot.test.ts @@ -207,7 +207,7 @@ describe('buildNativeChatSessionOptionSnapshot', () => { }) }) - it('exposes Codex model changes as native selectable values', () => { + it('routes Codex model changes through its typed TUI picker', () => { const snapshot = buildNativeChatSessionOptionSnapshot({ catalog: CODEX_SESSION_OPTION_CATALOG, models: CODEX_SESSION_OPTION_CATALOG.models, @@ -216,7 +216,7 @@ describe('buildNativeChatSessionOptionSnapshot', () => { modelLabel: 'Model' }) expect(snapshot[0]).toMatchObject({ settable: true }) - expect(snapshot[0]?.action).toBeUndefined() + expect(snapshot[0]?.action).toEqual({ type: 'agent-picker' }) expect(snapshot[0]?.kind).toMatchObject({ type: 'select', choices: expect.arrayContaining([{ value: 'gpt-5.5', label: 'GPT-5.5' }])