mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 19:52:02 +00:00
Merge branch 'main' into feat/support-permission
This commit is contained in:
commit
7450067e37
337 changed files with 31069 additions and 8843 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
261
packages/cli/src/ui/components/messages/ConversationMessages.tsx
Normal file
261
packages/cli/src/ui/components/messages/ConversationMessages.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -161,6 +161,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
|
|||
contentWidth,
|
||||
theme,
|
||||
settings,
|
||||
tabWidth,
|
||||
);
|
||||
} else {
|
||||
renderedOutput = renderDiffContent(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
105
packages/cli/src/ui/components/messages/StatusMessages.tsx
Normal file
105
packages/cli/src/ui/components/messages/StatusMessages.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue