fix(agent-status): map codex request_user_input questions to Needs You (#9861)

* fix(agent-status): map codex request_user_input questions to waiting

Codex 0.145 asks user questions via the auto-allowed request_user_input
tool (experimental default_mode_request_user_input): PreToolUse fires
while blocked on the answer with no Stop, so Orca showed the pane as
working/idle instead of Needs You. Map that PreToolUse to waiting
(mirrors grok's ask_user_question), exempt question waits from the codex
yolo auto-approval suppressor, and deliver native-chat answers to the
digit-commit selector by option number (typed labels are ignored and
Enter commits the highlighted first option). Older codex versions emit
no such event and are unchanged.

* fix(native-chat): preserve codex question answer semantics
This commit is contained in:
Brennan Benson 2026-07-22 12:00:14 -07:00 committed by GitHub
parent 4c2bb508c3
commit 0121f571e4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 329 additions and 37 deletions

View File

@ -185,12 +185,45 @@ describe('useMobileNativeChatAnswerSend', () => {
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '2', enter: false })
})
it('submits a non-Claude answer as pasted label text with a single Enter', async () => {
it('submits a Codex answer by option-number keystroke like Claude', async () => {
const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
await mount({ sendRequest } as unknown as RpcClient, vi.fn(), 'codex')
await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true)
// Codex's question tool commits the pasted answer: label text + one Enter.
// Codex's request_user_input card ignores pasted labels; the digit selects AND commits.
expect(sendRequest).toHaveBeenCalledTimes(1)
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '2', enter: false })
})
it('does not send a trailing Enter after Codex submits a multi-question answer', async () => {
const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
await mount({ sendRequest } as unknown as RpcClient, vi.fn(), 'codex')
const prompt: AskPrompt = {
questions: [
{ question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] },
{ question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] }
]
}
let result: Promise<boolean> | undefined
await act(async () => {
result = answerSend?.answerAsk(prompt, [{ indices: [1] }, { indices: [0] }])
})
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: '1', enter: false })
])
})
it('submits a non-selector answer as pasted label text with a single Enter', 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)
// Grok's question tool commits the pasted answer: label text + one Enter.
expect(sendRequest).toHaveBeenCalledTimes(1)
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: 'Spaces', enter: true })
})

View File

