diff --git a/mobile/src/session/mobile-native-chat-ask.test.ts b/mobile/src/session/mobile-native-chat-ask.test.ts index 54712e32c20..f3e2d89fcef 100644 --- a/mobile/src/session/mobile-native-chat-ask.test.ts +++ b/mobile/src/session/mobile-native-chat-ask.test.ts @@ -5,6 +5,7 @@ import { formatAskAnswer, parseAskFromStatus } from '../../../src/shared/native-chat-ask' +import { buildGrokAskAnswerKeys } from '../../../src/shared/native-chat-grok-ask-answer' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' function msg(blocks: NativeChatMessage['blocks'], id = 'm'): NativeChatMessage { @@ -236,3 +237,60 @@ describe('buildAskAnswerKeys', () => { ]) }) }) + +describe('buildGrokAskAnswerKeys', () => { + it('selects and submits a single-question single-select answer', () => { + const prompt = { + questions: [ + { question: 'q', multiSelect: false, options: [{ label: 'Tabs' }, { label: 'Spaces' }] } + ] + } + expect(buildGrokAskAnswerKeys(prompt, [{ indices: [1] }])).toEqual([ + { raw: '2' }, + { raw: '\r' } + ]) + }) + + it('moves between questions with Grok navigation before submitting', () => { + const prompt = { + questions: [ + { question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] }, + { question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] } + ] + } + expect(buildGrokAskAnswerKeys(prompt, [{ indices: [1] }, { indices: [0] }])).toEqual([ + { raw: '2' }, + { raw: 'l' }, + { raw: '1' }, + { raw: '\r' } + ]) + }) + + it('picks every Grok multi-select option before submitting', () => { + const prompt = { + questions: [ + { + question: 'q', + multiSelect: true, + options: [{ label: 'A' }, { label: 'B' }, { label: 'C' }] + } + ] + } + expect(buildGrokAskAnswerKeys(prompt, [{ indices: [0, 2] }])).toEqual([ + { raw: '1' }, + { raw: '3' }, + { raw: '\r' } + ]) + }) + + it('opens Grok free text with z before typing and submitting', () => { + const prompt = { + questions: [{ question: 'q', multiSelect: false, options: [{ label: 'A' }] }] + } + expect(buildGrokAskAnswerKeys(prompt, [{ indices: [], other: 'custom' }])).toEqual([ + { raw: 'z' }, + { text: 'custom' }, + { raw: '\r' } + ]) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-answer-send.test.ts b/mobile/src/session/use-mobile-native-chat-answer-send.test.ts index 927a0a722ee..7dc3aa2d478 100644 --- a/mobile/src/session/use-mobile-native-chat-answer-send.test.ts +++ b/mobile/src/session/use-mobile-native-chat-answer-send.test.ts @@ -256,13 +256,21 @@ describe('useMobileNativeChatAnswerSend', () => { ]) }) - it('answers Grok selector prompts with the option number', async () => { + it('answers Grok selector prompts with its pick and submit keys', async () => { const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) await mount({ sendRequest } as unknown as RpcClient, vi.fn(), 'grok') - await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true) - expect(sendRequest).toHaveBeenCalledTimes(1) - expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '2', enter: false }) + let result: Promise | undefined + await act(async () => { + result = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }]) + }) + await act(async () => vi.runAllTimersAsync()) + + await expect(result).resolves.toBe(true) + expect(sendRequest.mock.calls.map((call) => call[1])).toEqual([ + expect.objectContaining({ text: '2', enter: false }), + expect.objectContaining({ text: '\r', enter: false }) + ]) }) it('keeps the marker for a Grok selector answer', async () => { @@ -270,9 +278,17 @@ describe('useMobileNativeChatAnswerSend', () => { await mount({ sendRequest } as unknown as RpcClient, vi.fn(), 'grok') markMobileNativeChatInputStale('terminal') - await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true) - expect(sendRequest).toHaveBeenCalledTimes(1) - expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '2', enter: false }) + let result: Promise | undefined + await act(async () => { + result = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }]) + }) + await act(async () => vi.runAllTimersAsync()) + + await expect(result).resolves.toBe(true) + expect(sendRequest.mock.calls.map((call) => call[1])).toEqual([ + expect.objectContaining({ text: '2', enter: false }), + expect.objectContaining({ text: '\r', enter: false }) + ]) expect(isMobileNativeChatInputStale('terminal')).toBe(true) }) @@ -731,7 +747,7 @@ describe('useMobileNativeChatAnswerSend', () => { settle.push(resolve) }) ) - await mount({ sendRequest } as unknown as RpcClient, onSendError, 'grok') + await mount({ sendRequest } as unknown as RpcClient, onSendError, 'omp') let first: Promise | undefined let second: Promise | undefined @@ -739,6 +755,7 @@ describe('useMobileNativeChatAnswerSend', () => { first = acceptedAnswerAsk?.(TABS_OR_SPACES, [{ indices: [0] }]) await Promise.resolve() }) + expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: 'Tabs', enter: true }) await act(async () => { second = acceptedAnswerAsk?.(TABS_OR_SPACES, [{ indices: [1] }]) await Promise.resolve() @@ -797,13 +814,14 @@ describe('useMobileNativeChatAnswerSend', () => { settle.push(resolve) }) ) - await mount({ sendRequest } as unknown as RpcClient, onSendError, 'grok') + await mount({ sendRequest } as unknown as RpcClient, onSendError, 'omp') let first: Promise | undefined await act(async () => { first = acceptedAnswerAsk?.(TABS_OR_SPACES, [{ indices: [1] }]) await Promise.resolve() }) + expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: 'Spaces', enter: true }) await act(async () => { answerSend?.cancelPending() settle[0]!(acceptedResponse()) diff --git a/mobile/src/session/use-mobile-native-chat-answer-send.ts b/mobile/src/session/use-mobile-native-chat-answer-send.ts index 1792b1ac930..e725c276302 100644 --- a/mobile/src/session/use-mobile-native-chat-answer-send.ts +++ b/mobile/src/session/use-mobile-native-chat-answer-send.ts @@ -7,6 +7,7 @@ import { type AskAnswerSelection, type AskPrompt } from '../../../src/shared/native-chat-ask' +import { buildGrokAskAnswerKeys } from '../../../src/shared/native-chat-grok-ask-answer' import type { RpcClient } from '../transport/rpc-client' import { MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } from './mobile-native-chat-answer-stepping' import { @@ -258,10 +259,13 @@ export function useMobileNativeChatAnswerSend(args: { const sent = (await sendTerminal(formatAskAnswer(prompt, selections), true)) || fail() return sent && writeTurnsRef.current.get(handle) === turn } + const transcriptAgent = resolveNativeChatTranscriptAgent(agentRef.current) const groups = - resolveNativeChatTranscriptAgent(agentRef.current) === 'codex' + transcriptAgent === 'codex' ? buildCodexAskAnswerKeys(prompt, selections) - : buildAskAnswerKeys(prompt, selections) + : transcriptAgent === 'grok' + ? buildGrokAskAnswerKeys(prompt, selections) + : buildAskAnswerKeys(prompt, selections) for (let index = 0; index < groups.length; index += 1) { if (generationRef.current !== generation) { return false diff --git a/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts b/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts index 1590f55e7cc..4ca46af89c7 100644 --- a/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts +++ b/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts @@ -13,10 +13,12 @@ import { type AskQuestion, type InteractiveQuestionParser } from '../../../../shared/native-chat-ask' +import { buildGrokAskAnswerKeys } from '../../../../shared/native-chat-grok-ask-answer' export { buildAskAnswerKeys, buildCodexAskAnswerKeys, + buildGrokAskAnswerKeys, formatAskAnswer, hasAskAnswer, parseAskFromStatus, diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx index 864c62f6f91..df6b6974db9 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx @@ -66,12 +66,12 @@ describe('useNativeChatInteractiveSend', () => { it('routes a non-selector answer through the pasted-text send path', () => { const { result } = renderHook(() => - useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'grok') + useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'omp') ) act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }])) - // Grok commits a pasted answer: label text 'B', not option-number keystrokes. + // OMP commits a pasted answer: label text 'B', not option-number keystrokes. expect(mocks.sendNativeChatMessage).toHaveBeenCalledWith( { terminalTabId: 'tab-1' }, 'pty-1', @@ -80,6 +80,22 @@ describe('useNativeChatInteractiveSend', () => { expect(mocks.sendNativeChatAskAnswer).not.toHaveBeenCalled() }) + it('routes a Grok answer through its question-card keystrokes', () => { + const { result } = renderHook(() => + useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'grok') + ) + + act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }])) + + expect(mocks.sendNativeChatAskAnswer).toHaveBeenCalledWith( + { terminalTabId: 'tab-1' }, + 'pty-1', + [{ raw: '2' }, { raw: '\r' }], + expect.any(Function) + ) + expect(mocks.sendNativeChatMessage).not.toHaveBeenCalled() + }) + it('routes a Codex answer through the option-number keystroke path', () => { const { result } = renderHook(() => useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'codex') diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts index cec3be66c8a..c2942a979c7 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts @@ -10,6 +10,7 @@ import { import { buildAskAnswerKeys, buildCodexAskAnswerKeys, + buildGrokAskAnswerKeys, formatAskAnswer, hasAskAnswer, type AskAnswerSelection, @@ -96,7 +97,7 @@ export function useNativeChatInteractiveSend( // Claude, Codex, and Grok ignore pasted labels but have different selector // state machines. OpenClaude follows Claude's path. const stepsAnswer = shouldStepNativeChatAskAnswer(agent) - const buildsCodexAnswer = resolveNativeChatTranscriptAgent(agent) === 'codex' + const transcriptAgent = resolveNativeChatTranscriptAgent(agent) // Why: pin the answered question's baseline BEFORE delivery. A late settle // callback (paced writes + remote acceptance can span seconds on SSH) must // not read the live status and mint a fresh baseline for a replacement @@ -132,9 +133,11 @@ export function useNativeChatInteractiveSend( ? sendNativeChatAskAnswer( settings, targetPtyId, - buildsCodexAnswer + transcriptAgent === 'codex' ? buildCodexAskAnswerKeys(prompt, selections) - : buildAskAnswerKeys(prompt, selections), + : transcriptAgent === 'grok' + ? buildGrokAskAnswerKeys(prompt, selections) + : buildAskAnswerKeys(prompt, selections), onSettled ) : sendNativeChatMessage(settings, targetPtyId, formatAskAnswer(prompt, selections)) diff --git a/src/renderer/src/components/terminal-pane/agent-question-answered-inference.test.ts b/src/renderer/src/components/terminal-pane/agent-question-answered-inference.test.ts index 1460ef5b5e6..017ca511ca6 100644 --- a/src/renderer/src/components/terminal-pane/agent-question-answered-inference.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-question-answered-inference.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { isQuestionAnsweredSubmitInput } from '../../../../shared/agent-question-answered-intent' import { createAgentQuestionAnsweredInference } from './agent-question-answered-inference' const PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111' @@ -99,6 +100,19 @@ describe('agent question-answered inference', () => { } }) + it('keeps waiting when a Grok multi_select digit only picks a partial answer', () => { + const interactivePrompt = JSON.stringify({ + questions: [ + { + question: 'pick several', + multi_select: true, + options: [{ label: 'A' }, { label: 'B' }] + } + ] + }) + expect(isQuestionAnsweredSubmitInput('1', interactivePrompt)).toBe(false) + }) + it('keeps waiting when the synthetic free-text row is selected', () => { const { inference, inferQuestionAnswered } = makeInference(makeWaitingQuestionEntry()) diff --git a/src/shared/agent-question-answered-intent.ts b/src/shared/agent-question-answered-intent.ts index 97ac36f5cfd..c069e74769b 100644 --- a/src/shared/agent-question-answered-intent.ts +++ b/src/shared/agent-question-answered-intent.ts @@ -43,8 +43,17 @@ function readSingleSelectOptionCount(interactivePrompt: string | undefined): num if (!Array.isArray(parsed.questions) || parsed.questions.length !== 1) { return -1 } - const [question] = parsed.questions as { multiSelect?: unknown; options?: unknown }[] - if (!question || question.multiSelect === true || !Array.isArray(question.options)) { + const [question] = parsed.questions as { + multiSelect?: unknown + multi_select?: unknown + options?: unknown + }[] + if ( + !question || + question.multiSelect === true || + question.multi_select === true || + !Array.isArray(question.options) + ) { return -1 } return question.options.length diff --git a/src/shared/native-chat-grok-ask-answer.ts b/src/shared/native-chat-grok-ask-answer.ts new file mode 100644 index 00000000000..8ef8d354b4e --- /dev/null +++ b/src/shared/native-chat-grok-ask-answer.ts @@ -0,0 +1,53 @@ +import type { AskAnswerKeyGroup, AskAnswerSelection, AskPrompt } from './native-chat-ask' + +const GROK_ENTER = '\r' +const GROK_NEXT_QUESTION = 'l' +const GROK_FREE_TEXT = 'z' + +function optionKey(index: number): string | null { + if (index >= 0 && index < 9) { + return String(index + 1) + } + if (index >= 9 && index < 15) { + return String.fromCharCode('a'.charCodeAt(0) + index - 9) + } + return null +} + +/** Grok picks options by shortcut, moves questions with `l`, and opens free text with `z`. */ +export function buildGrokAskAnswerKeys( + prompt: AskPrompt, + selections: AskAnswerSelection[] +): AskAnswerKeyGroup[] { + const groups: AskAnswerKeyGroup[] = [] + + prompt.questions.forEach((question, questionIndex) => { + const selection = selections[questionIndex] + const other = (selection?.other ?? '').trim() + const selectedIndices = question.multiSelect + ? (selection?.indices ?? []) + : (selection?.indices.slice(0, 1) ?? []) + + if (!other || question.multiSelect) { + for (const index of selectedIndices) { + const key = optionKey(index) + if (key) { + groups.push({ raw: key }) + } + } + } + + if (other) { + const freeText = question.multiSelect + ? other + : [question.options[selectedIndices[0] ?? -1]?.label, other].filter(Boolean).join(', ') + groups.push({ raw: GROK_FREE_TEXT }, { text: freeText }, { raw: GROK_ENTER }) + } else if (questionIndex < prompt.questions.length - 1) { + groups.push({ raw: GROK_NEXT_QUESTION }) + } else if (selectedIndices.length > 0) { + groups.push({ raw: GROK_ENTER }) + } + }) + + return groups +}