Merge branch 'main' into feat/support-permission

This commit is contained in:
LaZzyMan 2026-03-11 17:11:28 +08:00
commit 7450067e37
337 changed files with 31069 additions and 8843 deletions

View file

@ -0,0 +1,456 @@
/**
* @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', () => {
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('Space: Toggle');
expect(output).toContain('Enter: Confirm');
});
});
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();
});
});
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('submits multi-select with Space to toggle then Enter to confirm', 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();
// Enter to confirm and submit
stdin.write('\r');
await wait();
expect(onConfirm).toHaveBeenCalledWith(
ToolConfirmationOutcome.ProceedOnce,
{ answers: { 0: 'Red' } },
);
unmount();
});
});
describe('multiple questions', () => {
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('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();
});
});
});

View file

@ -0,0 +1,572 @@
/**
* @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';
import { t } from '../../../i18n/index.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;
// Options + custom input ("Other")
const totalOptions = currentQuestion ? currentQuestion.options.length + 1 : 2;
// 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((prev) => Math.min(prev + 1, totalTabs - 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((prev) => Math.min(prev + 1, totalTabs - 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,
}));
}
}
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: Enter advances to next question / submits
if (isMultiSelect && currentQuestion) {
// Custom input is handled by TextInput's onSubmit
if (selectedIndex === currentQuestion.options.length) {
return;
}
handleMultiSelectSubmit();
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((prev) =>
Math.min(prev + 1, totalTabs - 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.accent} bold>
{t('Submit')}
</Text>
</Box>
</Box>
{/* Show selected answers */}
<Box flexDirection="column" marginBottom={1}>
<Text bold>{t('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.accent}>{answer}</Text>
) : (
<Text dimColor>{t('(not answered)')}</Text>
)}
</Text>
</Box>
);
})}
</Box>
<Box marginTop={1} marginBottom={1}>
<Text>{t('Ready to submit your answers?')}</Text>
</Box>
{/* Submit/Cancel options */}
<Box flexDirection="column">
<Box>
<Text
color={
selectedIndex === 0 ? theme.text.accent : theme.text.primary
}
bold={selectedIndex === 0}
>
{selectedIndex === 0 ? ' ' : ' '}1. {t('Submit answers')}
</Text>
</Box>
<Box>
<Text
color={
selectedIndex === 1 ? theme.text.accent : theme.text.primary
}
bold={selectedIndex === 1}
>
{selectedIndex === 1 ? ' ' : ' '}2. {t('Cancel')}
</Text>
</Box>
</Box>
<Box marginTop={1}>
<Text dimColor>
{t('↑/↓: 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.accent
: theme.text.primary
}
bold={idx === currentQuestionIndex}
dimColor={idx !== currentQuestionIndex}
>
{idx === currentQuestionIndex ? '▸ ' : ' '}
{q.header}
{isAnswered ? ' ✓' : ''}
</Text>
</Box>
);
})}
<Box>
<Text dimColor> {t('Submit')}</Text>
</Box>
</Box>
)}
{/* Question */}
<Box flexDirection="column" marginBottom={1}>
{!hasMultipleQuestions && (
<Box marginBottom={1}>
<Text color={theme.text.accent} 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;
// Calculate prefix width for description alignment:
// 2 (cursor) + checkbox (4 if multi) + number + ". " (2)
const prefixWidth =
2 + (isMultiSelect ? 4 : 0) + String(index + 1).length + 2;
return (
<Box key={index} flexDirection="column">
<Box>
<Text
color={isHighlighted ? theme.text.accent : theme.text.primary}
bold={isHighlighted}
>
{isSelected ? ' ' : ' '}
{isMultiSelect ? (isMultiChecked ? '[✓] ' : '[ ] ') : ''}
{index + 1}. {opt.label}
{isAnswered ? ' ✓' : ''}
</Text>
</Box>
{opt.description && (
<Box marginLeft={prefixWidth}>
<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.accent} 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={t('Type something...')}
isActive={true}
inputWidth={50}
/>
</Box>
) : (
// Show typed value or placeholder when not selected
<Box>
<Text
color={
isCustomInputAnswer ||
customInputChecked[currentQuestionIndex]
? theme.text.accent
: theme.text.primary
}
bold={
!!(
isCustomInputAnswer ||
customInputChecked[currentQuestionIndex]
)
}
dimColor={
!currentCustomInputValue &&
!isCustomInputAnswer &&
!customInputChecked[currentQuestionIndex]
}
>
{' '}
{isMultiSelect
? customInputChecked[currentQuestionIndex]
? '[✓] '
: '[ ] '
: ''}
{currentQuestion!.options.length + 1}.{' '}
{currentCustomInputValue || t('Type something...')}
{isCustomInputAnswer ? ' ✓' : ''}
</Text>
</Box>
)}
</Box>
</Box>
{/* Help text */}
<Box flexDirection="column" marginTop={1}>
<Box>
<Text dimColor>
{hasMultipleQuestions
? isMultiSelect
? t(
'↑/↓: Navigate | ←/→: Switch tabs | Space: Toggle | Enter: Confirm | Esc: Cancel',
)
: t(
'↑/↓: Navigate | ←/→: Switch tabs | Enter: Select | Esc: Cancel',
)
: isMultiSelect
? t(
'↑/↓: Navigate | Space: Toggle | Enter: Confirm | Esc: Cancel',
)
: t('↑/↓: Navigate | Enter: Select | Esc: Cancel')}
</Text>
</Box>
</Box>
</Box>
);
};

