mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-29 20:20:57 +00:00
feat: Implement AskUserQuestionTool for interactive user queries
- Added AskUserQuestionDialog component for handling user questions in CLI. - Integrated AskUserQuestionTool into the core toolset, allowing for dynamic user input during execution. - Enhanced ToolConfirmationMessage to utilize the new AskUserQuestionDialog for 'ask_user_question' type confirmations. - Updated core configuration to register the AskUserQuestionTool. - Implemented validation and execution logic for user questions, including multi-select options. - Added comprehensive tests for AskUserQuestionTool to ensure functionality and validation rules. - Updated tool names and display names to include AskUserQuestion.
This commit is contained in:
parent
12b669d7c6
commit
35b5bc8a1e
11 changed files with 2165 additions and 4 deletions
|
|
@ -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> = {},
|
||||
): ToolAskUserQuestionConfirmationDetails => ({
|
||||
type: 'ask_user_question',
|
||||
title: 'Question',
|
||||
questions: [createSingleQuestion()],
|
||||
onConfirm: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('<AskUserQuestionDialog />', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders single question with options', () => {
|
||||
const details = createConfirmationDetails();
|
||||
const onConfirm = vi.fn();
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Color');
|
||||
});
|
||||
|
||||
it('renders "Type something..." custom input option', () => {
|
||||
const details = createConfirmationDetails();
|
||||
const onConfirm = vi.fn();
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Type something...');
|
||||
});
|
||||
|
||||
it('renders help text for single select', () => {
|
||||
const details = createConfirmationDetails();
|
||||
const onConfirm = vi.fn();
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
isFocused={false}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<void>;
|
||||
}
|
||||
|
||||
export const AskUserQuestionDialog: React.FC<AskUserQuestionDialogProps> = ({
|
||||
confirmationDetails,
|
||||
isFocused = true,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||
const [selectedOptions, setSelectedOptions] = useState<
|
||||
Record<number, string>
|
||||
>({});
|
||||
const [customInputValues, setCustomInputValues] = useState<
|
||||
Record<number, string>
|
||||
>({});
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [multiSelectedOptions, setMultiSelectedOptions] = useState<
|
||||
Record<number, string[]>
|
||||
>({});
|
||||
const [customInputChecked, setCustomInputChecked] = useState<
|
||||
Record<number, boolean>
|
||||
>({});
|
||||
|
||||
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<string, string> = {};
|
||||
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 (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
{/* Tabs */}
|
||||
<Box marginBottom={1} flexDirection="row" gap={1}>
|
||||
{confirmationDetails.questions.map((q, idx) => {
|
||||
const isAnswered = getAnswerForQuestion(idx) !== undefined;
|
||||
return (
|
||||
<Box key={idx}>
|
||||
<Text dimColor>
|
||||
{isAnswered ? ' ' : ' '}
|
||||
{q.header}
|
||||
{isAnswered ? ' ✓' : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
<Box>
|
||||
<Text color={theme.text.link} bold>
|
||||
▸ Submit
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Show selected answers */}
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text bold>Your answers:</Text>
|
||||
{confirmationDetails.questions.map((q, idx) => {
|
||||
const answer = getAnswerForQuestion(idx);
|
||||
return (
|
||||
<Box key={idx} marginLeft={2}>
|
||||
<Text>
|
||||
{q.header}:{' '}
|
||||
{answer ? (
|
||||
<Text color={theme.text.link}>{answer}</Text>
|
||||
) : (
|
||||
<Text dimColor>(not answered)</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1} marginBottom={1}>
|
||||
<Text>Ready to submit your answers?</Text>
|
||||
</Box>
|
||||
|
||||
{/* Submit/Cancel options */}
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text
|
||||
color={selectedIndex === 0 ? theme.text.link : theme.text.primary}
|
||||
bold={selectedIndex === 0}
|
||||
>
|
||||
{selectedIndex === 0 ? '❯ ' : ' '}1. Submit answers
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text
|
||||
color={selectedIndex === 1 ? theme.text.link : theme.text.primary}
|
||||
bold={selectedIndex === 1}
|
||||
>
|
||||
{selectedIndex === 1 ? '❯ ' : ' '}2. Cancel
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>↑/↓: Navigate | ←/→: Switch tabs | Enter: Select</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Question tab
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
{/* Tabs for multiple questions */}
|
||||
{hasMultipleQuestions && (
|
||||
<Box marginBottom={1} flexDirection="row" gap={1}>
|
||||
{confirmationDetails.questions.map((q, idx) => {
|
||||
const isAnswered = getAnswerForQuestion(idx) !== undefined;
|
||||
return (
|
||||
<Box key={idx}>
|
||||
<Text
|
||||
color={
|
||||
idx === currentQuestionIndex
|
||||
? theme.text.link
|
||||
: theme.text.primary
|
||||
}
|
||||
bold={idx === currentQuestionIndex}
|
||||
dimColor={idx !== currentQuestionIndex}
|
||||
>
|
||||
{idx === currentQuestionIndex ? '▸ ' : ' '}
|
||||
{q.header}
|
||||
{isAnswered ? ' ✓' : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
<Box>
|
||||
<Text dimColor> Submit</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Question */}
|
||||
<Box marginBottom={1}>
|
||||
{!hasMultipleQuestions && (
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.text.link} bold>
|
||||
{currentQuestion!.header}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Text>{currentQuestion!.question}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Options */}
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{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 (
|
||||
<Box key={index} flexDirection="column">
|
||||
<Box>
|
||||
<Text
|
||||
color={isHighlighted ? theme.text.link : theme.text.primary}
|
||||
bold={isHighlighted}
|
||||
>
|
||||
{isSelected ? '❯ ' : ' '}
|
||||
{isMultiSelect ? (isMultiChecked ? '[✓] ' : '[ ] ') : ''}
|
||||
{index + 1}. {opt.label}
|
||||
{isAnswered ? ' ✓' : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
{opt.description && (
|
||||
<Box marginLeft={4}>
|
||||
<Text dimColor>{opt.description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Type something option/input */}
|
||||
<Box flexDirection="column">
|
||||
{isCustomInputSelected ? (
|
||||
// Inline TextInput replaces the option text
|
||||
<Box>
|
||||
<Text color={theme.text.link} bold>
|
||||
❯{' '}
|
||||
{isMultiSelect
|
||||
? customInputChecked[currentQuestionIndex]
|
||||
? '[✓] '
|
||||
: '[ ] '
|
||||
: ''}
|
||||
{currentQuestion!.options.length + 1}.{' '}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={currentCustomInputValue}
|
||||
initialCursorOffset={currentCustomInputValue.length}
|
||||
onChange={(value: string) => {
|
||||
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}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
// Show typed value or placeholder when not selected
|
||||
<Box>
|
||||
<Text
|
||||
color={
|
||||
isCustomInputAnswer ||
|
||||
customInputChecked[currentQuestionIndex]
|
||||
? theme.text.link
|
||||
: theme.text.primary
|
||||
}
|
||||
bold={
|
||||
!!(
|
||||
isCustomInputAnswer ||
|
||||
customInputChecked[currentQuestionIndex]
|
||||
)
|
||||
}
|
||||
dimColor={
|
||||
!currentCustomInputValue &&
|
||||
!isCustomInputAnswer &&
|
||||
!customInputChecked[currentQuestionIndex]
|
||||
}
|
||||
>
|
||||
{' '}
|
||||
{isMultiSelect
|
||||
? customInputChecked[currentQuestionIndex]
|
||||
? '[✓] '
|
||||
: '[ ] '
|
||||
: ''}
|
||||
{currentQuestion!.options.length + 1}.{' '}
|
||||
{currentCustomInputValue || 'Type something...'}
|
||||
{isCustomInputAnswer ? ' ✓' : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Submit option for multi-select */}
|
||||
{isMultiSelect && (
|
||||
<Box>
|
||||
<Text
|
||||
color={
|
||||
selectedIndex === submitOptionIndex
|
||||
? theme.text.link
|
||||
: theme.text.primary
|
||||
}
|
||||
bold={selectedIndex === submitOptionIndex}
|
||||
>
|
||||
{selectedIndex === submitOptionIndex ? '❯ ' : ' '}
|
||||
{submitOptionIndex + 1}. Submit
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Help text */}
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
↑/↓: Navigate {hasMultipleQuestions ? '| ←/→: Switch tabs ' : ''}|
|
||||
{isMultiSelect ? ' Space/Enter: Toggle | ' : ' Enter: Select | '}
|
||||
Esc: Cancel
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
@ -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<
|
|||
)}
|
||||
</Box>
|
||||
);
|
||||
} else if (confirmationDetails.type === 'ask_user_question') {
|
||||
// Use dedicated dialog for ask_user_question type
|
||||
return (
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={confirmationDetails}
|
||||
isFocused={isFocused}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
// mcp tool confirmation
|
||||
const mcpProps = confirmationDetails as ToolMcpConfirmationDetails;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue