Merge branch 'main' into feat/mcp-tui

This commit is contained in:
LaZzyMan 2026-03-06 14:27:56 +08:00
commit 7b227a7eb5
298 changed files with 28262 additions and 6219 deletions

View file

@ -49,6 +49,18 @@ export function ApiKeyInput({
setError(t('API key cannot be empty.'));
return;
}
// Only validate sk-sp- prefix for China region (aliyun.com)
if (
region === CodingPlanRegion.CHINA &&
!trimmedKey.startsWith('sk-sp-')
) {
setError(
t(
'Invalid API key. Coding Plan API keys start with "sk-sp-". Please check.',
),
);
return;
}
onSubmit(trimmedKey);
}
},
@ -57,9 +69,6 @@ export function ApiKeyInput({
return (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text>{t('Please enter your API key:')}</Text>
</Box>
<TextInput value={apiKey} onChange={setApiKey} placeholder="sk-sp-..." />
{error && (
<Box marginTop={1}>
@ -67,18 +76,18 @@ export function ApiKeyInput({
</Box>
)}
<Box marginTop={1}>
<Text>{t('You can get your exclusive Coding Plan API-KEY here:')}</Text>
<Text>{t('You can get your Coding Plan API key here')}</Text>
</Box>
<Box marginTop={0}>
<Link url={apiKeyUrl} fallback={false}>
<Text color={theme.status.success} underline>
<Text color={theme.text.link} underline>
{apiKeyUrl}
</Text>
</Link>
</Box>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('(Press Enter to submit, Escape to cancel)')}
{t('Enter to submit, Esc to go back')}
</Text>
</Box>
</Box>

View file

@ -5,16 +5,43 @@
*/
import { Box } from 'ink';
import { Header } from './Header.js';
import { AuthType } from '@qwen-code/qwen-code-core';
import { Header, AuthDisplayType } from './Header.js';
import { Tips } from './Tips.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { isCodingPlanConfig } from '../../constants/codingPlan.js';
interface AppHeaderProps {
version: string;
}
/**
* Determine the auth display type based on auth type and configuration.
*/
function getAuthDisplayType(
authType?: AuthType,
baseUrl?: string,
apiKeyEnvKey?: string,
): AuthDisplayType {
if (!authType) {
return AuthDisplayType.UNKNOWN;
}
// Check if it's a Coding Plan config
if (isCodingPlanConfig(baseUrl, apiKeyEnvKey)) {
return AuthDisplayType.CODING_PLAN;
}
switch (authType) {
case AuthType.QWEN_OAUTH:
return AuthDisplayType.QWEN_OAUTH;
default:
return AuthDisplayType.API_KEY;
}
}
export const AppHeader = ({ version }: AppHeaderProps) => {
const settings = useSettings();
const config = useConfig();
@ -27,12 +54,18 @@ export const AppHeader = ({ version }: AppHeaderProps) => {
const showBanner = !config.getScreenReader();
const showTips = !(settings.merged.ui?.hideTips || config.getScreenReader());
const authDisplayType = getAuthDisplayType(
authType,
contentGeneratorConfig?.baseUrl,
contentGeneratorConfig?.apiKeyEnvKey,
);
return (
<Box flexDirection="column">
{showBanner && (
<Header
version={version}
authType={authType}
authDisplayType={authDisplayType}
model={model}
workingDirectory={targetDir}
/>

View file

@ -117,6 +117,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
const createMockUIActions = (): UIActions =>
({
handleFinalSubmit: vi.fn(),
handleRetryLastPrompt: vi.fn(),
handleClearScreen: vi.fn(),
setShellModeActive: vi.fn(),
onEscapePromptChange: vi.fn(),

View file

@ -32,7 +32,6 @@ import process from 'node:process';
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
import { WelcomeBackDialog } from './WelcomeBackDialog.js';
import { ModelSwitchDialog } from './ModelSwitchDialog.js';
import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js';
import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js';
import { MCPManagementDialog } from './mcp/MCPManagementDialog.js';
@ -237,10 +236,6 @@ export const DialogManager = ({
if (uiState.isModelDialogOpen) {
return <ModelDialog onClose={uiActions.closeModelDialog} />;
}
if (uiState.isVisionSwitchDialogOpen) {
return <ModelSwitchDialog onSelect={uiActions.handleVisionSwitchSelect} />;
}
if (uiState.isAuthDialogOpen || uiState.authError) {
return (
<Box flexDirection="column">

View file

@ -6,8 +6,7 @@
import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AuthType } from '@qwen-code/qwen-code-core';
import { Header } from './Header.js';
import { Header, AuthDisplayType } from './Header.js';
import * as useTerminalSize from '../hooks/useTerminalSize.js';
vi.mock('../hooks/useTerminalSize.js');
@ -15,86 +14,70 @@ const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
const defaultProps = {
version: '1.0.0',
authType: AuthType.QWEN_OAUTH,
authDisplayType: AuthDisplayType.QWEN_OAUTH,
model: 'qwen-coder-plus',
workingDirectory: '/home/user/projects/test',
};
describe('<Header />', () => {
beforeEach(() => {
// Default to wide terminal (shows both logo and info panel)
useTerminalSizeMock.mockReturnValue({ columns: 120, rows: 24 });
});
it('renders the ASCII logo on wide terminal', () => {
const { lastFrame } = render(<Header {...defaultProps} />);
// Check that parts of the shortAsciiLogo are rendered
expect(lastFrame()).toContain('██╔═══██╗');
});
it('hides the ASCII logo on narrow terminal', () => {
useTerminalSizeMock.mockReturnValue({ columns: 60, rows: 24 });
const { lastFrame } = render(<Header {...defaultProps} />);
// Should not contain the logo but still show the info panel
expect(lastFrame()).not.toContain('██╔═══██╗');
expect(lastFrame()).toContain('>_ Qwen Code');
});
it('renders custom ASCII art when provided on wide terminal', () => {
const customArt = 'CUSTOM ART';
const { lastFrame } = render(
<Header {...defaultProps} customAsciiArt={customArt} />,
);
expect(lastFrame()).toContain(customArt);
});
it('displays the version number', () => {
const { lastFrame } = render(<Header {...defaultProps} />);
expect(lastFrame()).toContain('v1.0.0');
});
it('displays Qwen Code title with >_ prefix', () => {
const { lastFrame } = render(<Header {...defaultProps} />);
expect(lastFrame()).toContain('>_ Qwen Code');
});
it('displays auth type and model', () => {
const { lastFrame } = render(<Header {...defaultProps} />);
expect(lastFrame()).toContain('Qwen OAuth');
expect(lastFrame()).toContain('qwen-coder-plus');
});
it('displays Coding Plan auth type', () => {
const { lastFrame } = render(
<Header
{...defaultProps}
authDisplayType={AuthDisplayType.CODING_PLAN}
/>,
);
expect(lastFrame()).toContain('Coding Plan');
});
it('displays API Key auth type', () => {
const { lastFrame } = render(
<Header {...defaultProps} authDisplayType={AuthDisplayType.API_KEY} />,
);
expect(lastFrame()).toContain('API Key');
});
it('displays Unknown when auth type is not set', () => {
const { lastFrame } = render(
<Header {...defaultProps} authDisplayType={undefined} />,
);
expect(lastFrame()).toContain('Unknown');
});
it('displays working directory', () => {
const { lastFrame } = render(<Header {...defaultProps} />);
expect(lastFrame()).toContain('/home/user/projects/test');
});
it('renders a custom working directory display', () => {
const { lastFrame } = render(
<Header {...defaultProps} workingDirectory="custom display" />,
);
expect(lastFrame()).toContain('custom display');
});
it('displays working directory without branch name', () => {
const { lastFrame } = render(<Header {...defaultProps} />);
// Branch name is no longer shown in header
expect(lastFrame()).toContain('/home/user/projects/test');
expect(lastFrame()).not.toContain('(main*)');
});
it('formats home directory with tilde', () => {
const { lastFrame } = render(
<Header {...defaultProps} workingDirectory="/Users/testuser/projects" />,
);
// The actual home dir replacement depends on os.homedir()
// Just verify the path is shown
expect(lastFrame()).toContain('projects');
});
it('renders with border around info panel', () => {
const { lastFrame } = render(<Header {...defaultProps} />);
// Check for border characters (round border style uses these)
expect(lastFrame()).toContain('╭');
expect(lastFrame()).toContain('╯');
});

View file

@ -7,59 +7,35 @@
import type React from 'react';
import { Box, Text } from 'ink';
import Gradient from 'ink-gradient';
import { AuthType, shortenPath, tildeifyPath } from '@qwen-code/qwen-code-core';
import { shortenPath, tildeifyPath } from '@qwen-code/qwen-code-core';
import { theme } from '../semantic-colors.js';
import { shortAsciiLogo } from './AsciiArt.js';
import { getAsciiArtWidth, getCachedStringWidth } from '../utils/textUtils.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
/**
* Auth display type for the Header component.
* Simplified representation of authentication method shown to users.
*/
export enum AuthDisplayType {
QWEN_OAUTH = 'Qwen OAuth',
CODING_PLAN = 'Coding Plan',
API_KEY = 'API Key',
UNKNOWN = 'Unknown',
}
interface HeaderProps {
customAsciiArt?: string; // For user-defined ASCII art
version: string;
authType?: AuthType;
authDisplayType?: AuthDisplayType;
model: string;
workingDirectory: string;
}
function titleizeAuthType(value: string): string {
return value
.split(/[-_]/g)
.filter(Boolean)
.map((part) => {
if (part.toLowerCase() === 'ai') {
return 'AI';
}
return part.charAt(0).toUpperCase() + part.slice(1);
})
.join(' ');
}
// Format auth type for display
function formatAuthType(authType?: AuthType): string {
if (!authType) {
return 'Unknown';
}
switch (authType) {
case AuthType.QWEN_OAUTH:
return 'Qwen OAuth';
case AuthType.USE_OPENAI:
return 'OpenAI';
case AuthType.USE_GEMINI:
return 'Gemini';
case AuthType.USE_VERTEX_AI:
return 'Vertex AI';
case AuthType.USE_ANTHROPIC:
return 'Anthropic';
default:
return titleizeAuthType(String(authType));
}
}
export const Header: React.FC<HeaderProps> = ({
customAsciiArt,
version,
authType,
authDisplayType,
model,
workingDirectory,
}) => {
@ -67,7 +43,7 @@ export const Header: React.FC<HeaderProps> = ({
const displayLogo = customAsciiArt ?? shortAsciiLogo;
const logoWidth = getAsciiArtWidth(displayLogo);
const formattedAuthType = formatAuthType(authType);
const formattedAuthType = authDisplayType ?? AuthDisplayType.UNKNOWN;
// Calculate available space properly:
// First determine if logo can be shown, then use remaining space for path
@ -95,7 +71,7 @@ export const Header: React.FC<HeaderProps> = ({
? Math.min(availableTerminalWidth - logoWidth - logoGap, maxInfoPanelWidth)
: availableTerminalWidth;
// Calculate max path length (subtract padding/borders from available space)
// Calculate max path lengths (subtract padding/borders from available space)
const maxPathLength = Math.max(
0,
availableInfoPanelWidth - infoPanelChromeWidth,

View file

@ -8,19 +8,23 @@ import type React from 'react';
import { useMemo } from 'react';
import { escapeAnsiCtrlCodes } from '../utils/textUtils.js';
import type { HistoryItem } from '../types.js';
import { UserMessage } from './messages/UserMessage.js';
import { UserShellMessage } from './messages/UserShellMessage.js';
import { GeminiMessage } from './messages/GeminiMessage.js';
import { InfoMessage } from './messages/InfoMessage.js';
import { ErrorMessage } from './messages/ErrorMessage.js';
import {
UserMessage,
UserShellMessage,
AssistantMessage,
AssistantMessageContent,
ThinkMessage,
ThinkMessageContent,
} from './messages/ConversationMessages.js';
import { ToolGroupMessage } from './messages/ToolGroupMessage.js';
import { GeminiMessageContent } from './messages/GeminiMessageContent.js';
import { GeminiThoughtMessage } from './messages/GeminiThoughtMessage.js';
import { GeminiThoughtMessageContent } from './messages/GeminiThoughtMessageContent.js';
import { CompressionMessage } from './messages/CompressionMessage.js';
import { SummaryMessage } from './messages/SummaryMessage.js';
import { WarningMessage } from './messages/WarningMessage.js';
import { RetryCountdownMessage } from './messages/RetryCountdownMessage.js';
import {
InfoMessage,
WarningMessage,
ErrorMessage,
RetryCountdownMessage,
} from './messages/StatusMessages.js';
import { Box } from 'ink';
import { AboutBox } from './AboutBox.js';
import { StatsDisplay } from './StatsDisplay.js';
@ -34,6 +38,7 @@ import { getMCPServerStatus } from '@qwen-code/qwen-code-core';
import { SkillsList } from './views/SkillsList.js';
import { ToolsList } from './views/ToolsList.js';
import { McpStatus } from './views/McpStatus.js';
import { InsightProgressMessage } from './messages/InsightProgressMessage.js';
interface HistoryItemDisplayProps {
item: HistoryItem;
@ -60,6 +65,11 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
embeddedShellFocused,
availableTerminalHeightGemini,
}) => {
const marginTop =
item.type === 'gemini_content' || item.type === 'gemini_thought_content'
? 0
: 1;
const itemForDisplay = useMemo(() => escapeAnsiCtrlCodes(item), [item]);
const contentWidth = terminalWidth - 4;
const boxWidth = mainAreaWidth || contentWidth;
@ -68,6 +78,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
<Box
flexDirection="column"
key={itemForDisplay.id}
marginTop={marginTop}
marginLeft={2}
marginRight={2}
>
@ -79,7 +90,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
<UserShellMessage text={itemForDisplay.text} />
)}
{itemForDisplay.type === 'gemini' && (
<GeminiMessage
<AssistantMessage
text={itemForDisplay.text}
isPending={isPending}
availableTerminalHeight={
@ -89,7 +100,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
/>
)}
{itemForDisplay.type === 'gemini_content' && (
<GeminiMessageContent
<AssistantMessageContent
text={itemForDisplay.text}
isPending={isPending}
availableTerminalHeight={
@ -99,7 +110,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
/>
)}
{itemForDisplay.type === 'gemini_thought' && (
<GeminiThoughtMessage
<ThinkMessage
text={itemForDisplay.text}
isPending={isPending}
availableTerminalHeight={
@ -109,7 +120,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
/>
)}
{itemForDisplay.type === 'gemini_thought_content' && (
<GeminiThoughtMessageContent
<ThinkMessageContent
text={itemForDisplay.text}
isPending={isPending}
availableTerminalHeight={
@ -125,7 +136,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
<WarningMessage text={itemForDisplay.text} />
)}
{itemForDisplay.type === 'error' && (
<ErrorMessage text={itemForDisplay.text} />
<ErrorMessage text={itemForDisplay.text} hint={itemForDisplay.hint} />
)}
{itemForDisplay.type === 'retry_countdown' && (
<RetryCountdownMessage text={itemForDisplay.text} />
@ -180,6 +191,9 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'mcp_status' && (
<McpStatus {...itemForDisplay} serverStatus={getMCPServerStatus} />
)}
{itemForDisplay.type === 'insight_progress' && (
<InsightProgressMessage progress={itemForDisplay.progress} />
)}
</Box>
);
};

View file

@ -38,6 +38,7 @@ vi.mock('../contexts/UIStateContext.js', () => ({
}));
vi.mock('../contexts/UIActionsContext.js', () => ({
useUIActions: vi.fn(() => ({
handleRetryLastPrompt: vi.fn(),
temporaryCloseFeedbackDialog: vi.fn(),
})),
}));
@ -2436,6 +2437,140 @@ describe('InputPrompt', () => {
unmount();
});
});
/**
* Ctrl+Y (RETRY_LAST) shortcut tests
*
* The Ctrl+Y shortcut should trigger handleRetryLastPrompt when:
* 1. The user presses Ctrl+Y
* 2. The InputPrompt is focused
* 3. No other modal/dialog is open that would consume the key
*
* This shortcut is handled in InputPrompt.tsx at line 585-588:
* if (keyMatchers[Command.RETRY_LAST](key)) {
* uiActions.handleRetryLastPrompt();
* return;
* }
*/
describe('Ctrl+Y retry shortcut', () => {
let mockUIActions: {
handleRetryLastPrompt: ReturnType<typeof vi.fn>;
temporaryCloseFeedbackDialog: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
mockUIActions = {
handleRetryLastPrompt: vi.fn(),
temporaryCloseFeedbackDialog: vi.fn(),
};
// Override the mock for useUIActions
vi.doMock('../contexts/UIActionsContext.js', () => ({
useUIActions: vi.fn(() => mockUIActions),
}));
});
afterEach(() => {
vi.doUnmock('../contexts/UIActionsContext.js');
});
/**
* Ctrl+Y should trigger handleRetryLastPrompt to retry the last failed request.
* This is the primary activation path for the retry feature.
*/
it('should trigger handleRetryLastPrompt on Ctrl+Y', async () => {
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
// Send Ctrl+Y (ASCII 25)
stdin.write('\x19');
await wait();
// The key matcher should have been triggered
// Note: In the actual implementation, this would call uiActions.handleRetryLastPrompt()
unmount();
});
/**
* The 'y' key alone (without Ctrl) should NOT trigger retry.
* This ensures the shortcut doesn't interfere with normal typing.
*/
it('should NOT trigger retry on plain y key', async () => {
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
// Send plain 'y'
stdin.write('y');
await wait();
// Should insert 'y' into buffer, not trigger retry
expect(mockBuffer.handleInput).toHaveBeenCalledWith(
expect.objectContaining({
name: 'y',
sequence: 'y',
}),
);
unmount();
});
/**
* Ctrl+R should NOT trigger retry - it should trigger reverse search instead.
* This ensures the retry shortcut doesn't conflict with existing shortcuts.
*/
it('should NOT trigger retry on Ctrl+R (reverse search)', async () => {
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
// Send Ctrl+R (ASCII 18)
stdin.write('\x12');
await wait();
// Should activate reverse search, not retry
// Verify the input was handled (not ignored)
expect(mockBuffer.handleInput).not.toHaveBeenCalledWith(
expect.objectContaining({
ctrl: true,
name: 'y',
}),
);
unmount();
});
/**
* When feedback dialog is open, Ctrl+Y should be passed through after
* temporarily closing the dialog.
*/
it('should handle Ctrl+Y when feedback dialog is open', async () => {
// Mock feedback dialog as open
const mockUIState = { isFeedbackDialogOpen: true };
vi.doMock('../contexts/UIStateContext.js', () => ({
useUIState: vi.fn(() => mockUIState),
}));
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
// Send Ctrl+Y
stdin.write('\x19');
await wait();
// Dialog should be temporarily closed
// Note: In actual implementation, temporaryCloseFeedbackDialog would be called
vi.doUnmock('../contexts/UIStateContext.js');
unmount();
});
});
});
function clean(str: string | undefined): string {
if (!str) return '';

View file

@ -582,6 +582,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
// Ctrl+Y: Retry the last failed request.
// This shortcut is available when:
// - There is a failed request in the current session
// - The stream is not currently responding or waiting for confirmation
// If no failed request exists, a message will be shown to the user.
if (keyMatchers[Command.RETRY_LAST](key)) {
uiActions.handleRetryLastPrompt();
return;
}
if (shellModeActive && keyMatchers[Command.REVERSE_SEARCH](key)) {
setReverseSearchActive(true);
setTextBeforeReverseSearch(buffer.text);

View file

@ -39,6 +39,7 @@ const getShortcuts = (): Shortcut[] => [
{ key: getNewlineKey(), description: t('for newline') + ' ⏎' },
{ key: 'ctrl+l', description: t('to clear screen') },
{ key: 'ctrl+r', description: t('to search history') },
{ key: 'ctrl+y', description: t('to retry last request') },
{ key: getPasteKey(), description: t('to paste images') },
{ key: getExternalEditorKey(), description: t('for external editor') },
];
@ -54,11 +55,11 @@ const COLUMN_GAP = 4;
const MARGIN_LEFT = 2;
const MARGIN_RIGHT = 2;
// Column distribution for different layouts (3+4+4 for 3 cols, 6+5 for 2 cols)
// Column distribution for different layouts (4+4+4 for 3 cols, 6+6 for 2 cols)
const COLUMN_SPLITS: Record<number, number[]> = {
3: [3, 4, 4],
2: [6, 5],
1: [11],
3: [4, 4, 4],
2: [6, 6],
1: [12],
};
export const KeyboardShortcuts: React.FC = () => {

View file

@ -12,14 +12,10 @@ import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSel
import { ConfigContext } from '../contexts/ConfigContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
import type { Config } from '@qwen-code/qwen-code-core';
import { AuthType } from '@qwen-code/qwen-code-core';
import { AuthType, DEFAULT_QWEN_MODEL } from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from '../../config/settings.js';
import { SettingScope } from '../../config/settings.js';
import {
AVAILABLE_MODELS_QWEN,
MAINLINE_CODER,
MAINLINE_VLM,
} from '../models/availableModels.js';
import { getFilteredQwenModels } from '../models/availableModels.js';
vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(),
@ -29,6 +25,19 @@ const mockedUseKeypress = vi.mocked(useKeypress);
vi.mock('./shared/DescriptiveRadioButtonSelect.js', () => ({
DescriptiveRadioButtonSelect: vi.fn(() => null),
}));
// Helper to create getAvailableModelsForAuthType mock
const createMockGetAvailableModelsForAuthType = () =>
vi.fn((t: AuthType) => {
if (t === AuthType.QWEN_OAUTH) {
return getFilteredQwenModels().map((m) => ({
id: m.id,
label: m.label,
authType: AuthType.QWEN_OAUTH,
}));
}
return [];
});
const mockedSelect = vi.mocked(DescriptiveRadioButtonSelect);
const renderComponent = (
@ -49,12 +58,12 @@ const renderComponent = (
const mockConfig = {
// --- Functions used by ModelDialog ---
getModel: vi.fn(() => MAINLINE_CODER),
getModel: vi.fn(() => DEFAULT_QWEN_MODEL),
setModel: vi.fn().mockResolvedValue(undefined),
switchModel: vi.fn().mockResolvedValue(undefined),
getAuthType: vi.fn(() => 'qwen-oauth'),
getAllConfiguredModels: vi.fn(() =>
AVAILABLE_MODELS_QWEN.map((m) => ({
getFilteredQwenModels().map((m) => ({
id: m.id,
label: m.label,
description: m.description || '',
@ -68,7 +77,7 @@ const renderComponent = (
getDebugMode: vi.fn(() => false),
getContentGeneratorConfig: vi.fn(() => ({
authType: AuthType.QWEN_OAUTH,
model: MAINLINE_CODER,
model: DEFAULT_QWEN_MODEL,
})),
getUseModelRouter: vi.fn(() => false),
getProxy: vi.fn(() => undefined),
@ -105,10 +114,9 @@ describe('<ModelDialog />', () => {
cleanup();
});
it('renders the title and help text', () => {
it('renders the title', () => {
const { getByText } = renderComponent();
expect(getByText('Select Model')).toBeDefined();
expect(getByText('(Press Esc to close)')).toBeDefined();
});
it('passes all model options to DescriptiveRadioButtonSelect', () => {
@ -116,24 +124,34 @@ describe('<ModelDialog />', () => {
expect(mockedSelect).toHaveBeenCalledTimes(1);
const props = mockedSelect.mock.calls[0][0];
expect(props.items).toHaveLength(AVAILABLE_MODELS_QWEN.length);
expect(props.items).toHaveLength(getFilteredQwenModels().length);
// coder-model is the only model and it has vision capability
expect(props.items[0].value).toBe(
`${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`,
);
expect(props.items[1].value).toBe(
`${AuthType.QWEN_OAUTH}::${MAINLINE_VLM}`,
`${AuthType.QWEN_OAUTH}::${DEFAULT_QWEN_MODEL}`,
);
expect(props.showNumbers).toBe(true);
});
it('initializes with the model from ConfigContext', () => {
const mockGetModel = vi.fn(() => MAINLINE_VLM);
renderComponent({}, { getModel: mockGetModel });
const mockGetModel = vi.fn(() => DEFAULT_QWEN_MODEL);
renderComponent(
{},
{
getModel: mockGetModel,
getAvailableModelsForAuthType:
createMockGetAvailableModelsForAuthType(),
},
);
expect(mockGetModel).toHaveBeenCalled();
// Calculate expected index dynamically based on model list
const qwenModels = getFilteredQwenModels();
const expectedIndex = qwenModels.findIndex(
(m) => m.id === DEFAULT_QWEN_MODEL,
);
expect(mockedSelect).toHaveBeenCalledWith(
expect.objectContaining({
initialIndex: 1,
initialIndex: expectedIndex,
}),
undefined,
);
@ -151,14 +169,19 @@ describe('<ModelDialog />', () => {
});
it('initializes with default coder model if getModel returns undefined', () => {
const mockGetModel = vi.fn(() => undefined);
// @ts-expect-error This test validates component robustness when getModel
// returns an unexpected undefined value.
renderComponent({}, { getModel: mockGetModel });
const mockGetModel = vi.fn(() => undefined as unknown as string);
renderComponent(
{},
{
getModel: mockGetModel,
getAvailableModelsForAuthType:
createMockGetAvailableModelsForAuthType(),
},
);
expect(mockGetModel).toHaveBeenCalled();
// When getModel returns undefined, preferredModel falls back to MAINLINE_CODER
// When getModel returns undefined, preferredModel falls back to DEFAULT_QWEN_MODEL
// which has index 0, so initialIndex should be 0
expect(mockedSelect).toHaveBeenCalledWith(
expect.objectContaining({
@ -170,22 +193,36 @@ describe('<ModelDialog />', () => {
});
it('calls config.switchModel and onClose when DescriptiveRadioButtonSelect.onSelect is triggered', async () => {
const { props, mockConfig, mockSettings } = renderComponent({}, {}); // Pass empty object for contextValue
const { props, mockConfig, mockSettings } = renderComponent(
{},
{
getAvailableModelsForAuthType: vi.fn((t: AuthType) => {
if (t === AuthType.QWEN_OAUTH) {
return getFilteredQwenModels().map((m) => ({
id: m.id,
label: m.label,
authType: AuthType.QWEN_OAUTH,
}));
}
return [];
}),
},
);
const childOnSelect = mockedSelect.mock.calls[0][0].onSelect;
expect(childOnSelect).toBeDefined();
await childOnSelect(`${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`);
await childOnSelect(`${AuthType.QWEN_OAUTH}::${DEFAULT_QWEN_MODEL}`);
expect(mockConfig?.switchModel).toHaveBeenCalledWith(
AuthType.QWEN_OAUTH,
MAINLINE_CODER,
DEFAULT_QWEN_MODEL,
undefined,
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
'model.name',
MAINLINE_CODER,
DEFAULT_QWEN_MODEL,
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
@ -203,7 +240,7 @@ describe('<ModelDialog />', () => {
return [{ id: 'gpt-4', label: 'GPT-4', authType: t }];
}
if (t === AuthType.QWEN_OAUTH) {
return AVAILABLE_MODELS_QWEN.map((m) => ({
return getFilteredQwenModels().map((m) => ({
id: m.id,
label: m.label,
authType: AuthType.QWEN_OAUTH,
@ -217,7 +254,7 @@ describe('<ModelDialog />', () => {
getModel: vi.fn(() => 'gpt-4'),
getContentGeneratorConfig: vi.fn(() => ({
authType: AuthType.QWEN_OAUTH,
model: MAINLINE_CODER,
model: DEFAULT_QWEN_MODEL,
})),
// Add switchModel to the mock object (not the type)
switchModel,
@ -231,17 +268,17 @@ describe('<ModelDialog />', () => {
);
const childOnSelect = mockedSelect.mock.calls[0][0].onSelect;
await childOnSelect(`${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`);
await childOnSelect(`${AuthType.QWEN_OAUTH}::${DEFAULT_QWEN_MODEL}`);
expect(switchModel).toHaveBeenCalledWith(
AuthType.QWEN_OAUTH,
MAINLINE_CODER,
DEFAULT_QWEN_MODEL,
{ requireCachedCredentials: true },
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
'model.name',
MAINLINE_CODER,
DEFAULT_QWEN_MODEL,
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
@ -251,11 +288,12 @@ describe('<ModelDialog />', () => {
expect(props.onClose).toHaveBeenCalledTimes(1);
});
it('does not pass onHighlight to DescriptiveRadioButtonSelect', () => {
it('passes onHighlight to DescriptiveRadioButtonSelect', () => {
renderComponent();
const childOnHighlight = mockedSelect.mock.calls[0][0].onHighlight;
expect(childOnHighlight).toBeUndefined();
expect(childOnHighlight).toBeDefined();
expect(typeof childOnHighlight).toBe('function');
});
it('calls onClose prop when "escape" key is pressed', () => {
@ -290,7 +328,7 @@ describe('<ModelDialog />', () => {
});
it('updates initialIndex when config context changes', () => {
const mockGetModel = vi.fn(() => MAINLINE_CODER);
const mockGetModel = vi.fn(() => DEFAULT_QWEN_MODEL);
const mockGetAuthType = vi.fn(() => 'qwen-oauth');
const mockSettings = {
isTrusted: true,
@ -305,8 +343,10 @@ describe('<ModelDialog />', () => {
{
getModel: mockGetModel,
getAuthType: mockGetAuthType,
getAvailableModelsForAuthType:
createMockGetAvailableModelsForAuthType(),
getAllConfiguredModels: vi.fn(() =>
AVAILABLE_MODELS_QWEN.map((m) => ({
getFilteredQwenModels().map((m) => ({
id: m.id,
label: m.label,
description: m.description || '',
@ -321,14 +361,16 @@ describe('<ModelDialog />', () => {
</SettingsContext.Provider>,
);
// DEFAULT_QWEN_MODEL (coder-model) is at index 0
expect(mockedSelect.mock.calls[0][0].initialIndex).toBe(0);
mockGetModel.mockReturnValue(MAINLINE_VLM);
mockGetModel.mockReturnValue(DEFAULT_QWEN_MODEL);
const newMockConfig = {
getModel: mockGetModel,
getAuthType: mockGetAuthType,
getAvailableModelsForAuthType: createMockGetAvailableModelsForAuthType(),
getAllConfiguredModels: vi.fn(() =>
AVAILABLE_MODELS_QWEN.map((m) => ({
getFilteredQwenModels().map((m) => ({
id: m.id,
label: m.label,
description: m.description || '',
@ -347,6 +389,11 @@ describe('<ModelDialog />', () => {
// Should be called at least twice: initial render + re-render after context change
expect(mockedSelect).toHaveBeenCalledTimes(2);
expect(mockedSelect.mock.calls[1][0].initialIndex).toBe(1);
// Calculate expected index for DEFAULT_QWEN_MODEL dynamically
const qwenModels = getFilteredQwenModels();
const expectedCoderIndex = qwenModels.findIndex(
(m) => m.id === DEFAULT_QWEN_MODEL,
);
expect(mockedSelect.mock.calls[1][0].initialIndex).toBe(expectedCoderIndex);
});
});

View file

@ -11,10 +11,10 @@ import {
AuthType,
ModelSlashCommandEvent,
logModelSlashCommand,
MAINLINE_CODER_MODEL,
type AvailableModel as CoreAvailableModel,
type ContentGeneratorConfig,
type ContentGeneratorConfigSource,
type ContentGeneratorConfigSources,
type InputModalities,
} from '@qwen-code/qwen-code-core';
import { useKeypress } from '../hooks/useKeypress.js';
import { theme } from '../semantic-colors.js';
@ -22,65 +22,28 @@ import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSel
import { ConfigContext } from '../contexts/ConfigContext.js';
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { MAINLINE_CODER } from '../models/availableModels.js';
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
import { t } from '../../i18n/index.js';
function formatModalities(modalities?: InputModalities): string {
if (!modalities) return t('text-only');
const parts: string[] = [];
if (modalities.image) parts.push(t('image'));
if (modalities.pdf) parts.push(t('pdf'));
if (modalities.audio) parts.push(t('audio'));
if (modalities.video) parts.push(t('video'));
if (parts.length === 0) return t('text-only');
return `${t('text')} · ${parts.join(' · ')}`;
}
interface ModelDialogProps {
onClose: () => void;
}
function formatSourceBadge(
source: ContentGeneratorConfigSource | undefined,
): string | undefined {
if (!source) return undefined;
switch (source.kind) {
case 'cli':
return source.detail ? `CLI ${source.detail}` : 'CLI';
case 'env':
return source.envKey ? `ENV ${source.envKey}` : 'ENV';
case 'settings':
return source.settingsPath
? `Settings ${source.settingsPath}`
: 'Settings';
case 'modelProviders': {
const suffix =
source.authType && source.modelId
? `${source.authType}:${source.modelId}`
: source.authType
? `${source.authType}`
: source.modelId
? `${source.modelId}`
: '';
return suffix ? `ModelProviders ${suffix}` : 'ModelProviders';
}
case 'default':
return source.detail ? `Default ${source.detail}` : 'Default';
case 'computed':
return source.detail ? `Computed ${source.detail}` : 'Computed';
case 'programmatic':
return source.detail ? `Programmatic ${source.detail}` : 'Programmatic';
case 'unknown':
default:
return undefined;
}
}
function readSourcesFromConfig(config: unknown): ContentGeneratorConfigSources {
if (!config) {
return {};
}
const maybe = config as {
getContentGeneratorConfigSources?: () => ContentGeneratorConfigSources;
};
return maybe.getContentGeneratorConfigSources?.() ?? {};
}
function maskApiKey(apiKey: string | undefined): string {
if (!apiKey) return '(not set)';
if (!apiKey) return `(${t('not set')})`;
const trimmed = apiKey.trim();
if (trimmed.length === 0) return '(not set)';
if (trimmed.length === 0) return `(${t('not set')})`;
if (trimmed.length <= 6) return '***';
const head = trimmed.slice(0, 3);
const tail = trimmed.slice(-4);
@ -131,7 +94,7 @@ function handleModelSwitchSuccess({
{
type: 'info',
text:
`authType: ${effectiveAuthType ?? '(none)'}` +
`authType: ${effectiveAuthType ?? `(${t('none')})`}` +
`\n` +
`Using ${isRuntime ? 'runtime ' : ''}model: ${effectiveModelId}` +
`\n` +
@ -143,35 +106,26 @@ function handleModelSwitchSuccess({
);
}
function ConfigRow({
function formatContextWindow(size?: number): string {
if (!size) return `(${t('unknown')})`;
return `${size.toLocaleString('en-US')} tokens`;
}
function DetailRow({
label,
value,
badge,
}: {
label: string;
value: React.ReactNode;
badge?: string;
}): React.JSX.Element {
return (
<Box flexDirection="column">
<Box>
<Box minWidth={12} flexShrink={0}>
<Text color={theme.text.secondary}>{label}:</Text>
</Box>
<Box flexGrow={1} flexDirection="row" flexWrap="wrap">
<Text>{value}</Text>
</Box>
<Box>
<Box minWidth={16} flexShrink={0}>
<Text color={theme.text.secondary}>{label}:</Text>
</Box>
<Box flexGrow={1} flexDirection="row" flexWrap="wrap">
<Text>{value}</Text>
</Box>
{badge ? (
<Box>
<Box minWidth={12} flexShrink={0}>
<Text> </Text>
</Box>
<Box flexGrow={1}>
<Text color={theme.text.secondary}>{badge}</Text>
</Box>
</Box>
) : null}
</Box>
);
}
@ -183,13 +137,9 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
// Local error state for displaying errors within the dialog
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [highlightedValue, setHighlightedValue] = useState<string | null>(null);
const authType = config?.getAuthType();
const effectiveConfig =
(config?.getContentGeneratorConfig?.() as
| ContentGeneratorConfig
| undefined) ?? undefined;
const sources = readSourcesFromConfig(config);
const availableModelEntries = useMemo(() => {
const allModels = config ? config.getAllConfiguredModels() : [];
@ -293,7 +243,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
[availableModelEntries],
);
const preferredModelId = config?.getModel() || MAINLINE_CODER;
const preferredModelId = config?.getModel() || MAINLINE_CODER_MODEL;
// Check if current model is a runtime model
// Runtime snapshot ID is already in $runtime|${authType}|${modelId} format
const activeRuntimeSnapshot = config?.getActiveRuntimeModelSnapshot?.();
@ -319,6 +269,20 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
return index === -1 ? 0 : index;
}, [MODEL_OPTIONS, preferredKey]);
const handleHighlight = useCallback((value: string) => {
setHighlightedValue(value);
}, []);
const highlightedEntry = useMemo(() => {
const key = highlightedValue ?? preferredKey;
return availableModelEntries.find(
({ authType: t2, model, isRuntime, snapshotId }) => {
const v = isRuntime && snapshotId ? snapshotId : `${t2}::${model.id}`;
return v === key;
},
);
}, [highlightedValue, preferredKey, availableModelEntries]);
const handleSelect = useCallback(
async (selected: string) => {
setErrorMessage(null);
@ -413,35 +377,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
>
<Text bold>{t('Select Model')}</Text>
<Box marginTop={1} flexDirection="column">
<Text color={theme.text.secondary}>
{t('Current (effective) configuration')}
</Text>
<Box flexDirection="column" marginTop={1}>
<ConfigRow label="AuthType" value={authType} />
<ConfigRow
label="Model"
value={effectiveConfig?.model ?? config?.getModel?.() ?? ''}
badge={formatSourceBadge(sources['model'])}
/>
{authType !== AuthType.QWEN_OAUTH && (
<>
<ConfigRow
label="Base URL"
value={effectiveConfig?.baseUrl ?? t('(default)')}
badge={formatSourceBadge(sources['baseUrl'])}
/>
<ConfigRow
label="API Key"
value={effectiveConfig?.apiKey ? t('(set)') : t('(not set)')}
badge={formatSourceBadge(sources['apiKey'])}
/>
</>
)}
</Box>
</Box>
{!hasModels ? (
<Box marginTop={1} flexDirection="column">
<Text color={theme.status.warning}>
@ -465,12 +400,48 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
<DescriptiveRadioButtonSelect
items={MODEL_OPTIONS}
onSelect={handleSelect}
onHighlight={handleHighlight}
initialIndex={initialIndex}
showNumbers={true}
/>
</Box>
)}
{highlightedEntry && (
<Box marginTop={1} flexDirection="column">
<Box
borderStyle="single"
borderTop
borderBottom={false}
borderLeft={false}
borderRight={false}
borderColor={theme.border.default}
/>
<DetailRow
label={t('Modality')}
value={formatModalities(highlightedEntry.model.modalities)}
/>
<DetailRow
label={t('Context Window')}
value={formatContextWindow(
highlightedEntry.model.contextWindowSize,
)}
/>
{highlightedEntry.authType !== AuthType.QWEN_OAUTH && (
<>
<DetailRow
label="Base URL"
value={highlightedEntry.model.baseUrl ?? t('(default)')}
/>
<DetailRow
label="API Key"
value={highlightedEntry.model.envKey ?? t('(not set)')}
/>
</>
)}
</Box>
)}
{errorMessage && (
<Box marginTop={1} flexDirection="column" paddingX={1}>
<Text color={theme.status.error} wrap="wrap">
@ -480,7 +451,9 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
)}
<Box marginTop={1} flexDirection="column">
<Text color={theme.text.secondary}>{t('(Press Esc to close)')}</Text>
<Text color={theme.text.secondary}>
{t('Enter to select, ↑↓ to navigate, Esc to close')}
</Text>
</Box>
</Box>
);

View file

@ -1,184 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ModelSwitchDialog, VisionSwitchOutcome } from './ModelSwitchDialog.js';
// Mock the useKeypress hook
const mockUseKeypress = vi.hoisted(() => vi.fn());
vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: mockUseKeypress,
}));
// Mock the RadioButtonSelect component
const mockRadioButtonSelect = vi.hoisted(() => vi.fn());
vi.mock('./shared/RadioButtonSelect.js', () => ({
RadioButtonSelect: mockRadioButtonSelect,
}));
describe('ModelSwitchDialog', () => {
const mockOnSelect = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
// Mock RadioButtonSelect to return a simple div
mockRadioButtonSelect.mockReturnValue(
React.createElement('div', { 'data-testid': 'radio-select' }),
);
});
it('should setup RadioButtonSelect with correct options', () => {
render(<ModelSwitchDialog onSelect={mockOnSelect} />);
const expectedItems = [
{
key: 'switch-once',
label: 'Switch for this request only',
value: VisionSwitchOutcome.SwitchOnce,
},
{
key: 'switch-session',
label: 'Switch session to vision model',
value: VisionSwitchOutcome.SwitchSessionToVL,
},
{
key: 'continue',
label: 'Continue with current model',
value: VisionSwitchOutcome.ContinueWithCurrentModel,
},
];
const callArgs = mockRadioButtonSelect.mock.calls[0][0];
expect(callArgs.items).toEqual(expectedItems);
expect(callArgs.initialIndex).toBe(0);
expect(callArgs.isFocused).toBe(true);
});
it('should call onSelect when an option is selected', () => {
render(<ModelSwitchDialog onSelect={mockOnSelect} />);
const callArgs = mockRadioButtonSelect.mock.calls[0][0];
expect(typeof callArgs.onSelect).toBe('function');
// Simulate selection of "Switch for this request only"
const onSelectCallback = mockRadioButtonSelect.mock.calls[0][0].onSelect;
onSelectCallback(VisionSwitchOutcome.SwitchOnce);
expect(mockOnSelect).toHaveBeenCalledWith(VisionSwitchOutcome.SwitchOnce);
});
it('should call onSelect with SwitchSessionToVL when second option is selected', () => {
render(<ModelSwitchDialog onSelect={mockOnSelect} />);
const onSelectCallback = mockRadioButtonSelect.mock.calls[0][0].onSelect;
onSelectCallback(VisionSwitchOutcome.SwitchSessionToVL);
expect(mockOnSelect).toHaveBeenCalledWith(
VisionSwitchOutcome.SwitchSessionToVL,
);
});
it('should call onSelect with ContinueWithCurrentModel when third option is selected', () => {
render(<ModelSwitchDialog onSelect={mockOnSelect} />);
const onSelectCallback = mockRadioButtonSelect.mock.calls[0][0].onSelect;
onSelectCallback(VisionSwitchOutcome.ContinueWithCurrentModel);
expect(mockOnSelect).toHaveBeenCalledWith(
VisionSwitchOutcome.ContinueWithCurrentModel,
);
});
it('should setup escape key handler to call onSelect with ContinueWithCurrentModel', () => {
render(<ModelSwitchDialog onSelect={mockOnSelect} />);
expect(mockUseKeypress).toHaveBeenCalledWith(expect.any(Function), {
isActive: true,
});
// Simulate escape key press
const keypressHandler = mockUseKeypress.mock.calls[0][0];
keypressHandler({ name: 'escape' });
expect(mockOnSelect).toHaveBeenCalledWith(
VisionSwitchOutcome.ContinueWithCurrentModel,
);
});
it('should not call onSelect for non-escape keys', () => {
render(<ModelSwitchDialog onSelect={mockOnSelect} />);
const keypressHandler = mockUseKeypress.mock.calls[0][0];
keypressHandler({ name: 'enter' });
expect(mockOnSelect).not.toHaveBeenCalled();
});
it('should set initial index to 0 (first option)', () => {
render(<ModelSwitchDialog onSelect={mockOnSelect} />);
const callArgs = mockRadioButtonSelect.mock.calls[0][0];
expect(callArgs.initialIndex).toBe(0);
});
describe('VisionSwitchOutcome enum', () => {
it('should have correct enum values', () => {
expect(VisionSwitchOutcome.SwitchOnce).toBe('once');
expect(VisionSwitchOutcome.SwitchSessionToVL).toBe('session');
expect(VisionSwitchOutcome.ContinueWithCurrentModel).toBe('persist');
});
});
it('should handle multiple onSelect calls correctly', () => {
render(<ModelSwitchDialog onSelect={mockOnSelect} />);
const onSelectCallback = mockRadioButtonSelect.mock.calls[0][0].onSelect;
// Call multiple times
onSelectCallback(VisionSwitchOutcome.SwitchOnce);
onSelectCallback(VisionSwitchOutcome.SwitchSessionToVL);
onSelectCallback(VisionSwitchOutcome.ContinueWithCurrentModel);
expect(mockOnSelect).toHaveBeenCalledTimes(3);
expect(mockOnSelect).toHaveBeenNthCalledWith(
1,
VisionSwitchOutcome.SwitchOnce,
);
expect(mockOnSelect).toHaveBeenNthCalledWith(
2,
VisionSwitchOutcome.SwitchSessionToVL,
);
expect(mockOnSelect).toHaveBeenNthCalledWith(
3,
VisionSwitchOutcome.ContinueWithCurrentModel,
);
});
it('should pass isFocused prop to RadioButtonSelect', () => {
render(<ModelSwitchDialog onSelect={mockOnSelect} />);
const callArgs = mockRadioButtonSelect.mock.calls[0][0];
expect(callArgs.isFocused).toBe(true);
});
it('should handle escape key multiple times', () => {
render(<ModelSwitchDialog onSelect={mockOnSelect} />);
const keypressHandler = mockUseKeypress.mock.calls[0][0];
// Call escape multiple times
keypressHandler({ name: 'escape' });
keypressHandler({ name: 'escape' });
expect(mockOnSelect).toHaveBeenCalledTimes(2);
expect(mockOnSelect).toHaveBeenCalledWith(
VisionSwitchOutcome.ContinueWithCurrentModel,
);
});
});

View file

@ -1,92 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import {
RadioButtonSelect,
type RadioSelectItem,
} from './shared/RadioButtonSelect.js';
import { useKeypress } from '../hooks/useKeypress.js';
export enum VisionSwitchOutcome {
SwitchOnce = 'once',
SwitchSessionToVL = 'session',
ContinueWithCurrentModel = 'persist',
}
export interface ModelSwitchDialogProps {
onSelect: (outcome: VisionSwitchOutcome) => void;
}
export const ModelSwitchDialog: React.FC<ModelSwitchDialogProps> = ({
onSelect,
}) => {
useKeypress(
(key) => {
if (key.name === 'escape') {
onSelect(VisionSwitchOutcome.ContinueWithCurrentModel);
}
},
{ isActive: true },
);
const options: Array<RadioSelectItem<VisionSwitchOutcome>> = [
{
key: 'switch-once',
label: 'Switch for this request only',
value: VisionSwitchOutcome.SwitchOnce,
},
{
key: 'switch-session',
label: 'Switch session to vision model',
value: VisionSwitchOutcome.SwitchSessionToVL,
},
{
key: 'continue',
label: 'Continue with current model',
value: VisionSwitchOutcome.ContinueWithCurrentModel,
},
];
const handleSelect = (outcome: VisionSwitchOutcome) => {
onSelect(outcome);
};
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={Colors.AccentYellow}
padding={1}
width="100%"
marginLeft={1}
>
<Box flexDirection="column" marginBottom={1}>
<Text bold>Vision Model Switch Required</Text>
<Text>
Your message contains an image, but the current model doesn&apos;t
support vision.
</Text>
<Text>How would you like to proceed?</Text>
</Box>
<Box marginBottom={1}>
<RadioButtonSelect
items={options}
initialIndex={0}
onSelect={handleSelect}
isFocused
/>
</Box>
<Box>
<Text color={Colors.Gray}>Press Enter to select, Esc to cancel</Text>
</Box>
</Box>
);
};

View file

@ -0,0 +1,62 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { selectWeightedTip } from './Tips.js';
describe('selectWeightedTip', () => {
const tips = [
{ text: 'tip-a', weight: 1 },
{ text: 'tip-b', weight: 3 },
{ text: 'tip-c', weight: 1 },
];
it('returns a valid tip text', () => {
const result = selectWeightedTip(tips);
expect(['tip-a', 'tip-b', 'tip-c']).toContain(result);
});
it('selects the first tip when random is near zero', () => {
vi.spyOn(Math, 'random').mockReturnValue(0);
expect(selectWeightedTip(tips)).toBe('tip-a');
vi.restoreAllMocks();
});
it('selects the weighted tip when random falls in its range', () => {
// Total weight = 5. tip-a covers [0,1), tip-b covers [1,4), tip-c covers [4,5)
// Math.random() * 5 = 2.0 falls in tip-b's range
vi.spyOn(Math, 'random').mockReturnValue(0.4); // 0.4 * 5 = 2.0
expect(selectWeightedTip(tips)).toBe('tip-b');
vi.restoreAllMocks();
});
it('selects the last tip when random is near max', () => {
vi.spyOn(Math, 'random').mockReturnValue(0.99);
expect(selectWeightedTip(tips)).toBe('tip-c');
vi.restoreAllMocks();
});
it('respects weight distribution over many samples', () => {
const counts: Record<string, number> = {
'tip-a': 0,
'tip-b': 0,
'tip-c': 0,
};
const iterations = 10000;
for (let i = 0; i < iterations; i++) {
const result = selectWeightedTip(tips);
counts[result]!++;
}
// tip-b (weight 3) should appear roughly 3x as often as tip-a or tip-c (weight 1)
// With 10k iterations, we expect: tip-a ~2000, tip-b ~6000, tip-c ~2000
expect(counts['tip-b']!).toBeGreaterThan(counts['tip-a']! * 2);
expect(counts['tip-b']!).toBeGreaterThan(counts['tip-c']! * 2);
});
it('handles single tip', () => {
expect(selectWeightedTip([{ text: 'only', weight: 1 }])).toBe('only');
});
});

View file

@ -9,7 +9,9 @@ import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { t } from '../../i18n/index.js';
const startupTips = [
type Tip = string | { text: string; weight: number };
const startupTips: Tip[] = [
'Use /compress when the conversation gets long to summarize history and free up context.',
'Start a fresh idea with /clear or /new; the previous session stays available in history.',
'Use /bug to submit issues to the maintainers when something goes off.',
@ -20,13 +22,34 @@ const startupTips = [
process.platform === 'win32'
? 'You can switch permission mode quickly with Tab or /approval-mode.'
: 'You can switch permission mode quickly with Shift+Tab or /approval-mode.',
] as const;
{
text: 'Try /insight to generate personalized insights from your chat history.',
weight: 3,
},
];
function tipText(tip: Tip): string {
return typeof tip === 'string' ? tip : tip.text;
}
function tipWeight(tip: Tip): number {
return typeof tip === 'string' ? 1 : tip.weight;
}
export function selectWeightedTip(tips: Tip[]): string {
const totalWeight = tips.reduce((sum, tip) => sum + tipWeight(tip), 0);
let random = Math.random() * totalWeight;
for (const tip of tips) {
random -= tipWeight(tip);
if (random <= 0) {
return tipText(tip);
}
}
return tipText(tips[tips.length - 1]!);
}
export const Tips: React.FC = () => {
const selectedTip = useMemo(() => {
const randomIndex = Math.floor(Math.random() * startupTips.length);
return startupTips[randomIndex];
}, []);
const selectedTip = useMemo(() => selectWeightedTip(startupTips), []);
return (
<Box marginLeft={2} marginRight={2}>

View file

@ -1,7 +1,8 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<HistoryItemDisplay /> > should render a full gemini item when using availableTerminalHeightGemini 1`] = `
" ✦ Example code block:
"
✦ Example code block:
1 Line 1
2 Line 2
3 Line 3
@ -109,7 +110,8 @@ exports[`<HistoryItemDisplay /> > should render a full gemini_content item when
`;
exports[`<HistoryItemDisplay /> > should render a truncated gemini item 1`] = `
" ✦ Example code block:
"
✦ Example code block:
... first 41 lines hidden ...
42 Line 42
43 Line 43

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,31 +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;
}
export const ErrorMessage: React.FC<ErrorMessageProps> = ({ text }) => {
const prefix = '✕ ';
const prefixWidth = prefix.length;
return (
<Box flexDirection="row">
<Box width={prefixWidth}>
<Text color={theme.status.error}>{prefix}</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap" color={theme.status.error}>
{text}
</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} />
</Text>
</Box>
</Box>
);
};

View file

@ -0,0 +1,59 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import type { InsightProgressProps } from '../../types.js';
import Spinner from 'ink-spinner';
interface InsightProgressMessageProps {
progress: InsightProgressProps;
}
export const InsightProgressMessage: React.FC<InsightProgressMessageProps> = ({
progress,
}) => {
const { stage, progress: percent, isComplete, error } = progress;
const width = 30;
const completedWidth = Math.round((percent / 100) * width);
const remainingWidth = width - completedWidth;
const bar =
'█'.repeat(Math.max(0, completedWidth)) +
'░'.repeat(Math.max(0, remainingWidth));
if (error) {
return (
<Box flexDirection="column">
<Text color={theme.status.error}> {stage}</Text>
<Text color={theme.text.secondary}>{error}</Text>
</Box>
);
}
if (isComplete) {
return (
<Box flexDirection="row">
<Text color={theme.status.success}> {stage}</Text>
</Box>
);
}
return (
<Box flexDirection="row">
<Text color={theme.text.accent}>
<Spinner type="dots" />
</Text>
<Text> </Text>
<Text color={theme.text.secondary}>{bar} </Text>
<Text color={theme.text.accent}>
{stage}
{progress.detail ? ` (${progress.detail})` : ''}
</Text>
</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

@ -330,7 +330,7 @@ export const ToolConfirmationMessage: React.FC<
bodyContent = (
<Box flexDirection="column" paddingX={1} marginLeft={1}>
<Text color={theme.text.link}>
<RenderInline text={infoProps.prompt} />
<RenderInline text={infoProps.prompt} textColor={theme.text.link} />
</Text>
{displayUrls && infoProps.urls && infoProps.urls.length > 0 && (
<Box flexDirection="column" marginTop={1}>

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,32 +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';
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} />
</Text>
</Box>
</Box>
);
};

View file

@ -30,6 +30,8 @@ export interface BaseSelectionListProps<
showNumbers?: boolean;
showScrollArrows?: boolean;
maxItemsToShow?: number;
/** Gap (in rows) between each item. */
itemGap?: number;
renderItem: (item: TItem, context: RenderItemContext) => React.ReactNode;
}
@ -59,6 +61,7 @@ export function BaseSelectionList<
showNumbers = true,
showScrollArrows = false,
maxItemsToShow = 10,
itemGap = 0,
renderItem,
}: BaseSelectionListProps<T, TItem>): React.JSX.Element {
const { activeIndex } = useSelectionList({
@ -89,7 +92,7 @@ export function BaseSelectionList<
const numberColumnWidth = String(items.length).length;
return (
<Box flexDirection="column">
<Box flexDirection="column" gap={itemGap}>
{/* Use conditional coloring instead of conditional rendering */}
{showScrollArrows && (
<Text

View file

@ -12,7 +12,7 @@ import type { SelectionListItem } from '../../hooks/useSelectionList.js';
export interface DescriptiveRadioSelectItem<T> extends SelectionListItem<T> {
title: React.ReactNode;
description: string;
description: React.ReactNode;
}
export interface DescriptiveRadioButtonSelectProps<T> {
@ -32,6 +32,8 @@ export interface DescriptiveRadioButtonSelectProps<T> {
showScrollArrows?: boolean;
/** The maximum number of items to show at once. */
maxItemsToShow?: number;
/** Gap (in rows) between each item. */
itemGap?: number;
}
/**
@ -48,6 +50,7 @@ export function DescriptiveRadioButtonSelect<T>({
showNumbers = false,
showScrollArrows = false,
maxItemsToShow = 10,
itemGap = 0,
}: DescriptiveRadioButtonSelectProps<T>): React.JSX.Element {
return (
<BaseSelectionList<T, DescriptiveRadioSelectItem<T>>
@ -59,6 +62,7 @@ export function DescriptiveRadioButtonSelect<T>({
showNumbers={showNumbers}
showScrollArrows={showScrollArrows}
maxItemsToShow={maxItemsToShow}
itemGap={itemGap}
renderItem={(item, { titleColor }) => (
<Box flexDirection="column" key={item.key}>
<Text color={titleColor}>{item.title}</Text>