View file

@ -0,0 +1,261 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import stringWidth from 'string-width';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { theme } from '../../semantic-colors.js';
import {
SCREEN_READER_MODEL_PREFIX,
SCREEN_READER_USER_PREFIX,
} from '../../textConstants.js';
interface UserMessageProps {
text: string;
}
interface UserShellMessageProps {
text: string;
}
interface AssistantMessageProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
contentWidth: number;
}
interface AssistantMessageContentProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
contentWidth: number;
}
interface ThinkMessageProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
contentWidth: number;
}
interface ThinkMessageContentProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
contentWidth: number;
}
interface PrefixedTextMessageProps {
text: string;
prefix: string;
prefixColor: string;
textColor: string;
ariaLabel?: string;
marginTop?: number;
alignSelf?: 'auto' | 'flex-start' | 'center' | 'flex-end';
}
interface PrefixedMarkdownMessageProps {
text: string;
prefix: string;
prefixColor: string;
isPending: boolean;
availableTerminalHeight?: number;
contentWidth: number;
ariaLabel?: string;
textColor?: string;
}
interface ContinuationMarkdownMessageProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
contentWidth: number;
basePrefix: string;
textColor?: string;
}
function getPrefixWidth(prefix: string): number {
// Reserve one extra column so text never touches the prefix glyph.
return stringWidth(prefix) + 1;
}
const PrefixedTextMessage: React.FC<PrefixedTextMessageProps> = ({
text,
prefix,
prefixColor,
textColor,
ariaLabel,
marginTop = 0,
alignSelf,
}) => {
const prefixWidth = getPrefixWidth(prefix);
return (
<Box
flexDirection="row"
paddingY={0}
marginTop={marginTop}
alignSelf={alignSelf}
>
<Box width={prefixWidth}>
<Text color={prefixColor} aria-label={ariaLabel}>
{prefix}
</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap" color={textColor}>
{text}
</Text>
</Box>
</Box>
);
};
const PrefixedMarkdownMessage: React.FC<PrefixedMarkdownMessageProps> = ({
text,
prefix,
prefixColor,
isPending,
availableTerminalHeight,
contentWidth,
ariaLabel,
textColor,
}) => {
const prefixWidth = getPrefixWidth(prefix);
return (
<Box flexDirection="row">
<Box width={prefixWidth}>
<Text color={prefixColor} aria-label={ariaLabel}>
{prefix}
</Text>
</Box>
<Box flexGrow={1} flexDirection="column">
<MarkdownDisplay
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth - prefixWidth}
textColor={textColor}
/>
</Box>
</Box>
);
};
const ContinuationMarkdownMessage: React.FC<
ContinuationMarkdownMessageProps
> = ({
text,
isPending,
availableTerminalHeight,
contentWidth,
basePrefix,
textColor,
}) => {
const prefixWidth = getPrefixWidth(basePrefix);
return (
<Box flexDirection="column" paddingLeft={prefixWidth}>
<MarkdownDisplay
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth - prefixWidth}
textColor={textColor}
/>
</Box>
);
};
export const UserMessage: React.FC<UserMessageProps> = ({ text }) => (
<PrefixedTextMessage
text={text}
prefix=">"
prefixColor={theme.text.accent}
textColor={theme.text.accent}
ariaLabel={SCREEN_READER_USER_PREFIX}
alignSelf="flex-start"
/>
);
export const UserShellMessage: React.FC<UserShellMessageProps> = ({ text }) => {
const commandToDisplay = text.startsWith('!') ? text.substring(1) : text;
return (
<PrefixedTextMessage
text={commandToDisplay}
prefix="$"
prefixColor={theme.text.link}
textColor={theme.text.primary}
/>
);
};
export const AssistantMessage: React.FC<AssistantMessageProps> = ({
text,
isPending,
availableTerminalHeight,
contentWidth,
}) => (
<PrefixedMarkdownMessage
text={text}
prefix="✦"
prefixColor={theme.text.accent}
ariaLabel={SCREEN_READER_MODEL_PREFIX}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth}
/>
);
export const AssistantMessageContent: React.FC<
AssistantMessageContentProps
> = ({ text, isPending, availableTerminalHeight, contentWidth }) => (
<ContinuationMarkdownMessage
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth}
basePrefix="✦"
/>
);
export const ThinkMessage: React.FC<ThinkMessageProps> = ({
text,
isPending,
availableTerminalHeight,
contentWidth,
}) => (
<PrefixedMarkdownMessage
text={text}
prefix="✦"
prefixColor={theme.text.secondary}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth}
textColor={theme.text.secondary}
/>
);
export const ThinkMessageContent: React.FC<ThinkMessageContentProps> = ({
text,
isPending,
availableTerminalHeight,
contentWidth,
}) => (
<ContinuationMarkdownMessage
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth}
basePrefix="✦"
textColor={theme.text.secondary}
/>
);

