mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-29 20:20:57 +00:00
Merge branch 'main' into feat/mcp-tui
This commit is contained in:
commit
7b227a7eb5
298 changed files with 28262 additions and 6219 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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('╯');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 '';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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'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>
|
||||
);
|
||||
};
|
||||
62
packages/cli/src/ui/components/Tips.test.ts
Normal file
62
packages/cli/src/ui/components/Tips.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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,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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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} />
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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,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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue