diff --git a/packages/cli/src/ui/components/messages/AskUserQuestionDialog.test.tsx b/packages/cli/src/ui/components/messages/AskUserQuestionDialog.test.tsx new file mode 100644 index 000000000..a8884d805 --- /dev/null +++ b/packages/cli/src/ui/components/messages/AskUserQuestionDialog.test.tsx @@ -0,0 +1,913 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { AskUserQuestionDialog } from './AskUserQuestionDialog.js'; +import type { ToolAskUserQuestionConfirmationDetails } from '@qwen-code/qwen-code-core'; +import { ToolConfirmationOutcome } from '@qwen-code/qwen-code-core'; +import { renderWithProviders } from '../../../test-utils/render.js'; + +const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms)); + +const createSingleQuestion = ( + overrides: Partial< + ToolAskUserQuestionConfirmationDetails['questions'][0] + > = {}, +): ToolAskUserQuestionConfirmationDetails['questions'][0] => ({ + question: 'What is your favorite color?', + header: 'Color', + options: [ + { label: 'Red', description: 'A warm color' }, + { label: 'Blue', description: 'A cool color' }, + { label: 'Green', description: '' }, + ], + multiSelect: false, + ...overrides, +}); + +const createConfirmationDetails = ( + overrides: Partial = {}, +): ToolAskUserQuestionConfirmationDetails => ({ + type: 'ask_user_question', + title: 'Question', + questions: [createSingleQuestion()], + onConfirm: vi.fn(), + ...overrides, +}); + +describe('', () => { + describe('rendering', () => { + it('renders single question with options', () => { + const details = createConfirmationDetails(); + const onConfirm = vi.fn(); + + const { lastFrame } = renderWithProviders( + , + ); + + const output = lastFrame(); + expect(output).toContain('What is your favorite color?'); + expect(output).toContain('Red'); + expect(output).toContain('Blue'); + expect(output).toContain('Green'); + expect(output).toContain('A warm color'); + expect(output).toContain('A cool color'); + }); + + it('renders header for single question', () => { + const details = createConfirmationDetails(); + const onConfirm = vi.fn(); + + const { lastFrame } = renderWithProviders( + , + ); + + expect(lastFrame()).toContain('Color'); + }); + + it('renders "Type something..." custom input option', () => { + const details = createConfirmationDetails(); + const onConfirm = vi.fn(); + + const { lastFrame } = renderWithProviders( + , + ); + + expect(lastFrame()).toContain('Type something...'); + }); + + it('renders help text for single select', () => { + const details = createConfirmationDetails(); + const onConfirm = vi.fn(); + + const { lastFrame } = renderWithProviders( + , + ); + + expect(lastFrame()).toContain('Enter: Select'); + expect(lastFrame()).toContain('Esc: Cancel'); + expect(lastFrame()).not.toContain('Switch tabs'); + }); + + it('renders tabs for multiple questions', () => { + const details = createConfirmationDetails({ + questions: [ + createSingleQuestion({ header: 'Q1' }), + createSingleQuestion({ + header: 'Q2', + question: 'Second question?', + }), + ], + }); + const onConfirm = vi.fn(); + + const { lastFrame } = renderWithProviders( + , + ); + + const output = lastFrame(); + expect(output).toContain('Q1'); + expect(output).toContain('Q2'); + expect(output).toContain('Submit'); + expect(output).toContain('Switch tabs'); + }); + + it('renders multi-select with checkboxes and submit option', () => { + const details = createConfirmationDetails({ + questions: [createSingleQuestion({ multiSelect: true })], + }); + const onConfirm = vi.fn(); + + const { lastFrame } = renderWithProviders( + , + ); + + const output = lastFrame(); + expect(output).toContain('[ ]'); + expect(output).toContain('Submit'); + expect(output).toContain('Space/Enter: Toggle'); + }); + }); + + describe('single-select interaction', () => { + it('selects an option with Enter and submits immediately for single question', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails(); + + const { stdin, unmount } = renderWithProviders( + , + ); + await wait(); + + // Press Enter to select the first option (Red) + stdin.write('\r'); + await wait(); + + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { 0: 'Red' } }, + ); + unmount(); + }); + + it('navigates down with arrow key and selects', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails(); + + const { stdin, unmount } = renderWithProviders( + , + ); + await wait(); + + // Navigate down to "Blue" + stdin.write('\u001B[B'); // Down arrow + await wait(); + + // Press Enter + stdin.write('\r'); + await wait(); + + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { 0: 'Blue' } }, + ); + unmount(); + }); + + it('navigates with number keys', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails(); + + const { stdin, unmount } = renderWithProviders( + , + ); + await wait(); + + // Press '2' to select Blue + stdin.write('2'); + await wait(); + + // Press Enter + stdin.write('\r'); + await wait(); + + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { 0: 'Blue' } }, + ); + unmount(); + }); + + it('cancels with Escape', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails(); + + const { stdin, unmount } = renderWithProviders( + , + ); + await wait(); + + stdin.write('\u001B'); // Escape + await wait(); + + expect(onConfirm).toHaveBeenCalledWith(ToolConfirmationOutcome.Cancel); + unmount(); + }); + + it('does not navigate above first option', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails(); + + const { stdin, unmount } = renderWithProviders( + , + ); + await wait(); + + // Try to go up from first option + stdin.write('\u001B[A'); // Up arrow + await wait(); + + // Should still select the first option + stdin.write('\r'); + await wait(); + + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { 0: 'Red' } }, + ); + unmount(); + }); + + it('does not navigate below last option', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails(); + + const { stdin, lastFrame, unmount } = renderWithProviders( + , + ); + await wait(); + + // Navigate way past the last option (3 options + 1 custom input = 4 total) + for (let i = 0; i < 10; i++) { + stdin.write('\u001B[B'); // Down arrow + await wait(); + } + + // Should still render without crashing + expect(lastFrame()).toContain('What is your favorite color?'); + unmount(); + }); + }); + + describe('multi-select interaction', () => { + it('toggles options with Space', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails({ + questions: [createSingleQuestion({ multiSelect: true })], + }); + + const { stdin, lastFrame, unmount } = renderWithProviders( + , + ); + await wait(); + + // Space to toggle first option + stdin.write(' '); + await wait(); + + // Should show checked state + expect(lastFrame()).toContain('[✓]'); + unmount(); + }); + + it('toggles options with Enter', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails({ + questions: [createSingleQuestion({ multiSelect: true })], + }); + + const { stdin, lastFrame, unmount } = renderWithProviders( + , + ); + await wait(); + + // Enter to toggle first option + stdin.write('\r'); + await wait(); + + expect(lastFrame()).toContain('[✓]'); + unmount(); + }); + + it('selects option with Space and submits for multi-select question', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails({ + questions: [createSingleQuestion({ multiSelect: true })], + }); + + const { stdin, unmount } = renderWithProviders( + , + ); + await wait(); + + // Space to toggle first option + stdin.write(' '); + await wait(); + + // Move to "Submit" option (3 options + custom input + submit) + for (let i = 0; i < 4; i++) { + stdin.write('\u001B[B'); + await wait(); + } + + // Space on submit option should submit selected values + stdin.write('\r'); + await wait(); + + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { 0: 'Red' } }, + ); + unmount(); + }); + + it('shows typed custom input text in frame for multi-select question', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails({ + questions: [createSingleQuestion({ multiSelect: true })], + }); + + const { stdin, lastFrame, unmount } = renderWithProviders( + , + ); + await wait(); + + // Move to "Type something..." input + for (let i = 0; i < 3; i++) { + stdin.write('\u001B[B'); + await wait(); + } + + stdin.write('Orange'); + await wait(); + + console.log(lastFrame()); + + expect(lastFrame()).toContain('Orange'); + unmount(); + }); + }); + + describe('multiple questions', () => { + it('auto-advances to next question after selecting an option', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails({ + questions: [ + createSingleQuestion({ header: 'Q1' }), + createSingleQuestion({ + header: 'Q2', + question: 'Second question?', + }), + ], + }); + + const { stdin, lastFrame, unmount } = renderWithProviders( + , + ); + await wait(); + + // Select first option in Q1 + stdin.write('\r'); + await wait(200); // Wait for auto-advance timeout (150ms) + + // Should now show Q2 + expect(lastFrame()).toContain('Second question?'); + unmount(); + }); + + it('navigates between tabs with left/right arrows', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails({ + questions: [ + createSingleQuestion({ header: 'Q1' }), + createSingleQuestion({ + header: 'Q2', + question: 'Second question?', + }), + ], + }); + + const { stdin, lastFrame, unmount } = renderWithProviders( + , + ); + await wait(); + + // Navigate right to Q2 + stdin.write('\u001B[C'); // Right arrow + await wait(); + + expect(lastFrame()).toContain('Second question?'); + + // Navigate left back to Q1 + stdin.write('\u001B[D'); // Left arrow + await wait(); + + expect(lastFrame()).toContain('What is your favorite color?'); + unmount(); + }); + + it('shows Submit tab for multiple questions', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails({ + questions: [ + createSingleQuestion({ header: 'Q1' }), + createSingleQuestion({ header: 'Q2' }), + ], + }); + + const { stdin, lastFrame, unmount } = renderWithProviders( + , + ); + await wait(); + + // Navigate to submit tab (right arrow twice: Q1 -> Q2 -> Submit) + stdin.write('\u001B[C'); // Right + await wait(); + stdin.write('\u001B[C'); // Right + await wait(); + + const output = lastFrame(); + expect(output).toContain('Submit answers'); + expect(output).toContain('Cancel'); + expect(output).toContain('Your answers'); + unmount(); + }); + + it('submits all answers from Submit tab', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails({ + questions: [ + createSingleQuestion({ header: 'Q1' }), + createSingleQuestion({ + header: 'Q2', + question: 'Second question?', + }), + ], + }); + + const { stdin, unmount } = renderWithProviders( + , + ); + await wait(); + + // Answer Q1 + stdin.write('\r'); // Select Red + await wait(200); + + // Answer Q2 + stdin.write('\r'); // Select Red + await wait(200); + + // Now on Submit tab, press Enter to submit + stdin.write('\r'); + await wait(); + + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { 0: 'Red', 1: 'Red' } }, + ); + unmount(); + }); + + it('cancels from Submit tab', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails({ + questions: [ + createSingleQuestion({ header: 'Q1' }), + createSingleQuestion({ header: 'Q2' }), + ], + }); + + const { stdin, unmount } = renderWithProviders( + , + ); + await wait(); + + // Navigate to submit tab + stdin.write('\u001B[C'); // Right + await wait(); + stdin.write('\u001B[C'); // Right + await wait(); + + // Navigate down to Cancel option + stdin.write('\u001B[B'); // Down + await wait(); + + // Press Enter + stdin.write('\r'); + await wait(); + + expect(onConfirm).toHaveBeenCalledWith(ToolConfirmationOutcome.Cancel); + unmount(); + }); + + it('shows unanswered questions as (not answered) in Submit tab', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails({ + questions: [ + createSingleQuestion({ header: 'Q1' }), + createSingleQuestion({ header: 'Q2' }), + ], + }); + + const { stdin, lastFrame, unmount } = renderWithProviders( + , + ); + await wait(); + + // Navigate directly to submit tab without answering anything + stdin.write('\u001B[C'); // Right + await wait(); + stdin.write('\u001B[C'); // Right + await wait(); + + expect(lastFrame()).toContain('(not answered)'); + unmount(); + }); + }); + + describe('focus behavior', () => { + it('does not respond to keys when isFocused is false', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails(); + + const { stdin, unmount } = renderWithProviders( + , + ); + await wait(); + + stdin.write('\r'); // Enter + await wait(); + stdin.write('\u001B'); // Escape + await wait(); + + expect(onConfirm).not.toHaveBeenCalled(); + unmount(); + }); + }); + + describe('escape from custom input', () => { + it('cancels from custom input with Escape', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails(); + + const { stdin, unmount } = renderWithProviders( + , + ); + await wait(); + + // Navigate to custom input (3 options, so index 3 is custom input) + stdin.write('\u001B[B'); // Down + await wait(); + stdin.write('\u001B[B'); // Down + await wait(); + stdin.write('\u001B[B'); // Down - now at custom input + await wait(); + + // Press Escape + stdin.write('\u001B'); + await wait(); + + expect(onConfirm).toHaveBeenCalledWith(ToolConfirmationOutcome.Cancel); + unmount(); + }); + }); + + describe('answered question marker', () => { + it('shows check mark on answered question tab', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails({ + questions: [ + createSingleQuestion({ header: 'Q1' }), + createSingleQuestion({ header: 'Q2' }), + ], + }); + + const { stdin, lastFrame, unmount } = renderWithProviders( + , + ); + await wait(); + + // Answer Q1 + stdin.write('\r'); // Select Red + await wait(200); + + // Q2 is now active; check that Q1 shows ✓ + expect(lastFrame()).toContain('Q1'); + expect(lastFrame()).toContain('✓'); + unmount(); + }); + }); + + describe('custom input preserves state', () => { + it('preserves typed text when navigating away and back', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails(); + + const { stdin, lastFrame, unmount } = renderWithProviders( + , + ); + await wait(); + + // Navigate to custom input (3 options, index 3) + for (let i = 0; i < 3; i++) { + stdin.write('\u001B[B'); // Down + await wait(); + } + + // Type something + stdin.write('Purple'); + await wait(); + + expect(lastFrame()).toContain('Purple'); + + // Navigate away (up to first option) + stdin.write('\u001B[A'); // Up + await wait(); + stdin.write('\u001B[A'); // Up + await wait(); + stdin.write('\u001B[A'); // Up + await wait(); + + // Navigate back to custom input + for (let i = 0; i < 3; i++) { + stdin.write('\u001B[B'); // Down + await wait(); + } + + // Text should still be there + expect(lastFrame()).toContain('Purple'); + unmount(); + }); + + it('does not auto-check custom input in multi-select when navigating back', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails({ + questions: [createSingleQuestion({ multiSelect: true })], + }); + + const { stdin, lastFrame, unmount } = renderWithProviders( + , + ); + await wait(); + + // Navigate to custom input (index 3) + for (let i = 0; i < 3; i++) { + stdin.write('\u001B[B'); + await wait(); + } + + // Type something - auto-checks + stdin.write('Custom'); + await wait(); + + expect(lastFrame()).toContain('[✓]'); + + // Enter to toggle it off (since auto-check already checked it) + stdin.write('\r'); + await wait(); + + // Should be unchecked now - verify on the custom input line specifically + const afterToggle = lastFrame()!; + const toggledLine = afterToggle + .split('\n') + .find((l) => l.includes('Custom')); + expect(toggledLine).toBeDefined(); + expect(toggledLine).toContain('[ ]'); + expect(toggledLine).not.toContain('[✓]'); + + // Navigate away + stdin.write('\u001B[A'); // Up + await wait(); + + // Navigate back to custom input + stdin.write('\u001B[B'); // Down + await wait(); + + // Should still be unchecked (not auto-checked on remount) + const output = lastFrame()!; + const lines = output.split('\n'); + const customLine = lines.find((l) => l.includes('Custom')); + expect(customLine).toBeDefined(); + expect(customLine).toContain('[ ]'); + expect(customLine).not.toContain('[✓]'); + unmount(); + }); + + it('keeps custom input checked when navigating back if user checked it', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails({ + questions: [createSingleQuestion({ multiSelect: true })], + }); + + const { stdin, lastFrame, unmount } = renderWithProviders( + , + ); + await wait(); + + // Navigate to custom input (index 3) + for (let i = 0; i < 3; i++) { + stdin.write('\u001B[B'); + await wait(); + } + + // Type something - should auto-check + stdin.write('Custom'); + await wait(); + + // Should already be checked (auto-checked on type) + expect(lastFrame()).toContain('[✓]'); + + // Navigate away + stdin.write('\u001B[A'); // Up + await wait(); + + // Navigate back to custom input + stdin.write('\u001B[B'); // Down + await wait(); + + // Should still be checked + const output = lastFrame()!; + const lines = output.split('\n'); + const customLine = lines.find((l) => l.includes('Custom')); + expect(customLine).toBeDefined(); + expect(customLine).toContain('[✓]'); + unmount(); + }); + + it('auto-checks custom input in multi-select when user types text', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails({ + questions: [createSingleQuestion({ multiSelect: true })], + }); + + const { stdin, lastFrame, unmount } = renderWithProviders( + , + ); + await wait(); + + // Navigate to custom input (index 3) + for (let i = 0; i < 3; i++) { + stdin.write('\u001B[B'); + await wait(); + } + + // Type something - should auto-check + stdin.write('Hello'); + await wait(); + + const output = lastFrame()!; + const lines = output.split('\n'); + const customLine = lines.find((l) => l.includes('Hello')); + expect(customLine).toBeDefined(); + expect(customLine).toContain('[✓]'); + unmount(); + }); + + it('auto-unchecks custom input in multi-select when text is cleared', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails({ + questions: [createSingleQuestion({ multiSelect: true })], + }); + + const { stdin, lastFrame, unmount } = renderWithProviders( + , + ); + await wait(); + + // Navigate to custom input (index 3) + for (let i = 0; i < 3; i++) { + stdin.write('\u001B[B'); + await wait(); + } + + // Type something - should auto-check + stdin.write('Hi'); + await wait(); + + // Verify auto-check on the custom input line + const afterType = lastFrame()!; + const typedLine = afterType.split('\n').find((l) => l.includes('Hi')); + expect(typedLine).toBeDefined(); + expect(typedLine).toContain('[✓]'); + + // Delete all text (backspace twice) + stdin.write('\x7f'); // backspace + await wait(); + stdin.write('\x7f'); // backspace + await wait(); + + // Should be unchecked now - check the custom input line (option 4) + const afterClear = lastFrame()!; + const clearedLine = afterClear.split('\n').find((l) => l.includes('4.')); + expect(clearedLine).toBeDefined(); + expect(clearedLine).toContain('[ ]'); + expect(clearedLine).not.toContain('[✓]'); + unmount(); + }); + }); +}); diff --git a/packages/cli/src/ui/components/messages/AskUserQuestionDialog.tsx b/packages/cli/src/ui/components/messages/AskUserQuestionDialog.tsx new file mode 100644 index 000000000..c50eaa917 --- /dev/null +++ b/packages/cli/src/ui/components/messages/AskUserQuestionDialog.tsx @@ -0,0 +1,593 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useState } from 'react'; +import { Box, Text } from 'ink'; +import { + type ToolAskUserQuestionConfirmationDetails, + ToolConfirmationOutcome, + type ToolConfirmationPayload, +} from '@qwen-code/qwen-code-core'; +import { theme } from '../../semantic-colors.js'; +import { useKeypress } from '../../hooks/useKeypress.js'; +import { TextInput } from '../shared/TextInput.js'; + +interface AskUserQuestionDialogProps { + confirmationDetails: ToolAskUserQuestionConfirmationDetails; + isFocused?: boolean; + onConfirm: ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => Promise; +} + +export const AskUserQuestionDialog: React.FC = ({ + confirmationDetails, + isFocused = true, + onConfirm, +}) => { + const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); + const [selectedOptions, setSelectedOptions] = useState< + Record + >({}); + const [customInputValues, setCustomInputValues] = useState< + Record + >({}); + const [selectedIndex, setSelectedIndex] = useState(0); + const [multiSelectedOptions, setMultiSelectedOptions] = useState< + Record + >({}); + const [customInputChecked, setCustomInputChecked] = useState< + Record + >({}); + + const hasMultipleQuestions = confirmationDetails.questions.length > 1; + const totalTabs = hasMultipleQuestions + ? confirmationDetails.questions.length + 1 + : confirmationDetails.questions.length; // +1 for Submit tab + const isSubmitTab = + hasMultipleQuestions && currentQuestionIndex === totalTabs - 1; + + const currentQuestion = isSubmitTab + ? null + : confirmationDetails.questions[currentQuestionIndex]; + const isMultiSelect = currentQuestion?.multiSelect ?? false; + // Multi-select: options + custom input + submit; Single-select: options + custom input + const totalOptions = currentQuestion + ? currentQuestion.options.length + (isMultiSelect ? 2 : 1) + : 2; + const submitOptionIndex = currentQuestion + ? currentQuestion.options.length + 1 + : -1; + + // Check if the custom input option is selected + const isCustomInputSelected = + !isSubmitTab && + currentQuestion && + selectedIndex === currentQuestion.options.length; + + const currentCustomInputValue = customInputValues[currentQuestionIndex] ?? ''; + const isCustomInputAnswer = + !isSubmitTab && + currentQuestion && + !isMultiSelect && + selectedOptions[currentQuestionIndex] !== undefined && + !currentQuestion.options.some( + (opt) => opt.label === selectedOptions[currentQuestionIndex], + ); + + // Compute the current answer for a question, considering multi-select state + const getAnswerForQuestion = (idx: number): string | undefined => { + const q = confirmationDetails.questions[idx]; + if (q?.multiSelect) { + const selections = [...(multiSelectedOptions[idx] ?? [])]; + const customValue = (customInputValues[idx] ?? '').trim(); + if (customInputChecked[idx] && customValue) { + selections.push(customValue); + } + return selections.length > 0 ? selections.join(', ') : undefined; + } + return selectedOptions[idx]; + }; + + const handleSubmit = async () => { + const answers: Record = {}; + confirmationDetails.questions.forEach((_, idx) => { + const answer = getAnswerForQuestion(idx); + if (answer !== undefined) { + answers[idx] = answer; + } + }); + + await onConfirm(ToolConfirmationOutcome.ProceedOnce, { answers }); + }; + + const handleMultiSelectSubmit = () => { + if (!currentQuestion) return; + const selections = [...(multiSelectedOptions[currentQuestionIndex] ?? [])]; + const customValue = currentCustomInputValue.trim(); + if (customInputChecked[currentQuestionIndex] && customValue) { + selections.push(customValue); + } + if (selections.length === 0) return; + + const value = selections.join(', '); + const updated = { ...selectedOptions, [currentQuestionIndex]: value }; + setSelectedOptions(updated); + + if (!hasMultipleQuestions) { + void onConfirm(ToolConfirmationOutcome.ProceedOnce, { + answers: { [currentQuestionIndex]: value }, + }); + } else { + if (currentQuestionIndex < totalTabs - 1) { + setTimeout(() => { + setCurrentQuestionIndex(currentQuestionIndex + 1); + setSelectedIndex(0); + }, 150); + } + } + }; + + const handleCustomInputSubmit = () => { + const trimmedValue = currentCustomInputValue.trim(); + + if (isMultiSelect) { + // Toggle custom input checked state + if (!trimmedValue) return; + setCustomInputChecked((prev) => ({ + ...prev, + [currentQuestionIndex]: !prev[currentQuestionIndex], + })); + return; + } + + if (!trimmedValue) return; + + const updated = { + ...selectedOptions, + [currentQuestionIndex]: trimmedValue, + }; + setSelectedOptions(updated); + + // If single question, submit immediately + if (!hasMultipleQuestions) { + void onConfirm(ToolConfirmationOutcome.ProceedOnce, { + answers: { + [currentQuestionIndex]: trimmedValue, + }, + }); + } else { + // Auto-advance to next tab + if (currentQuestionIndex < totalTabs - 1) { + setTimeout(() => { + setCurrentQuestionIndex(currentQuestionIndex + 1); + setSelectedIndex(0); + }, 150); + } + } + }; + + // Handle navigation and selection + useKeypress( + (key) => { + if (!isFocused) return; + + // When custom input is focused, still allow up/down navigation, tab switch and escape + if (isCustomInputSelected) { + if (key.name === 'up') { + setSelectedIndex(Math.max(0, selectedIndex - 1)); + return; + } + if (key.name === 'down') { + setSelectedIndex(Math.min(totalOptions - 1, selectedIndex + 1)); + return; + } + if (key.name === 'escape' || (key.ctrl && key.name === 'c')) { + void onConfirm(ToolConfirmationOutcome.Cancel); + return; + } + return; + } + + const input = key.sequence; + + // Tab navigation (left/right arrows) + if (key.name === 'left' && hasMultipleQuestions) { + if (currentQuestionIndex > 0) { + setCurrentQuestionIndex(currentQuestionIndex - 1); + setSelectedIndex(0); + } + return; + } + if (key.name === 'right' && hasMultipleQuestions) { + if (currentQuestionIndex < totalTabs - 1) { + setCurrentQuestionIndex(currentQuestionIndex + 1); + setSelectedIndex(0); + } + return; + } + + // Option navigation (up/down arrows) + if (key.name === 'up') { + setSelectedIndex(Math.max(0, selectedIndex - 1)); + return; + } + if (key.name === 'down') { + setSelectedIndex(Math.min(totalOptions - 1, selectedIndex + 1)); + return; + } + + // Number key selection + const numKey = parseInt(input || '', 10); + if (!isNaN(numKey) && numKey >= 1 && numKey <= totalOptions) { + setSelectedIndex(numKey - 1); + return; + } + + // Space to toggle multi-select + if (key.name === 'space' && isMultiSelect && currentQuestion) { + if (selectedIndex < currentQuestion.options.length) { + const option = currentQuestion.options[selectedIndex]; + if (option) { + const current = multiSelectedOptions[currentQuestionIndex] ?? []; + const isChecked = current.includes(option.label); + const updated = isChecked + ? current.filter((l) => l !== option.label) + : [...current, option.label]; + setMultiSelectedOptions((prev) => ({ + ...prev, + [currentQuestionIndex]: updated, + })); + } + } + if (selectedIndex === submitOptionIndex) { + handleMultiSelectSubmit(); + } + return; + } + + // Enter to select + if (key.name === 'return') { + // Handle Submit tab + if (isSubmitTab) { + if (selectedIndex === 0) { + // Submit + void handleSubmit(); + } else { + // Cancel + void onConfirm(ToolConfirmationOutcome.Cancel); + } + return; + } + + // Handle multi-select + if (isMultiSelect && currentQuestion) { + // Custom input is handled by TextInput's onSubmit + if (selectedIndex === currentQuestion.options.length) { + return; + } + // Submit option + if (selectedIndex === submitOptionIndex) { + handleMultiSelectSubmit(); + return; + } + // Toggle predefined option (same as Space) + if (selectedIndex < currentQuestion.options.length) { + const option = currentQuestion.options[selectedIndex]; + if (option) { + const current = multiSelectedOptions[currentQuestionIndex] ?? []; + const isChecked = current.includes(option.label); + const updated = isChecked + ? current.filter((l) => l !== option.label) + : [...current, option.label]; + setMultiSelectedOptions((prev) => ({ + ...prev, + [currentQuestionIndex]: updated, + })); + } + } + return; + } + + // Handle question options (not custom input - that's handled by TextInput) + if (currentQuestion && selectedIndex < currentQuestion.options.length) { + const option = currentQuestion.options[selectedIndex]; + if (option) { + const updated = { + ...selectedOptions, + [currentQuestionIndex]: option.label, + }; + setSelectedOptions(updated); + + // If single question, submit immediately + if (!hasMultipleQuestions) { + void onConfirm(ToolConfirmationOutcome.ProceedOnce, { + answers: { [currentQuestionIndex]: option.label }, + }); + } else { + // Auto-advance to next tab after selection + if (currentQuestionIndex < totalTabs - 1) { + setTimeout(() => { + setCurrentQuestionIndex(currentQuestionIndex + 1); + setSelectedIndex(0); + }, 150); + } + } + } + } + return; + } + + // Cancel + if (key.name === 'escape' || (key.ctrl && key.name === 'c')) { + void onConfirm(ToolConfirmationOutcome.Cancel); + return; + } + }, + { isActive: isFocused }, + ); + + // Submit tab (for multiple questions) + if (isSubmitTab) { + return ( + + {/* Tabs */} + + {confirmationDetails.questions.map((q, idx) => { + const isAnswered = getAnswerForQuestion(idx) !== undefined; + return ( + + + {isAnswered ? ' ' : ' '} + {q.header} + {isAnswered ? ' ✓' : ''} + + + ); + })} + + + ▸ Submit + + + + + {/* Show selected answers */} + + Your answers: + {confirmationDetails.questions.map((q, idx) => { + const answer = getAnswerForQuestion(idx); + return ( + + + {q.header}:{' '} + {answer ? ( + {answer} + ) : ( + (not answered) + )} + + + ); + })} + + + + Ready to submit your answers? + + + {/* Submit/Cancel options */} + + + + {selectedIndex === 0 ? '❯ ' : ' '}1. Submit answers + + + + + {selectedIndex === 1 ? '❯ ' : ' '}2. Cancel + + + + + + ↑/↓: Navigate | ←/→: Switch tabs | Enter: Select + + + ); + } + + // Question tab + return ( + + {/* Tabs for multiple questions */} + {hasMultipleQuestions && ( + + {confirmationDetails.questions.map((q, idx) => { + const isAnswered = getAnswerForQuestion(idx) !== undefined; + return ( + + + {idx === currentQuestionIndex ? '▸ ' : ' '} + {q.header} + {isAnswered ? ' ✓' : ''} + + + ); + })} + + Submit + + + )} + + {/* Question */} + + {!hasMultipleQuestions && ( + + + {currentQuestion!.header} + + + )} + {currentQuestion!.question} + + + {/* Options */} + + {currentQuestion!.options.map((opt, index) => { + const isSelected = selectedIndex === index; + const isMultiChecked = + isMultiSelect && + (multiSelectedOptions[currentQuestionIndex] ?? []).includes( + opt.label, + ); + const isAnswered = + !isMultiSelect && + selectedOptions[currentQuestionIndex] === opt.label; + const isHighlighted = isSelected || isAnswered || isMultiChecked; + return ( + + + + {isSelected ? '❯ ' : ' '} + {isMultiSelect ? (isMultiChecked ? '[✓] ' : '[ ] ') : ''} + {index + 1}. {opt.label} + {isAnswered ? ' ✓' : ''} + + + {opt.description && ( + + {opt.description} + + )} + + ); + })} + + {/* Type something option/input */} + + {isCustomInputSelected ? ( + // Inline TextInput replaces the option text + + + ❯{' '} + {isMultiSelect + ? customInputChecked[currentQuestionIndex] + ? '[✓] ' + : '[ ] ' + : ''} + {currentQuestion!.options.length + 1}.{' '} + + { + const oldValue = + customInputValues[currentQuestionIndex] ?? ''; + if (isMultiSelect && value !== oldValue) { + setCustomInputChecked((prevChecked) => ({ + ...prevChecked, + [currentQuestionIndex]: value.trim().length > 0, + })); + } + setCustomInputValues((prev) => ({ + ...prev, + [currentQuestionIndex]: value, + })); + }} + onSubmit={handleCustomInputSubmit} + placeholder="Type something..." + isActive={true} + inputWidth={50} + /> + + ) : ( + // Show typed value or placeholder when not selected + + + {' '} + {isMultiSelect + ? customInputChecked[currentQuestionIndex] + ? '[✓] ' + : '[ ] ' + : ''} + {currentQuestion!.options.length + 1}.{' '} + {currentCustomInputValue || 'Type something...'} + {isCustomInputAnswer ? ' ✓' : ''} + + + )} + + + {/* Submit option for multi-select */} + {isMultiSelect && ( + + + {selectedIndex === submitOptionIndex ? '❯ ' : ' '} + {submitOptionIndex + 1}. Submit + + + )} + + + {/* Help text */} + + + + ↑/↓: Navigate {hasMultipleQuestions ? '| ←/→: Switch tabs ' : ''}| + {isMultiSelect ? ' Space/Enter: Toggle | ' : ' Enter: Select | '} + Esc: Cancel + + + + + ); +}; diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index 7bfe9a962..3def94d41 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -25,6 +25,7 @@ import { useKeypress } from '../../hooks/useKeypress.js'; import { useSettings } from '../../contexts/SettingsContext.js'; import { theme } from '../../semantic-colors.js'; import { t } from '../../../i18n/index.js'; +import { AskUserQuestionDialog } from './AskUserQuestionDialog.js'; export interface ToolConfirmationMessageProps { confirmationDetails: ToolCallConfirmationDetails; @@ -345,6 +346,15 @@ export const ToolConfirmationMessage: React.FC< )} ); + } else if (confirmationDetails.type === 'ask_user_question') { + // Use dedicated dialog for ask_user_question type + return ( + + ); } else { // mcp tool confirmation const mcpProps = confirmationDetails as ToolMcpConfirmationDetails; diff --git a/packages/cli/src/ui/components/shared/TextInput.tsx b/packages/cli/src/ui/components/shared/TextInput.tsx index 40d471296..01ebc2fa0 100644 --- a/packages/cli/src/ui/components/shared/TextInput.tsx +++ b/packages/cli/src/ui/components/shared/TextInput.tsx @@ -26,6 +26,7 @@ export interface TextInputProps { isActive?: boolean; // when false, ignore keypresses validationErrors?: string[]; inputWidth?: number; + initialCursorOffset?: number; } export function TextInput({ @@ -37,6 +38,7 @@ export function TextInput({ isActive = true, validationErrors = [], inputWidth = 80, + initialCursorOffset, }: TextInputProps) { const allowMultiline = height > 1; @@ -51,6 +53,7 @@ export function TextInput({ const buffer = useTextBuffer({ initialText: value || '', + initialCursorOffset, viewport: { height, width: inputWidth }, isValidPath: () => false, onChange: stableOnChange, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index e1598a641..6dcd00d3d 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -42,6 +42,7 @@ import { import { GitService } from '../services/gitService.js'; // Tools +import { AskUserQuestionTool } from '../tools/askUserQuestion.js'; import { EditTool } from '../tools/edit.js'; import { ExitPlanModeTool } from '../tools/exitPlanMode.js'; import { GlobTool } from '../tools/glob.js'; @@ -1713,6 +1714,7 @@ export class Config { registerCoreTool(ShellTool, this); registerCoreTool(MemoryTool); registerCoreTool(TodoWriteTool, this); + registerCoreTool(AskUserQuestionTool, this); !this.sdkMode && registerCoreTool(ExitPlanModeTool, this); registerCoreTool(WebFetchTool, this); // Conditionally register web search tool if web search provider is configured diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index fc0455a8a..5a94374d6 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -850,8 +850,14 @@ export class CoreToolScheduler { this.setStatusInternal(reqInfo.callId, 'scheduled'); } } else if ( - this.config.getApprovalMode() === ApprovalMode.YOLO || - doesToolInvocationMatch(toolCall.tool, invocation, allowedTools) + (this.config.getApprovalMode() === ApprovalMode.YOLO || + doesToolInvocationMatch( + toolCall.tool, + invocation, + allowedTools, + )) && + // Even in YOLO mode, ask_user_question tool requires user confirmation to ensure the user always has a chance to respond to questions + confirmationDetails.type !== 'ask_user_question' ) { this.setToolCallOutcome( reqInfo.callId, diff --git a/packages/core/src/extension/claude-converter.ts b/packages/core/src/extension/claude-converter.ts index 28835bc87..93a00bb6c 100644 --- a/packages/core/src/extension/claude-converter.ts +++ b/packages/core/src/extension/claude-converter.ts @@ -91,7 +91,7 @@ export interface ClaudeMarketplaceConfig { } const CLAUDE_TOOLS_MAPPING: Record = { - AskUserQuestion: 'None', + AskUserQuestion: 'AskUserQuestion', Bash: 'Shell', BashOutput: 'None', Edit: 'Edit', diff --git a/packages/core/src/tools/askUserQuestion.test.ts b/packages/core/src/tools/askUserQuestion.test.ts new file mode 100644 index 000000000..865150864 --- /dev/null +++ b/packages/core/src/tools/askUserQuestion.test.ts @@ -0,0 +1,258 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { AskUserQuestionTool } from './askUserQuestion.js'; +import type { Config } from '../config/config.js'; +import { ApprovalMode } from '../config/config.js'; +import { ToolConfirmationOutcome } from './tools.js'; + +describe('AskUserQuestionTool', () => { + let mockConfig: Config; + let tool: AskUserQuestionTool; + + beforeEach(() => { + mockConfig = { + isInteractive: vi.fn().mockReturnValue(true), + getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), + getTargetDir: vi.fn().mockReturnValue('/mock/dir'), + getChatRecordingService: vi.fn(), + } as unknown as Config; + + tool = new AskUserQuestionTool(mockConfig); + }); + + describe('validateToolParams', () => { + it('should accept valid params with single question', () => { + const params = { + questions: [ + { + question: 'What is your favorite color?', + header: 'Color', + options: [ + { label: 'Red', description: 'The color red' }, + { label: 'Blue', description: 'The color blue' }, + ], + multiSelect: false, + }, + ], + }; + + const result = tool.validateToolParams(params); + expect(result).toBeNull(); + }); + + it('should reject params with too many questions', () => { + const params = { + questions: Array(5).fill({ + question: 'Test?', + header: 'Test', + options: [ + { label: 'A', description: 'Option A' }, + { label: 'B', description: 'Option B' }, + ], + multiSelect: false, + }), + }; + + const result = tool.validateToolParams(params); + expect(result).toContain('between 1 and 4 questions'); + }); + + it('should reject question with header too long', () => { + const params = { + questions: [ + { + question: 'Test question?', + header: 'ThisHeaderIsTooLong', + options: [ + { label: 'A', description: 'Option A' }, + { label: 'B', description: 'Option B' }, + ], + multiSelect: false, + }, + ], + }; + + const result = tool.validateToolParams(params); + expect(result).toContain('12 characters or less'); + }); + + it('should reject question with too few options', () => { + const params = { + questions: [ + { + question: 'Test question?', + header: 'Test', + options: [{ label: 'A', description: 'Only one option' }], + multiSelect: false, + }, + ], + }; + + const result = tool.validateToolParams(params); + expect(result).toContain('between 2 and 4 options'); + }); + }); + + describe('shouldConfirmExecute', () => { + it('should return confirmation details in interactive mode', async () => { + const params = { + questions: [ + { + question: 'Pick a framework?', + header: 'Framework', + options: [ + { label: 'React', description: 'A JavaScript library' }, + { label: 'Vue', description: 'Progressive framework' }, + ], + multiSelect: false, + }, + ], + }; + + const invocation = tool.build(params); + const confirmation = await invocation.shouldConfirmExecute( + new AbortController().signal, + ); + + expect(confirmation).not.toBe(false); + if (confirmation && confirmation.type === 'ask_user_question') { + expect(confirmation.type).toBe('ask_user_question'); + expect(confirmation.questions).toEqual(params.questions); + expect(confirmation.onConfirm).toBeDefined(); + } + }); + + it('should return false in non-interactive mode', async () => { + (mockConfig.isInteractive as Mock).mockReturnValue(false); + + const params = { + questions: [ + { + question: 'Test?', + header: 'Test', + options: [ + { label: 'A', description: 'Option A' }, + { label: 'B', description: 'Option B' }, + ], + multiSelect: false, + }, + ], + }; + + const invocation = tool.build(params); + const confirmation = await invocation.shouldConfirmExecute( + new AbortController().signal, + ); + + expect(confirmation).toBe(false); + }); + }); + + describe('execute', () => { + it('should return error in non-interactive mode', async () => { + (mockConfig.isInteractive as Mock).mockReturnValue(false); + + const params = { + questions: [ + { + question: 'Test?', + header: 'Test', + options: [ + { label: 'A', description: 'Option A' }, + { label: 'B', description: 'Option B' }, + ], + multiSelect: false, + }, + ], + }; + + const invocation = tool.build(params); + const result = await invocation.execute(new AbortController().signal); + + expect(result.llmContent).toContain('non-interactive mode'); + expect(result.returnDisplay).toContain('non-interactive mode'); + }); + + it('should return cancellation message when user declines', async () => { + const params = { + questions: [ + { + question: 'Test?', + header: 'Test', + options: [ + { label: 'A', description: 'Option A' }, + { label: 'B', description: 'Option B' }, + ], + multiSelect: false, + }, + ], + }; + + const invocation = tool.build(params); + const confirmation = await invocation.shouldConfirmExecute( + new AbortController().signal, + ); + + if (confirmation !== false) { + // Simulate user cancellation + await confirmation.onConfirm(ToolConfirmationOutcome.Cancel); + } + + const result = await invocation.execute(new AbortController().signal); + expect(result.llmContent).toContain('declined to answer'); + }); + + it('should return formatted answers when user provides them', async () => { + const params = { + questions: [ + { + question: 'Pick a framework?', + header: 'Framework', + options: [ + { label: 'React', description: 'A JavaScript library' }, + { label: 'Vue', description: 'Progressive framework' }, + ], + multiSelect: false, + }, + { + question: 'Pick a language?', + header: 'Language', + options: [ + { label: 'TypeScript', description: 'Typed JavaScript' }, + { label: 'JavaScript', description: 'Plain JS' }, + ], + multiSelect: false, + }, + ], + }; + + const invocation = tool.build(params); + const confirmation = await invocation.shouldConfirmExecute( + new AbortController().signal, + ); + + if (confirmation !== false) { + // Simulate user providing answers + await confirmation.onConfirm(ToolConfirmationOutcome.ProceedOnce, { + answers: { + '0': 'React', + '1': 'TypeScript', + }, + }); + } + + const result = await invocation.execute(new AbortController().signal); + + expect(result.llmContent).toContain('Framework**: React'); + expect(result.llmContent).toContain('Language**: TypeScript'); + expect(result.returnDisplay).toContain( + 'has provided the following answers:', + ); + }); + }); +}); diff --git a/packages/core/src/tools/askUserQuestion.ts b/packages/core/src/tools/askUserQuestion.ts new file mode 100644 index 000000000..be908881d --- /dev/null +++ b/packages/core/src/tools/askUserQuestion.ts @@ -0,0 +1,350 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + ToolAskUserQuestionConfirmationDetails, + ToolConfirmationPayload, + ToolResult, +} from './tools.js'; +import { + BaseDeclarativeTool, + BaseToolInvocation, + Kind, + ToolConfirmationOutcome, +} from './tools.js'; +import type { FunctionDeclaration } from '@google/genai'; +import type { Config } from '../config/config.js'; +import { ToolDisplayNames, ToolNames } from './tool-names.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('ASK_USER_QUESTION'); + +export interface QuestionOption { + label: string; + description: string; +} + +export interface Question { + question: string; + header: string; + options: QuestionOption[]; + multiSelect: boolean; +} + +export interface AskUserQuestionParams { + questions: Question[]; + answers?: Record; + metadata?: { + source?: string; + }; +} + +const askUserQuestionToolDescription = `Use this tool when you need to ask the user questions during execution. This allows you to: +1. Gather user preferences or requirements +2. Clarify ambiguous instructions +3. Get decisions on implementation choices as you work +4. Offer choices to the user about what direction to take. + +Usage notes: +- Users will always be able to select "Other" to provide custom text input +- Use multiSelect: true to allow multiple answers to be selected for a question +- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label + +Plan mode note: In plan mode, use this tool to clarify requirements or choose between approaches BEFORE finalizing your plan. Do NOT use this tool to ask "Is this plan ready?" or "Should I proceed?" - use ExitPlanMode for plan approval. +`; + +const askUserQuestionToolSchemaData: FunctionDeclaration = { + name: 'ask_user_question', + description: askUserQuestionToolDescription, + parametersJsonSchema: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + questions: { + description: 'Questions to ask the user (1-4 questions)', + minItems: 1, + maxItems: 4, + type: 'array', + items: { + type: 'object', + properties: { + question: { + description: + 'The complete question to ask the user. Should be clear, specific, and end with a question mark. Example: "Which library should we use for date formatting?" If multiSelect is true, phrase it accordingly, e.g. "Which features do you want to enable?"', + type: 'string', + }, + header: { + description: + 'Very short label displayed as a chip/tag (max 12 chars). Examples: "Auth method", "Library", "Approach".', + type: 'string', + }, + options: { + description: + "The available choices for this question. Must have 2-4 options. Each option should be a distinct, mutually exclusive choice (unless multiSelect is enabled). There should be no 'Other' option, that will be provided automatically.", + minItems: 2, + maxItems: 4, + type: 'array', + items: { + type: 'object', + properties: { + label: { + description: + 'The display text for this option that the user will see and select. Should be concise (1-5 words) and clearly describe the choice.', + type: 'string', + }, + description: { + description: + 'Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications.', + type: 'string', + }, + }, + required: ['label', 'description'], + additionalProperties: false, + }, + }, + multiSelect: { + description: + 'Set to true to allow the user to select multiple options instead of just one. Use when choices are not mutually exclusive.', + default: false, + type: 'boolean', + }, + }, + required: ['question', 'header', 'options', 'multiSelect'], + additionalProperties: false, + }, + }, + answers: { + description: 'User answers collected by the permission component', + type: 'object', + propertyNames: { + type: 'string', + }, + additionalProperties: { + type: 'string', + }, + }, + metadata: { + description: + 'Optional metadata for tracking and analytics purposes. Not displayed to user.', + type: 'object', + properties: { + source: { + description: + 'Optional identifier for the source of this question (e.g., "remember" for /remember command). Used for analytics tracking.', + type: 'string', + }, + }, + additionalProperties: false, + }, + }, + required: ['questions'], + additionalProperties: false, + }, +}; + +class AskUserQuestionToolInvocation extends BaseToolInvocation< + AskUserQuestionParams, + ToolResult +> { + private userAnswers: Record = {}; + private wasAnswered = false; + + constructor( + private readonly _config: Config, + params: AskUserQuestionParams, + ) { + super(params); + } + + getDescription(): string { + const questionCount = this.params.questions.length; + return `Ask user ${questionCount} question${questionCount > 1 ? 's' : ''}`; + } + + override async shouldConfirmExecute( + _abortSignal: AbortSignal, + ): Promise { + if (!this._config.isInteractive()) { + // In non-interactive mode, we cannot collect user input + return false; + } + + const details: ToolAskUserQuestionConfirmationDetails = { + type: 'ask_user_question', + title: 'Please answer the following question(s):', + questions: this.params.questions, + metadata: this.params.metadata, + onConfirm: async ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => { + switch (outcome) { + case ToolConfirmationOutcome.ProceedOnce: + case ToolConfirmationOutcome.ProceedAlways: + this.wasAnswered = true; + this.userAnswers = payload?.answers ?? {}; + break; + case ToolConfirmationOutcome.Cancel: + this.wasAnswered = false; + break; + default: + this.wasAnswered = true; + this.userAnswers = payload?.answers ?? {}; + break; + } + }, + }; + + return details; + } + + async execute(_signal: AbortSignal): Promise { + try { + // In non-interactive mode, we cannot collect user input + if (!this._config.isInteractive()) { + const errorMessage = + 'Cannot ask user questions in non-interactive mode. Please run in interactive mode to use this tool.'; + return { + llmContent: errorMessage, + returnDisplay: errorMessage, + }; + } + + if (!this.wasAnswered) { + const cancellationMessage = 'User declined to answer the questions.'; + return { + llmContent: cancellationMessage, + returnDisplay: cancellationMessage, + }; + } + + // Format the answers for LLM consumption + const answersContent = Object.entries(this.userAnswers) + .map(([key, value]) => { + const questionIndex = parseInt(key, 10); + const question = this.params.questions[questionIndex]; + return `**${question?.header || `Question ${questionIndex + 1}`}**: ${value}`; + }) + .join('\n'); + + const llmMessage = `User has provided the following answers:\n\n${answersContent}`; + const displayMessage = `User has provided the following answers:\n\n${answersContent}`; + + return { + llmContent: llmMessage, + returnDisplay: displayMessage, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + debugLogger.error( + `[AskUserQuestionTool] Error executing ask_user_question: ${errorMessage}`, + ); + + const errorLlmContent = `Failed to process user answers: ${errorMessage}`; + + return { + llmContent: errorLlmContent, + returnDisplay: `Error processing answers: ${errorMessage}`, + }; + } + } +} + +export class AskUserQuestionTool extends BaseDeclarativeTool< + AskUserQuestionParams, + ToolResult +> { + static readonly Name: string = ToolNames.ASK_USER_QUESTION; + + constructor(private readonly config: Config) { + super( + AskUserQuestionTool.Name, + ToolDisplayNames.ASK_USER_QUESTION, + askUserQuestionToolDescription, + Kind.Think, + askUserQuestionToolSchemaData.parametersJsonSchema as Record< + string, + unknown + >, + ); + } + + override validateToolParams(params: AskUserQuestionParams): string | null { + // Validate questions array + if (!Array.isArray(params.questions)) { + return 'Parameter "questions" must be an array.'; + } + + if (params.questions.length < 1 || params.questions.length > 4) { + return 'Parameter "questions" must contain between 1 and 4 questions.'; + } + + // Validate individual questions + for (let i = 0; i < params.questions.length; i++) { + const question = params.questions[i]; + + if ( + !question.question || + typeof question.question !== 'string' || + question.question.trim() === '' + ) { + return `Question ${i + 1}: "question" must be a non-empty string.`; + } + + if ( + !question.header || + typeof question.header !== 'string' || + question.header.trim() === '' + ) { + return `Question ${i + 1}: "header" must be a non-empty string.`; + } + + if (question.header.length > 12) { + return `Question ${i + 1}: "header" must be 12 characters or less.`; + } + + if (!Array.isArray(question.options)) { + return `Question ${i + 1}: "options" must be an array.`; + } + + if (question.options.length < 2 || question.options.length > 4) { + return `Question ${i + 1}: "options" must contain between 2 and 4 options.`; + } + + // Validate options + for (let j = 0; j < question.options.length; j++) { + const option = question.options[j]; + + if ( + !option.label || + typeof option.label !== 'string' || + option.label.trim() === '' + ) { + return `Question ${i + 1}, Option ${j + 1}: "label" must be a non-empty string.`; + } + + if ( + !option.description || + typeof option.description !== 'string' || + option.description.trim() === '' + ) { + return `Question ${i + 1}, Option ${j + 1}: "description" must be a non-empty string.`; + } + } + + if (typeof question.multiSelect !== 'boolean') { + return `Question ${i + 1}: "multiSelect" must be a boolean.`; + } + } + + return null; + } + + protected createInvocation(params: AskUserQuestionParams) { + return new AskUserQuestionToolInvocation(this.config, params); + } +} diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index 3399f7d41..c118bffbd 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -25,6 +25,7 @@ export const ToolNames = { WEB_SEARCH: 'web_search', LS: 'list_directory', LSP: 'lsp', + ASK_USER_QUESTION: 'ask_user_question', } as const; /** @@ -48,6 +49,7 @@ export const ToolDisplayNames = { WEB_SEARCH: 'WebSearch', LS: 'ListFiles', LSP: 'Lsp', + ASK_USER_QUESTION: 'AskUserQuestion', } as const; // Migration from old tool names to new tool names diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 96ae53402..649b0cb4f 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -549,6 +549,8 @@ export interface ToolConfirmationPayload { newContent?: string; // used to provide custom cancellation message when outcome is Cancel cancelMessage?: string; + // used to pass user answers from ask_user_question tool + answers?: Record; } export interface ToolExecuteConfirmationDetails { @@ -587,7 +589,8 @@ export type ToolCallConfirmationDetails = | ToolExecuteConfirmationDetails | ToolMcpConfirmationDetails | ToolInfoConfirmationDetails - | ToolPlanConfirmationDetails; + | ToolPlanConfirmationDetails + | ToolAskUserQuestionConfirmationDetails; export interface ToolPlanConfirmationDetails { type: 'plan'; @@ -596,6 +599,27 @@ export interface ToolPlanConfirmationDetails { onConfirm: (outcome: ToolConfirmationOutcome) => Promise; } +export interface ToolAskUserQuestionConfirmationDetails { + type: 'ask_user_question'; + title: string; + questions: Array<{ + question: string; + header: string; + options: Array<{ + label: string; + description: string; + }>; + multiSelect: boolean; + }>; + metadata?: { + source?: string; + }; + onConfirm: ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => Promise; +} + /** * TODO: * 1. support explicit denied outcome