View file

@ -56,6 +56,7 @@ index 0000000..e69de29
80,
undefined,
mockSettings,
4,
);
});
@ -86,6 +87,7 @@ index 0000000..e69de29
80,
undefined,
mockSettings,
4,
);
});
@ -115,6 +117,7 @@ index 0000000..e69de29
80,
undefined,
mockSettings,
4,
);
});

View file

@ -161,6 +161,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
contentWidth,
theme,
settings,
tabWidth,
);
} else {
renderedOutput = renderDiffContent(

View file

@ -1,38 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text, Box } from 'ink';
import { theme } from '../../semantic-colors.js';
interface ErrorMessageProps {
text: string;
/** Optional inline hint displayed after the error text in secondary/dimmed color */
hint?: string;
}
/**
* Renders an error message with a "✕" prefix.
* When a hint is provided (e.g., retry countdown), it is displayed inline
* in parentheses with a dimmed secondary color, similar to the ESC hint
* style used in LoadingIndicator.
*/
export const ErrorMessage: React.FC<ErrorMessageProps> = ({ text, hint }) => {
const prefix = '✕ ';
const prefixWidth = prefix.length;
return (
<Box flexDirection="row">
<Box width={prefixWidth}>
<Text color={theme.status.error}>{prefix}</Text>
</Box>
<Box flexGrow={1} flexWrap="wrap" flexDirection="row">
<Text color={theme.status.error}>{text}</Text>
{hint && <Text color={theme.text.secondary}> ({hint})</Text>}
</Box>
</Box>
);
};

View file

@ -1,46 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text, Box } from 'ink';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { theme } from '../../semantic-colors.js';
import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js';
interface GeminiMessageProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
contentWidth: number;
}
export const GeminiMessage: React.FC<GeminiMessageProps> = ({
text,
isPending,
availableTerminalHeight,
contentWidth,
}) => {
const prefix = '✦ ';
const prefixWidth = prefix.length;
return (
<Box flexDirection="row">
<Box width={prefixWidth}>
<Text color={theme.text.accent} aria-label={SCREEN_READER_MODEL_PREFIX}>
{prefix}
</Text>
</Box>
<Box flexGrow={1} flexDirection="column">
<MarkdownDisplay
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth - prefixWidth}
/>
</Box>
</Box>
);
};