@ -3,16 +3,20 @@ import type { RpcClient } from '../transport/rpc-client'
import { MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } from './mobile-native-chat-answer-stepping'
import {
buildAskAnswerKeys,
buildCodexAskAnswerKeys,
formatAskAnswer,
hasAskAnswer,
type AskAnswerSelection,
type AskPrompt
} from './mobile-native-chat-ask'
import { sendMobileNativeChatMessage } from './mobile-native-chat-send'
import { shouldStepNativeChatAskAnswer } from '../../../src/shared/native-chat-agent-support'
import {
resolveNativeChatTranscriptAgent,
shouldStepNativeChatAskAnswer
} from '../../../src/shared/native-chat-agent-support'
/** Sends an AskUserQuestion answer to the active chat pane. Claude's selector is
* answered by option-number keystrokes; other agents get pasted label text.
/** Sends an ask-user answer to the active chat pane. Claude and Codex selectors
* use their agent-specific keystrokes; other agents get pasted label text.
* Extracted from the session route to keep that file under its line cap and to
* own the pending-timer lifecycle in one place. */
export type MobileNativeChatAnswerSend = {
@ -32,8 +36,8 @@ function sanitizeAskFreeText(text: string): string {
/**
* Owns the ask-answer send sequence for the mobile native chat. Reads the live
* pane/agent through refs (the route already keeps them current) so the returned
* callbacks stay stable. Claude answers are delivered as `buildAskAnswerKeys`
* keystroke groups written one selector-step apart over the EXISTING
* callbacks stay stable. Selector answers are delivered as keystroke groups
* written one step apart over the EXISTING
* `terminal.send` passthrough (raw text, no enter) same contract the
* permission card already uses, so old runtimes replay them verbatim (no new
* RPC; keystrokes are built client-side). The scheduled wait chain is cancelled
@ -136,14 +140,15 @@ export function useMobileNativeChatAnswerSend(args: {
}
return false
}
// Non-Claude question tools commit a pasted answer, so send the label text
// with one Enter. Claude's arrow-navigate selector ignores pasted labels
// (STA-1860): drive it by option-number keystrokes instead, one group per
// selector step so each renders before the next lands.
// Grok commits pasted labels; Claude and Codex need their selector-specific
// keystrokes paced so each step renders before the next lands.
if (!shouldStepNativeChatAskAnswer(agentRef.current)) {
return (await sendTerminal(formatAskAnswer(prompt, selections), true)) || fail()
}
const groups = buildAskAnswerKeys(prompt, selections)
const groups =
resolveNativeChatTranscriptAgent(agentRef.current) === 'codex'
? buildCodexAskAnswerKeys(prompt, selections)
: buildAskAnswerKeys(prompt, selections)
for (let index = 0; index < groups.length; index += 1) {
if (generationRef.current !== generation) {
return false

View File

@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
buildAskAnswerKeys,
buildCodexAskAnswerKeys,
formatAskAnswer,
hasAskAnswer,
parseApprovalFromStatus,
@ -240,6 +241,52 @@ describe('buildAskAnswerKeys', () => {
})
})
describe('buildCodexAskAnswerKeys', () => {
it("submits the final multi-question option without Claude's extra Enter", () => {
const prompt: AskPrompt = {
questions: [
{ question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] },
{ question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] }
]
}
expect(buildCodexAskAnswerKeys(prompt, [{ indices: [1] }, { indices: [0] }])).toEqual([
{ raw: '2' },
{ raw: '1' }
])
})
it('adds free text as notes before committing the selected row', () => {
expect(
buildCodexAskAnswerKeys(single(['Tabs', 'Spaces']), [
{ indices: [1], other: 'Keep existing files' }
])
).toEqual([{ raw: '\x1b[B' }, { raw: '\t' }, { text: 'Keep existing files' }, { raw: '\r' }])
})
it("targets Codex's synthetic None-of-the-above row for a custom answer", () => {
expect(
buildCodexAskAnswerKeys(single(['Tabs', 'Spaces']), [{ indices: [], other: 'Four spaces' }])
).toEqual([{ raw: '\x1b[A' }, { raw: '\t' }, { text: 'Four spaces' }, { raw: '\r' }])
})
it('clears skipped rows and confirms the partial answer once', () => {
const prompt: AskPrompt = {
questions: [
{ question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] },
{ question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] }
]
}
expect(buildCodexAskAnswerKeys(prompt, [{ indices: [] }, { indices: [1] }])).toEqual([
{ raw: '\x7f' },
{ raw: '\x1b[C' },
{ raw: '2' },
{ raw: '\r' }
])
})
})
describe('hasAskAnswer', () => {
it('is true for a picked option or typed text, false when empty', () => {
expect(hasAskAnswer(single(['A', 'B']), [{ indices: [1] }])).toBe(true)

View File

@ -1,6 +1,7 @@
import { translate } from '@/i18n/i18n'
import {
buildAskAnswerKeys,
buildCodexAskAnswerKeys,
formatAskAnswer,
hasAskAnswer,
parseAskFromStatus,
@ -15,6 +16,7 @@ import {
export {
buildAskAnswerKeys,
buildCodexAskAnswerKeys,
formatAskAnswer,
hasAskAnswer,
parseAskFromStatus,

View File

@ -64,14 +64,14 @@ describe('useNativeChatInteractiveSend', () => {
mocks.sendNativeChatMessage.mockReturnValue(handle)
})
it('routes a non-Claude answer through the pasted-text send path', () => {
it('routes a non-selector answer through the pasted-text send path', () => {
const { result } = renderHook(() =>
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'codex')
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'grok')
)
act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }]))
// Codex commits a pasted answer: label text 'B', not option-number keystrokes.
// Grok commits a pasted answer: label text 'B', not option-number keystrokes.
expect(mocks.sendNativeChatMessage).toHaveBeenCalledWith(
{ terminalTabId: 'tab-1' },
'pty-1',
@ -80,6 +80,40 @@ describe('useNativeChatInteractiveSend', () => {
expect(mocks.sendNativeChatAskAnswer).not.toHaveBeenCalled()
})
it('routes a Codex answer through the option-number keystroke path', () => {
const { result } = renderHook(() =>
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'codex')
)
act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }]))
// Codex's request_user_input card ignores typed labels (STA-1860 shape):
// the 2nd option is delivered as its digit '2', which selects AND commits.
expect(mocks.sendNativeChatAskAnswer).toHaveBeenCalledWith(
{ terminalTabId: 'tab-1' },
'pty-1',
[{ raw: '2' }],
expect.any(Function)
)
expect(mocks.sendNativeChatMessage).not.toHaveBeenCalled()
})
it('does not send a trailing Enter after Codex submits a multi-question answer', () => {
const prompt: AskPrompt = {
questions: [
{ question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] },
{ question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] }
]
}
const { result } = renderHook(() =>
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'codex')
)
act(() => result.current.sendAnswer(prompt, [{ indices: [1] }, { indices: [0] }]))
expect(mocks.sendNativeChatAskAnswer.mock.calls[0]?.[2]).toEqual([{ raw: '2' }, { raw: '1' }])
})
it('routes a Claude answer through the option-number keystroke path', () => {
const { result } = renderHook(() =>
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'claude')

View File

@ -3,9 +3,13 @@ import { useAppStore } from '../../store'
import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection'
import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
import type { AgentType } from '../../../../shared/native-chat-types'
import { shouldStepNativeChatAskAnswer } from '../../../../shared/native-chat-agent-support'
import {
resolveNativeChatTranscriptAgent,
shouldStepNativeChatAskAnswer
} from '../../../../shared/native-chat-agent-support'
import {
buildAskAnswerKeys,
buildCodexAskAnswerKeys,
formatAskAnswer,
hasAskAnswer,
type AskAnswerSelection,
@ -42,9 +46,8 @@ export type NativeChatInteractiveSend = {
* Reuse the desktop composer's exact send path for the interactive cards:
* resolve this tab's live ptyId + runtime owner settings, then write bytes via
* `sendRuntimePtyInput` (which branches local pty:write vs remote runtime RPC,
* so SSH panes work unchanged). Claude's AskUserQuestion answers are delivered
* as selector keystrokes (by option number, `sendNativeChatAskAnswer`); other
* agents' question tools commit a pasted answer, so those still go through
* so SSH panes work unchanged). Claude and Codex answers use their respective
* selector keystrokes via `sendNativeChatAskAnswer`; other agents still go through
* `sendNativeChatMessage`. Control strings (option digits, ESC) are written raw.
*/
export function useNativeChatInteractiveSend(
@ -90,13 +93,10 @@ export function useNativeChatInteractiveSend(
// Cancel any prior in-flight answer before starting a new one.
cancelInFlight()
const settings = getSettingsForAgentTabRuntimeOwner(terminalTabId)
// Claude's AskUserQuestion is an arrow-navigate selector: it commits by the
// highlighted option, not a pasted label, so answer it with per-option
// keystrokes (by option number), paced so each step renders before the next.
// Other agents' question tools commit a pasted answer, so send label text.
// Gate on the transcript agent (not `=== 'claude'`) so OpenClaude — which
// runs the same selector — takes the keystroke path too.
// Claude and Codex ignore pasted labels but have different selector state
// machines; Grok commits pasted text. OpenClaude follows Claude's path.
const stepsAnswer = shouldStepNativeChatAskAnswer(agent)
const buildsCodexAnswer = resolveNativeChatTranscriptAgent(agent) === 'codex'
// 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,7 +132,9 @@ export function useNativeChatInteractiveSend(
? sendNativeChatAskAnswer(
settings,
targetPtyId,
buildAskAnswerKeys(prompt, selections),
buildsCodexAnswer
? buildCodexAskAnswerKeys(prompt, selections)
: buildAskAnswerKeys(prompt, selections),
onSettled
)
: sendNativeChatMessage(settings, targetPtyId, formatAskAnswer(prompt, selections))

View File

@ -83,6 +83,25 @@ describe('Codex auto-approval status suppression', () => {
).toBe(true)
})
it('preserves request_user_input question waits even under yolo attribution', () => {
registerCodexLaunchConfig({
agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '',
launchToken
})
expect(
shouldSuppressCodexAutoApprovalStatus(
{
state: 'waiting',
prompt: 'pick a color',
agentType: 'codex',
toolName: 'request_user_input'
},
{ paneKey, tabId: 'tab-1', launchToken }
)
).toBe(false)
})
it('preserves manual Codex permission attention', () => {
registerCodexLaunchConfig({ agentArgs: '', launchToken })

View File

@ -1,3 +1,4 @@
import { isAskUserQuestionTool } from '../../../../shared/agent-question-answered-intent'
import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume'
import { getSyntheticAgentTitleProfile } from '../../../../shared/synthetic-agent-title'
import { resolveTuiAgentPermissionMode } from '../../../../shared/tui-agent-permissions'
@ -28,6 +29,10 @@ export function shouldSuppressCodexAutoApprovalStatus(
if (payload.agentType !== 'codex' || !isCodexAutoApprovedPermissionState(payload.state)) {
return false
}
// Why: request_user_input waits are real questions the user must answer — yolo auto-approval never resolves them, so they must keep driving status.
if (isAskUserQuestionTool(payload.toolName)) {
return false
}
const state = useAppStore.getState()
if (typeof state.getAgentLaunchConfigForStatusMetadata !== 'function') {

View File

@ -2519,6 +2519,82 @@ describe('shared agent-hook-listener', () => {
expect(next?.payload.toolInput).toBeUndefined()
})
it('maps Codex request_user_input PreToolUse to waiting with the question card, then clears on the answer', () => {
// Real Codex 0.145 shapes: PreToolUse fires while blocked on the answer (no Stop),
// PostToolUse carries the answers, Stop ends the turn.
const questions = {
questions: [
{
id: 'color_preference',
header: 'Color',
question: 'Which color do you prefer: red or blue?',
options: [{ label: 'Blue', description: 'Choose blue.' }]
}
]
}
const waiting = normalizeHookPayload(
state,
'codex',
{
paneKey: PANE_KEY,
payload: {
hook_event_name: 'PreToolUse',
tool_name: 'request_user_input',
tool_input: questions,
tool_use_id: 'call_1'
}
},
'production'
)
expect(waiting?.payload.state).toBe('waiting')
expect(waiting?.payload.toolName).toBe('request_user_input')
expect(waiting?.payload.interactivePrompt).toBe(JSON.stringify(questions))
const answered = normalizeHookPayload(
state,
'codex',
{
paneKey: PANE_KEY,
payload: {
hook_event_name: 'PostToolUse',
tool_name: 'request_user_input',
tool_input: questions,
tool_response: '{"answers":{"color_preference":{"answers":["Blue"]}}}',
tool_use_id: 'call_1'
}
},
'production'
)
expect(answered?.payload.state).toBe('working')
expect(answered?.payload.interactivePrompt).toBeUndefined()
const stop = normalizeHookPayload(
state,
'codex',
{ paneKey: PANE_KEY, payload: { hook_event_name: 'Stop' } },
'production'
)
expect(stop?.payload.state).toBe('done')
})
it('keeps ordinary Codex PreToolUse mapped to working', () => {
const working = normalizeHookPayload(
state,
'codex',
{
paneKey: PANE_KEY,
payload: {
hook_event_name: 'PreToolUse',
tool_name: 'shell',
tool_input: { command: 'ls' }
}
},
'production'
)
expect(working?.payload.state).toBe('working')
expect(working?.payload.interactivePrompt).toBeUndefined()
})
it('clears stale Droid tool input when a same-tool update has explicit unpreviewable input', () => {
normalizeHookPayload(
state,

View File

@ -3230,13 +3230,17 @@ function normalizeCodexEvent(
return normalizeCodexSubagentLifecycleEvent(state, eventName, paneKey, hookPayload)
}
// Why: Codex's request_user_input (0.145+) is auto-allowed, so it fires PreToolUse while blocked on a human answer; map to waiting like grok's ask_user_question.
const isUserInputPreTool =
eventName === 'PreToolUse' &&
isAskUserQuestionTool(readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name'))
const stateName =
eventName === 'SessionStart' ||
eventName === 'UserPromptSubmit' ||
eventName === 'PreToolUse' ||
(eventName === 'PreToolUse' && !isUserInputPreTool) ||
eventName === 'PostToolUse'
? 'working'
: eventName === 'PermissionRequest'
: eventName === 'PermissionRequest' || isUserInputPreTool
? 'waiting'
: eventName === 'Stop'
? 'done'

View File

@ -11,12 +11,14 @@ export type AgentQuestionAnsweredInferenceRequest = {
baselineAgentType: AgentType | undefined
}
/** True for the AskUserQuestion tool across the casing variants different
* agents emit (`AskUserQuestion` / `ask_user_question` / `askUserQuestion`).
/** True for the ask-the-user-a-question tool across agents: Claude's
* `AskUserQuestion`, grok/Pi's `ask_user_question`, and Codex ≥0.145's
* `request_user_input` (same questions/options input shape).
* Why: this is the structured "pick an option" prompt whose full input the
* clients render as a live card. */
export function isAskUserQuestionTool(toolName: string | undefined): boolean {
return toolName?.replaceAll(/[^a-z0-9]/gi, '').toLowerCase() === 'askuserquestion'
const normalized = toolName?.replaceAll(/[^a-z0-9]/gi, '').toLowerCase()
return normalized === 'askuserquestion' || normalized === 'requestuserinput'
}
const QUESTION_ANSWER_ENTER_INPUTS: ReadonlySet<string> = new Set([

View File

@ -31,13 +31,15 @@ describe('isNativeChatSupportedAgent', () => {
})
describe('shouldStepNativeChatAskAnswer', () => {
it('steps only the Claude-format agents (Claude, OpenClaude)', () => {
it('steps the digit-commit selector agents (Claude, OpenClaude, Codex)', () => {
expect(shouldStepNativeChatAskAnswer('claude')).toBe(true)
expect(shouldStepNativeChatAskAnswer('openclaude')).toBe(true)
// Codex 0.145's request_user_input card ignores typed labels and commits on
// the highlighted row, so pasted answers misdeliver like STA-1860.
expect(shouldStepNativeChatAskAnswer('codex')).toBe(true)
})
it('does not step other or unknown agents', () => {
expect(shouldStepNativeChatAskAnswer('codex')).toBe(false)
expect(shouldStepNativeChatAskAnswer('grok')).toBe(false)
expect(shouldStepNativeChatAskAnswer('cursor')).toBe(false)
expect(shouldStepNativeChatAskAnswer(null)).toBe(false)

View File

@ -12,11 +12,14 @@ export function isNativeChatSupportedAgent(agent: string | null | undefined): bo
return agent != null && NATIVE_CHAT_SUPPORTED_AGENTS.has(agent)
}
/** True when the agent renders Claude's multi-step AskUserQuestion one question
* per step, each Enter advancing so a multi-line answer must be paced per line.
* Other agents submit the whole answer with a single Enter. */
/** True when the agent renders a digit-commit question selector that ignores
* typed label text (pasting "Blue" + Enter commits the highlighted FIRST
* option STA-1860): Claude's AskUserQuestion and Codex 0.145's
* request_user_input card both behave this way, so answers must be delivered
* as per-option keystrokes. Other agents commit a pasted answer. */
export function shouldStepNativeChatAskAnswer(agent: string | null | undefined): boolean {
return resolveNativeChatTranscriptAgent(agent) === 'claude'
const transcriptAgent = resolveNativeChatTranscriptAgent(agent)
return transcriptAgent === 'claude' || transcriptAgent === 'codex'
}
export function resolveNativeChatTranscriptAgent(

View File

@ -160,6 +160,9 @@ export function formatAskAnswer(prompt: AskPrompt, selections: AskAnswerSelectio
// has applied it.
const ASK_ENTER = '\r'
const ASK_NEXT_TAB = '\x1b[C'
const ASK_PREVIOUS_ROW = '\x1b[A'
const ASK_NEXT_ROW = '\x1b[B'
const ASK_NOTES = '\t'
/** Build the ordered keystroke groups that answer a Claude Code AskUserQuestion.
* Each group is written a step apart so the selector applies it before the next.
@ -221,6 +224,61 @@ export function buildAskAnswerKeys(
return groups
}
/** Build keystrokes for Codex's request_user_input overlay.
*
* Unlike Claude, Codex submits on the final option digit and attaches free text
* as notes to the highlighted row. The overlay starts on the first row, so note
* answers move to the target without committing, open notes with Tab, then
* submit with Enter. */
export function buildCodexAskAnswerKeys(
prompt: AskPrompt,
selections: AskAnswerSelection[]
): AskAnswerKeyGroup[] {
const groups: AskAnswerKeyGroup[] = []
let hasUnanswered = false
prompt.questions.forEach((question, questionIndex) => {
const selection = selections[questionIndex]
const selectedIndex = selection?.indices[0]
const note = (selection?.other ?? '').trim()
if (note) {
const targetIndex = selectedIndex ?? question.options.length
const rowCount = question.options.length + 1
const nextSteps = targetIndex
const previousSteps = rowCount - targetIndex
const usePrevious = previousSteps < nextSteps
const navigationKey = usePrevious ? ASK_PREVIOUS_ROW : ASK_NEXT_ROW
const navigationSteps = usePrevious ? previousSteps : nextSteps
for (let index = 0; index < navigationSteps; index += 1) {
groups.push({ raw: navigationKey })
}
groups.push({ raw: ASK_NOTES }, { text: note }, { raw: ASK_ENTER })
return
}
if (selectedIndex !== undefined) {
groups.push({ raw: String(selectedIndex + 1) })
return
}
hasUnanswered = true
groups.push({ raw: '\x7f' })
if (questionIndex < prompt.questions.length - 1) {
groups.push({ raw: ASK_NEXT_TAB })
} else {
groups.push({ raw: ASK_ENTER })
}
})
// Codex opens a confirmation after the last question when any were skipped;
// Proceed is highlighted by default, so one Enter submits the partial answer.
if (hasUnanswered) {
groups.push({ raw: ASK_ENTER })
}
return groups
}
/** Whether any question in `selections` carries an answer worth submitting. */
export function hasAskAnswer(prompt: AskPrompt, selections: AskAnswerSelection[]): boolean {
return prompt.questions.some((_, i) => isAnswered(selections[i]))