View file

@ -1,43 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box } from 'ink';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
interface GeminiMessageContentProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
contentWidth: number;
}
/*
* Gemini message content is a semi-hacked component. The intention is to represent a partial
* of GeminiMessage and is only used when a response gets too long. In that instance messages
* are split into multiple GeminiMessageContent's to enable the root <Static> component in
* App.tsx to be as performant as humanly possible.
*/
export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
text,
isPending,
availableTerminalHeight,
contentWidth,
}) => {
const originalPrefix = '✦ ';
const prefixWidth = originalPrefix.length;
return (
<Box flexDirection="column" paddingLeft={prefixWidth}>
<MarkdownDisplay
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth - prefixWidth}
/>
</Box>
);
};

View file

@ -1,48 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text, Box } from 'ink';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { theme } from '../../semantic-colors.js';
interface GeminiThoughtMessageProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
contentWidth: number;
}
/**
* Displays model thinking/reasoning text with a softer, dimmed style
* to visually distinguish it from regular content output.
*/
export const GeminiThoughtMessage: React.FC<GeminiThoughtMessageProps> = ({
text,
isPending,
availableTerminalHeight,
contentWidth,
}) => {
const prefix = '✦ ';
const prefixWidth = prefix.length;
return (
<Box flexDirection="row">
<Box width={prefixWidth}>
<Text color={theme.text.secondary}>{prefix}</Text>
</Box>
<Box flexGrow={1} flexDirection="column">
<MarkdownDisplay
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth - prefixWidth}
textColor={theme.text.secondary}
/>
</Box>
</Box>
);
};

View file

@ -1,40 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box } from 'ink';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { theme } from '../../semantic-colors.js';
interface GeminiThoughtMessageContentProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
contentWidth: number;
}
/**
* Continuation component for thought messages, similar to GeminiMessageContent.
* Used when a thought response gets too long and needs to be split for performance.
*/
export const GeminiThoughtMessageContent: React.FC<
GeminiThoughtMessageContentProps
> = ({ text, isPending, availableTerminalHeight, contentWidth }) => {
const originalPrefix = '✦ ';
const prefixWidth = originalPrefix.length;
return (
<Box flexDirection="column" paddingLeft={prefixWidth}>
<MarkdownDisplay
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth - prefixWidth}
textColor={theme.text.secondary}
/>
</Box>
);
};

View file

@ -1,37 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text, Box } from 'ink';
import { theme } from '../../semantic-colors.js';
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
interface InfoMessageProps {
text: string;
}
export const InfoMessage: React.FC<InfoMessageProps> = ({ text }) => {
// Don't render anything if text is empty
if (!text || text.trim() === '') {
return null;
}
const prefix = ' ';
const prefixWidth = prefix.length;
return (
<Box flexDirection="row">
<Box width={prefixWidth}>
<Text color={theme.status.warning}>{prefix}</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap" color={theme.status.warning}>
<RenderInline text={text} textColor={theme.status.warning} />
</Text>
</Box>
</Box>
);
};

View file

@ -1,41 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text, Box } from 'ink';
import { theme } from '../../semantic-colors.js';
interface RetryCountdownMessageProps {
text: string;
}
/**
* Displays a retry countdown message in a dimmed/secondary style
* to visually distinguish it from error messages.
*/
export const RetryCountdownMessage: React.FC<RetryCountdownMessageProps> = ({
text,
}) => {
if (!text || text.trim() === '') {
return null;
}
const prefix = '↻ ';
const prefixWidth = prefix.length;
return (
<Box flexDirection="row">
<Box width={prefixWidth}>
<Text color={theme.text.secondary}>{prefix}</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap" color={theme.text.secondary}>
{text}
</Text>
</Box>
</Box>
);
};

View file

@ -0,0 +1,105 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import stringWidth from 'string-width';
import { theme } from '../../semantic-colors.js';
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
interface StatusMessageProps {
text: string;
prefix: string;
prefixColor: string;
textColor: string;
children?: React.ReactNode;
}
interface StatusTextProps {
text: string;
}
/**
* Shared renderer for status-like history messages (info/warning/error/retry).
* Keeps prefix spacing and wrapping behavior consistent across variants.
*/
export const StatusMessage: React.FC<StatusMessageProps> = ({
text,
prefix,
prefixColor,
textColor,
children,
}) => {
if (!text || text.trim() === '') {
return null;
}
const prefixWidth = stringWidth(prefix) + 1;
return (
<Box flexDirection="row">
<Box width={prefixWidth} flexShrink={0}>
<Text color={prefixColor}>{prefix}</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap" color={textColor}>
<RenderInline text={text} />
{children}
</Text>
</Box>
</Box>
);
};
export const InfoMessage: React.FC<StatusTextProps> = ({ text }) => (
<StatusMessage
text={text}
prefix="●"
prefixColor={theme.text.primary}
textColor={theme.text.primary}
/>
);
export const SuccessMessage: React.FC<StatusTextProps> = ({ text }) => (
<StatusMessage
text={text}
prefix="✓"
prefixColor={theme.status.success}
textColor={theme.status.success}
/>
);
export const WarningMessage: React.FC<StatusTextProps> = ({ text }) => (
<StatusMessage
text={text}
prefix="⚠"
prefixColor={theme.status.warning}
textColor={theme.status.warning}
/>
);
export const ErrorMessage: React.FC<StatusTextProps & { hint?: string }> = ({
text,
hint,
}) => (
<StatusMessage
text={text}
prefix="✕"
prefixColor={theme.status.error}
textColor={theme.status.error}
>
{hint && <Text color={theme.text.secondary}> ({hint})</Text>}
</StatusMessage>
);
export const RetryCountdownMessage: React.FC<StatusTextProps> = ({ text }) => (
<StatusMessage
text={text}
prefix="↻"
prefixColor={theme.text.secondary}
textColor={theme.text.secondary}
/>
);

View file

@ -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;
@ -363,6 +364,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;

View file

@ -1,38 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text, Box } from 'ink';
import { theme } from '../../semantic-colors.js';
import { SCREEN_READER_USER_PREFIX } from '../../textConstants.js';
import { isSlashCommand as checkIsSlashCommand } from '../../utils/commandUtils.js';
interface UserMessageProps {
text: string;
}
export const UserMessage: React.FC<UserMessageProps> = ({ text }) => {
const prefix = '> ';
const prefixWidth = prefix.length;
const isSlashCommand = checkIsSlashCommand(text);
const textColor = isSlashCommand ? theme.text.accent : theme.text.secondary;
return (
<Box flexDirection="row" paddingY={0} marginY={1} alignSelf="flex-start">
<Box width={prefixWidth}>
<Text color={theme.text.accent} aria-label={SCREEN_READER_USER_PREFIX}>
{prefix}
</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap" color={textColor}>
{text}
</Text>
</Box>
</Box>
);
};

View file

@ -1,25 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
interface UserShellMessageProps {
text: string;
}
export const UserShellMessage: React.FC<UserShellMessageProps> = ({ text }) => {
// Remove leading '!' if present, as App.tsx adds it for the processor.
const commandToDisplay = text.startsWith('!') ? text.substring(1) : text;
return (
<Box>
<Text color={theme.text.link}>$ </Text>
<Text color={theme.text.primary}>{commandToDisplay}</Text>
</Box>
);
};

View file

@ -1,33 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../../colors.js';
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
import { theme } from '../../semantic-colors.js';
interface WarningMessageProps {
text: string;
}
export const WarningMessage: React.FC<WarningMessageProps> = ({ text }) => {
const prefix = '⚠ ';
const prefixWidth = 3;
return (
<Box flexDirection="row">
<Box width={prefixWidth}>
<Text color={Colors.AccentYellow}>{prefix}</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap" color={Colors.AccentYellow}>
<RenderInline text={text} textColor={theme.status.warning} />
</Text>
</Box>
</Box>
);
};