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

@ -209,6 +209,7 @@ describe('AppContainer State Management', () => {
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
retryLastPrompt: vi.fn(),
});
mockedUseVim.mockReturnValue({ handleInput: vi.fn() });
mockedUseFolderTrust.mockReturnValue({
@ -607,6 +608,7 @@ describe('AppContainer State Management', () => {
pendingHistoryItems: [],
thought: { subject: thoughtSubject },
cancelOngoingRequest: vi.fn(),
retryLastPrompt: vi.fn(),
});
// Act: Render the container
@ -652,6 +654,7 @@ describe('AppContainer State Management', () => {
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
retryLastPrompt: vi.fn(),
});
// Act: Render the container
@ -698,6 +701,7 @@ describe('AppContainer State Management', () => {
pendingHistoryItems: [],
thought: { subject: thoughtSubject },
cancelOngoingRequest: vi.fn(),
retryLastPrompt: vi.fn(),
});
// Act: Render the container
@ -744,6 +748,7 @@ describe('AppContainer State Management', () => {
pendingHistoryItems: [],
thought: { subject: shortTitle },
cancelOngoingRequest: vi.fn(),
retryLastPrompt: vi.fn(),
});
// Act: Render the container
@ -794,6 +799,7 @@ describe('AppContainer State Management', () => {
pendingHistoryItems: [],
thought: { subject: title },
cancelOngoingRequest: vi.fn(),
retryLastPrompt: vi.fn(),
});
// Act: Render the container
@ -841,6 +847,7 @@ describe('AppContainer State Management', () => {
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
retryLastPrompt: vi.fn(),
});
// Act: Render the container
@ -882,6 +889,7 @@ describe('AppContainer State Management', () => {
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
retryLastPrompt: vi.fn(),
activePtyId: 'some-id',
});
@ -1013,6 +1021,7 @@ describe('AppContainer State Management', () => {
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: mockCancelOngoingRequest,
retryLastPrompt: vi.fn(),
});
const mockHandleSlashCommand = vi.fn();

View file

@ -100,8 +100,6 @@ import { t } from '../i18n/index.js';
import { useWelcomeBack } from './hooks/useWelcomeBack.js';
import { useDialogClose } from './hooks/useDialogClose.js';
import { useInitializationAuthError } from './hooks/useInitializationAuthError.js';
import { type VisionSwitchOutcome } from './components/ModelSwitchDialog.js';
import { processVisionSwitchOutcome } from './hooks/useVisionAutoSwitch.js';
import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js';
import { useAgentsManagerDialog } from './hooks/useAgentsManagerDialog.js';
import { useMcpDialog } from './hooks/useMcpDialog.js';
@ -498,18 +496,6 @@ export const AppContainer = (props: AppContainerProps) => {
} = useAgentsManagerDialog();
const { isMcpDialogOpen, openMcpDialog, closeMcpDialog } = useMcpDialog();
// Vision model auto-switch dialog state (must be before slashCommandActions)
const [isVisionSwitchDialogOpen, setIsVisionSwitchDialogOpen] =
useState(false);
const [visionSwitchResolver, setVisionSwitchResolver] = useState<{
resolve: (result: {
modelOverride?: string;
persistSessionModel?: string;
showGuidance?: boolean;
}) => void;
reject: () => void;
} | null>(null);
const slashCommandActions = useMemo(
() => ({
openAuthDialog,
@ -567,6 +553,7 @@ export const AppContainer = (props: AppContainerProps) => {
historyManager.loadHistory,
refreshStatic,
toggleVimEnabled,
isProcessing,
setIsProcessing,
setGeminiMdFileCount,
slashCommandActions,
@ -575,32 +562,6 @@ export const AppContainer = (props: AppContainerProps) => {
logger,
);
// Vision switch handlers
const handleVisionSwitchRequired = useCallback(
async (_query: unknown) =>
new Promise<{
modelOverride?: string;
persistSessionModel?: string;
showGuidance?: boolean;
}>((resolve, reject) => {
setVisionSwitchResolver({ resolve, reject });
setIsVisionSwitchDialogOpen(true);
}),
[],
);
const handleVisionSwitchSelect = useCallback(
(outcome: VisionSwitchOutcome) => {
setIsVisionSwitchDialogOpen(false);
if (visionSwitchResolver) {
const result = processVisionSwitchOutcome(outcome);
visionSwitchResolver.resolve(result);
setVisionSwitchResolver(null);
}
},
[visionSwitchResolver],
);
// onDebugMessage should log to debug logfile, not update footer debugMessage
const onDebugMessage = useCallback(
(message: string) => {
@ -672,6 +633,7 @@ export const AppContainer = (props: AppContainerProps) => {
pendingHistoryItems: pendingGeminiHistoryItems,
thought,
cancelOngoingRequest,
retryLastPrompt,
handleApprovalModeChange,
activePtyId,
loopDetectionConfirmationRequest,
@ -691,11 +653,9 @@ export const AppContainer = (props: AppContainerProps) => {
setModelSwitchedFromQuotaError,
refreshStatic,
() => cancelHandlerRef.current(),
settings.merged.experimental?.visionModelPreview ?? false, // visionModelPreviewEnabled
setEmbeddedShellFocused,
terminalWidth,
terminalHeight,
handleVisionSwitchRequired, // onVisionSwitchRequired
);
// Track whether suggestions are visible for Tab key handling
@ -850,7 +810,6 @@ export const AppContainer = (props: AppContainerProps) => {
!isThemeDialogOpen &&
!isEditorDialogOpen &&
!showWelcomeBackDialog &&
!isVisionSwitchDialogOpen &&
welcomeBackChoice !== 'restart' &&
geminiClient?.isInitialized?.()
) {
@ -866,7 +825,6 @@ export const AppContainer = (props: AppContainerProps) => {
isThemeDialogOpen,
isEditorDialogOpen,
showWelcomeBackDialog,
isVisionSwitchDialogOpen,
welcomeBackChoice,
geminiClient,
]);
@ -1338,7 +1296,6 @@ export const AppContainer = (props: AppContainerProps) => {
isThemeDialogOpen ||
isSettingsDialogOpen ||
isModelDialogOpen ||
isVisionSwitchDialogOpen ||
isPermissionsDialogOpen ||
isAuthDialogOpen ||
isAuthenticating ||
@ -1451,8 +1408,6 @@ export const AppContainer = (props: AppContainerProps) => {
extensionsUpdateState,
activePtyId,
embeddedShellFocused,
// Vision switch dialog
isVisionSwitchDialogOpen,
// Welcome back dialog
showWelcomeBackDialog,
welcomeBackInfo,
@ -1545,8 +1500,6 @@ export const AppContainer = (props: AppContainerProps) => {
activePtyId,
historyManager,
embeddedShellFocused,
// Vision switch dialog
isVisionSwitchDialogOpen,
// Welcome back dialog
showWelcomeBackDialog,
welcomeBackInfo,
@ -1589,9 +1542,8 @@ export const AppContainer = (props: AppContainerProps) => {
onSuggestionsVisibilityChange: setHasSuggestionsVisible,
refreshStatic,
handleFinalSubmit,
handleRetryLastPrompt: retryLastPrompt,
handleClearScreen,
// Vision switch dialog
handleVisionSwitchSelect,
// Welcome back dialog
handleWelcomeBackSelection,
handleWelcomeBackClose,
@ -1636,8 +1588,8 @@ export const AppContainer = (props: AppContainerProps) => {
handleEscapePromptChange,
refreshStatic,
handleFinalSubmit,
retryLastPrompt,
handleClearScreen,
handleVisionSwitchSelect,
handleWelcomeBackSelection,
handleWelcomeBackClose,
// Subagent dialogs

View file

@ -32,6 +32,7 @@ const createMockUIActions = (overrides: Partial<UIActions> = {}): UIActions => {
// AuthDialog only uses handleAuthSelect
const baseActions = {
handleAuthSelect: vi.fn(),
handleRetryLastPrompt: vi.fn(),
} as Partial<UIActions>;
return {
@ -169,9 +170,9 @@ describe('AuthDialog', () => {
const { lastFrame } = renderAuthDialog(settings);
// Since the auth dialog shows API-KEY option now,
// Since the auth dialog shows API Key option now,
// it won't show GEMINI_API_KEY messages
expect(lastFrame()).toContain('API-KEY');
expect(lastFrame()).toContain('API Key');
});
it('should not show the GEMINI_API_KEY message if QWEN_DEFAULT_AUTH_TYPE is set to something else', () => {
@ -257,9 +258,9 @@ describe('AuthDialog', () => {
const { lastFrame } = renderAuthDialog(settings);
// Since the auth dialog shows API-KEY option now,
// Since the auth dialog shows API Key option now,
// it won't show GEMINI_API_KEY messages
expect(lastFrame()).toContain('API-KEY');
expect(lastFrame()).toContain('API Key');
});
});
@ -305,7 +306,7 @@ describe('AuthDialog', () => {
const { lastFrame } = renderAuthDialog(settings);
// QWEN_OAUTH is the first option, so it should be selected
expect(lastFrame()).toContain('● 1. Qwen OAuth');
expect(lastFrame()).toContain('Qwen OAuth');
});
it('should fall back to default if QWEN_DEFAULT_AUTH_TYPE is not set', () => {
@ -345,7 +346,7 @@ describe('AuthDialog', () => {
const { lastFrame } = renderAuthDialog(settings);
// Default is Qwen OAuth (first option)
expect(lastFrame()).toContain('● 1. Qwen OAuth');
expect(lastFrame()).toContain('Qwen OAuth');
});
it('should show an error and fall back to default if QWEN_DEFAULT_AUTH_TYPE is invalid', () => {
@ -388,7 +389,7 @@ describe('AuthDialog', () => {
// Since the auth dialog doesn't show QWEN_DEFAULT_AUTH_TYPE errors anymore,
// it will just show the default Qwen OAuth option
expect(lastFrame()).toContain('● 1. Qwen OAuth');
expect(lastFrame()).toContain('Qwen OAuth');
});
});

View file

@ -11,16 +11,19 @@ import { Box, Text } from 'ink';
import Link from 'ink-link';
import { theme } from '../semantic-colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
import { DescriptiveRadioButtonSelect } from '../components/shared/DescriptiveRadioButtonSelect.js';
import { ApiKeyInput } from '../components/ApiKeyInput.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { t } from '../../i18n/index.js';
import { CodingPlanRegion } from '../../constants/codingPlan.js';
import {
CodingPlanRegion,
isCodingPlanConfig,
} from '../../constants/codingPlan.js';
const MODEL_PROVIDERS_DOCUMENTATION_URL =
'https://qwenlm.github.io/qwen-code-docs/en/users/configuration/settings/#modelproviders';
'https://qwenlm.github.io/qwen-code-docs/en/users/configuration/model-providers/';
function parseDefaultAuthType(
defaultAuthType: string | undefined,
@ -34,11 +37,11 @@ function parseDefaultAuthType(
return null;
}
// Sub-mode types for API-KEY authentication
type ApiKeySubMode = 'coding-plan' | 'coding-plan-intl' | 'custom';
// Main menu option type
type MainOption = typeof AuthType.QWEN_OAUTH | 'CODING_PLAN' | 'API_KEY';
// View level for navigation
type ViewLevel = 'main' | 'api-key-sub' | 'api-key-input' | 'custom-info';
type ViewLevel = 'main' | 'region-select' | 'api-key-input' | 'custom-info';
export function AuthDialog(): React.JSX.Element {
const { pendingAuthType, authError } = useUIState();
@ -50,58 +53,107 @@ export function AuthDialog(): React.JSX.Element {
const config = useConfig();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const [viewLevel, setViewLevel] = useState<ViewLevel>('main');
const [apiKeySubModeIndex, setApiKeySubModeIndex] = useState<number>(0);
const [regionIndex, setRegionIndex] = useState<number>(0);
const [region, setRegion] = useState<CodingPlanRegion>(
CodingPlanRegion.CHINA,
);
// Main authentication entries
// Main authentication entries (flat three-option layout)
const mainItems = [
{
key: AuthType.QWEN_OAUTH,
title: t('Qwen OAuth'),
label: t('Qwen OAuth'),
value: AuthType.QWEN_OAUTH,
description: t(
'Free \u00B7 Up to 1,000 requests/day \u00B7 Qwen latest models',
),
value: AuthType.QWEN_OAUTH as MainOption,
},
{
key: 'API-KEY',
label: t('API-KEY'),
value: 'API-KEY' as const,
key: 'CODING_PLAN',
title: t('Alibaba Cloud Coding Plan'),
label: t('Alibaba Cloud Coding Plan'),
description: t(
'Paid \u00B7 Up to 6,000 requests/5 hrs \u00B7 All Alibaba Cloud Coding Plan Models',
),
value: 'CODING_PLAN' as MainOption,
},
{
key: 'API_KEY',
title: t('API Key'),
label: t('API Key'),
description: t('Bring your own API key'),
value: 'API_KEY' as MainOption,
},
];
// API-KEY sub-mode entries
const apiKeySubItems = [
// Region selection entries (shown after selecting Alibaba Cloud Coding Plan)
const regionItems = [
{
key: 'coding-plan',
label: t('Coding Plan (Bailian, China)'),
value: 'coding-plan' as ApiKeySubMode,
key: 'china',
title: '阿里云百炼 (aliyun.com)',
label: '阿里云百炼 (aliyun.com)',
description: (
<Link
url="https://help.aliyun.com/zh/model-studio/coding-plan"
fallback={false}
>
<Text color={theme.text.secondary}>
https://help.aliyun.com/zh/model-studio/coding-plan
</Text>
</Link>
),
value: CodingPlanRegion.CHINA,
},
{
key: 'coding-plan-intl',
label: t('Coding Plan (Bailian, Global/Intl)'),
value: 'coding-plan-intl' as ApiKeySubMode,
},
{
key: 'custom',
label: t('Custom'),
value: 'custom' as ApiKeySubMode,
key: 'global',
title: 'Alibaba Cloud (alibabacloud.com)',
label: 'Alibaba Cloud (alibabacloud.com)',
description: (
<Link
url="https://www.alibabacloud.com/help/en/model-studio/coding-plan"
fallback={false}
>
<Text color={theme.text.secondary}>
https://www.alibabacloud.com/help/en/model-studio/coding-plan
</Text>
</Link>
),
value: CodingPlanRegion.GLOBAL,
},
];
// Map an AuthType to the corresponding main menu option.
// QWEN_OAUTH maps directly; any other auth type maps to CODING_PLAN only
// if the current config actually uses a Coding Plan baseUrl+envKey,
// otherwise it maps to API_KEY.
const contentGenConfig = config.getContentGeneratorConfig();
const isCurrentlyCodingPlan =
isCodingPlanConfig(
contentGenConfig?.baseUrl,
contentGenConfig?.apiKeyEnvKey,
) !== false;
const authTypeToMainOption = (authType: AuthType): MainOption => {
if (authType === AuthType.QWEN_OAUTH) return AuthType.QWEN_OAUTH;
if (authType === AuthType.USE_OPENAI && isCurrentlyCodingPlan)
return 'CODING_PLAN';
return 'API_KEY';
};
const initialAuthIndex = Math.max(
0,
mainItems.findIndex((item) => {
// Priority 1: pendingAuthType
if (pendingAuthType) {
return item.value === pendingAuthType;
return item.value === authTypeToMainOption(pendingAuthType);
}
// Priority 2: config.getAuthType() - the source of truth
const currentAuthType = config.getAuthType();
if (currentAuthType) {
return item.value === currentAuthType;
return item.value === authTypeToMainOption(currentAuthType);
}
// Priority 3: QWEN_DEFAULT_AUTH_TYPE env var
@ -109,7 +161,7 @@ export function AuthDialog(): React.JSX.Element {
process.env['QWEN_DEFAULT_AUTH_TYPE'],
);
if (defaultAuthType) {
return item.value === defaultAuthType;
return item.value === authTypeToMainOption(defaultAuthType);
}
// Priority 4: default to QWEN_OAUTH
@ -117,21 +169,19 @@ export function AuthDialog(): React.JSX.Element {
}),
);
const hasApiKey = Boolean(config.getContentGeneratorConfig()?.apiKey);
const currentSelectedAuthType =
selectedIndex !== null
? mainItems[selectedIndex]?.value
: mainItems[initialAuthIndex]?.value;
const handleMainSelect = async (
value: (typeof mainItems)[number]['value'],
) => {
const handleMainSelect = async (value: MainOption) => {
setErrorMessage(null);
onAuthError(null);
if (value === 'API-KEY') {
// Navigate to API-KEY sub-mode selection
setViewLevel('api-key-sub');
if (value === 'CODING_PLAN') {
// Navigate to region selection
setViewLevel('region-select');
return;
}
if (value === 'API_KEY') {
// Navigate directly to custom API key info
setViewLevel('custom-info');
return;
}
@ -139,19 +189,11 @@ export function AuthDialog(): React.JSX.Element {
await onAuthSelect(value);
};
const handleApiKeySubSelect = async (subMode: ApiKeySubMode) => {
const handleRegionSelect = async (selectedRegion: CodingPlanRegion) => {
setErrorMessage(null);
onAuthError(null);
if (subMode === 'coding-plan') {
setRegion(CodingPlanRegion.CHINA);
setViewLevel('api-key-input');
} else if (subMode === 'coding-plan-intl') {
setRegion(CodingPlanRegion.GLOBAL);
setViewLevel('api-key-input');
} else {
setViewLevel('custom-info');
}
setRegion(selectedRegion);
setViewLevel('api-key-input');
};
const handleApiKeyInputSubmit = async (apiKey: string) => {
@ -170,12 +212,10 @@ export function AuthDialog(): React.JSX.Element {
setErrorMessage(null);
onAuthError(null);
if (viewLevel === 'api-key-sub') {
if (viewLevel === 'region-select' || viewLevel === 'custom-info') {
setViewLevel('main');
// Reset selectedIndex to ensure UI syncs with initialAuthIndex
setSelectedIndex(null);
} else if (viewLevel === 'api-key-input' || viewLevel === 'custom-info') {
setViewLevel('api-key-sub');
} else if (viewLevel === 'api-key-input') {
setViewLevel('region-select');
}
};
@ -183,7 +223,7 @@ export function AuthDialog(): React.JSX.Element {
(key) => {
if (key.name === 'escape') {
// Handle Escape based on current view level
if (viewLevel === 'api-key-sub') {
if (viewLevel === 'region-select') {
handleGoBack();
return;
}
@ -215,62 +255,39 @@ export function AuthDialog(): React.JSX.Element {
const renderMainView = () => (
<>
<Box marginTop={1}>
<Text>{t('How would you like to authenticate for this project?')}</Text>
</Box>
<Box marginTop={1}>
<RadioButtonSelect
<DescriptiveRadioButtonSelect
items={mainItems}
initialIndex={initialAuthIndex}
onSelect={handleMainSelect}
onHighlight={(value) => {
const index = mainItems.findIndex((item) => item.value === value);
setSelectedIndex(index);
}}
itemGap={1}
/>
</Box>
<Box marginTop={1} paddingLeft={2}>
<Text color={theme.text.secondary}>
{currentSelectedAuthType === AuthType.QWEN_OAUTH
? t('Login with QwenChat account to use daily free quota.')
: t('Use coding plan credentials or your own api-keys/providers.')}
</Text>
</Box>
</>
);
// Render API-KEY sub-mode selection
const renderApiKeySubView = () => (
// Render region selection for Alibaba Cloud Coding Plan
const renderRegionSelectView = () => (
<>
<Box marginTop={1}>
<Text>{t('Select API-KEY configuration mode:')}</Text>
</Box>
<Box marginTop={1}>
<RadioButtonSelect
items={apiKeySubItems}
initialIndex={apiKeySubModeIndex}
onSelect={handleApiKeySubSelect}
onHighlight={(value) => {
const index = apiKeySubItems.findIndex(
(item) => item.value === value,
);
setApiKeySubModeIndex(index);
}}
/>
</Box>
<Box marginTop={1} paddingLeft={2}>
<Text color={theme.text.secondary}>
{apiKeySubItems[apiKeySubModeIndex]?.value === 'custom'
? t(
'More instructions about configuring `modelProviders` manually.',
)
: t(
"Paste your api key of Bailian Coding Plan and you're all set!",
)}
<Text color={theme.text.primary}>
{t('Choose based on where your account is registered')}
</Text>
</Box>
<Box marginTop={1}>
<DescriptiveRadioButtonSelect
items={regionItems}
initialIndex={regionIndex}
onSelect={handleRegionSelect}
onHighlight={(value) => {
const index = regionItems.findIndex((item) => item.value === value);
setRegionIndex(index);
}}
itemGap={1}
/>
</Box>
<Box marginTop={1}>
<Text color={theme?.text?.secondary}>
{t('(Press Escape to go back)')}
{t('Enter to select, ↑↓ to navigate, Esc to go back')}
</Text>
</Box>
</>
@ -291,68 +308,22 @@ export function AuthDialog(): React.JSX.Element {
const renderCustomInfoView = () => (
<>
<Box marginTop={1}>
<Text bold>{t('Custom API-KEY Configuration')}</Text>
</Box>
<Box marginTop={1}>
<Text>
{t('For advanced users who want to configure models manually.')}
<Text color={theme.text.primary}>
{t('You can configure your API key and models in settings.json')}
</Text>
</Box>
<Box marginTop={1}>
<Text>{t('Please configure your models in settings.json:')}</Text>
</Box>
<Box marginTop={1} paddingLeft={2}>
<Text color={theme.status.warning}>
1. {t('Set API key via environment variable (e.g., OPENAI_API_KEY)')}
</Text>
</Box>
<Box marginTop={0} paddingLeft={2}>
<Text color={theme.status.warning}>
2.{' '}
{t(
"Add model configuration to modelProviders['openai'] (or other auth types)",
)}
</Text>
</Box>
<Box marginTop={0} paddingLeft={2}>
<Text color={theme.status.warning}>
3.{' '}
{t(
'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig',
)}
</Text>
</Box>
<Box marginTop={0} paddingLeft={2}>
<Text color={theme.status.warning}>
4.{' '}
{t(
'Use /model command to select your preferred model from the configured list',
)}
</Text>
</Box>
<Box marginTop={1}>
<Text color={theme?.text?.secondary}>
{t(
'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.',
)}
</Text>
</Box>
<Box marginTop={1}>
<Text color={theme?.text?.secondary} underline>
{t('More instructions please check:')}
</Text>
<Text>{t('Refer to the documentation for setup instructions')}</Text>
</Box>
<Box marginTop={0}>
<Link url={MODEL_PROVIDERS_DOCUMENTATION_URL} fallback={false}>
<Text color={theme.status.success} underline>
<Text color={theme.text.link}>
{MODEL_PROVIDERS_DOCUMENTATION_URL}
</Text>
</Link>
</Box>
<Box marginTop={1}>
<Text color={theme?.text?.secondary}>
{t('(Press Escape to go back)')}
</Text>
<Text color={theme.text.secondary}>{t('Esc to go back')}</Text>
</Box>
</>
);
@ -360,15 +331,15 @@ export function AuthDialog(): React.JSX.Element {
const getViewTitle = () => {
switch (viewLevel) {
case 'main':
return t('Get started');
case 'api-key-sub':
return t('API-KEY Configuration');
return t('Select Authentication Method');
case 'region-select':
return t('Select Region for Coding Plan');
case 'api-key-input':
return t('Coding Plan Setup');
return t('Enter Coding Plan API Key');
case 'custom-info':
return t('Custom Configuration');
default:
return t('Get started');
return t('Select Authentication Method');
}
};
@ -383,7 +354,7 @@ export function AuthDialog(): React.JSX.Element {
<Text bold>{getViewTitle()}</Text>
{viewLevel === 'main' && renderMainView()}
{viewLevel === 'api-key-sub' && renderApiKeySubView()}
{viewLevel === 'region-select' && renderRegionSelectView()}
{viewLevel === 'api-key-input' && renderApiKeyInputView()}
{viewLevel === 'custom-info' && renderCustomInfoView()}
@ -395,31 +366,28 @@ export function AuthDialog(): React.JSX.Element {
{viewLevel === 'main' && (
<>
<Box marginTop={1}>
<Text color={theme.text.accent}>
{t('(Use Enter to Set Auth)')}
{/* <Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('Enter to select, \u2191\u2193 to navigate, Esc to close')}
</Text>
</Box> */}
<Box marginY={1}>
<Text color={theme.border.default}>{'\u2500'.repeat(80)}</Text>
</Box>
<Box>
<Text color={theme.text.primary}>
{t('Terms of Services and Privacy Notice')}:
</Text>
</Box>
{hasApiKey && currentSelectedAuthType === AuthType.QWEN_OAUTH && (
<Box marginTop={1}>
<Text color={theme?.text?.secondary}>
{t(
'Note: Your existing API key in settings.json will not be cleared when using Qwen OAuth. You can switch back to OpenAI authentication later if needed.',
)}
<Box>
<Link
url="https://qwenlm.github.io/qwen-code-docs/en/users/support/tos-privacy/"
fallback={false}
>
<Text color={theme.text.secondary} underline>
https://qwenlm.github.io/qwen-code-docs/en/users/support/tos-privacy/
</Text>
</Box>
)}
<Box marginTop={1}>
<Text>
{t('Terms of Services and Privacy Notice for Qwen Code')}
</Text>
</Box>
<Box marginTop={1}>
<Text color={theme.text.link}>
{
'https://qwenlm.github.io/qwen-code-docs/en/users/support/tos-privacy/'
}
</Text>
</Link>
</Box>
</>
)}

View file

@ -35,6 +35,7 @@ import {
CodingPlanRegion,
CODING_PLAN_ENV_KEY,
} from '../../constants/codingPlan.js';
import { backupSettingsFile } from '../../utils/settingsUtils.js';
export type { QwenAuthState } from '../hooks/useQwenAuth.js';
@ -299,11 +300,15 @@ export const useAuthCommand = (
setAuthError(null);
// Get configuration based on region
const { template, version, regionName } = getCodingPlanConfig(region);
const { template, version } = getCodingPlanConfig(region);
// Get persist scope
const persistScope = getPersistScopeForModelSelection(settings);
// Backup settings file before modification
const settingsFile = settings.forScope(persistScope);
backupSettingsFile(settingsFile.path);
// Store api-key in settings.env (unified env key)
settings.setValue(persistScope, `env.${CODING_PLAN_ENV_KEY}`, apiKey);
@ -384,8 +389,8 @@ export const useAuthCommand = (
{
type: MessageType.INFO,
text: t(
'Authenticated successfully with {{region}}. API key is stored in settings.env.',
{ region: regionName },
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).',
{ region: t('Alibaba Cloud Coding Plan') },
),
},
Date.now(),

View file

@ -20,6 +20,7 @@ export const compressCommand: SlashCommand = {
action: async (context) => {
const { ui } = context;
const executionMode = context.executionMode ?? 'interactive';
const abortSignal = context.abortSignal;
if (executionMode === 'interactive' && ui.pendingItem) {
ui.addItem(
@ -96,6 +97,10 @@ export const compressCommand: SlashCommand = {
const compressed = await doCompress();
if (abortSignal?.aborted) {
return;
}
if (!compressed) {
if (executionMode === 'interactive') {
ui.addItem(
@ -137,6 +142,10 @@ export const compressCommand: SlashCommand = {
content: `Context compressed (${compressed.originalTokenCount} -> ${compressed.newTokenCount}).`,
};
} catch (e) {
// If cancelled via ESC, don't show error — cancelSlashCommand already handled UI
if (abortSignal?.aborted) {
return;
}
if (executionMode === 'interactive') {
ui.addItem(
{

View file

@ -0,0 +1,322 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {
SlashCommand,
SlashCommandActionReturn,
CommandContext,
MessageActionReturn,
} from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
import type { HookRegistryEntry } from '@qwen-code/qwen-code-core';
/**
* Format hook source for display
*/
function formatHookSource(source: string): string {
switch (source) {
case 'project':
return 'Project';
case 'user':
return 'User';
case 'system':
return 'System';
case 'extensions':
return 'Extension';
default:
return source;
}
}
/**
* Format hook status for display
*/
function formatHookStatus(enabled: boolean): string {
return enabled ? '✓ Enabled' : '✗ Disabled';
}
const listCommand: SlashCommand = {
name: 'list',
get description() {
return t('List all configured hooks');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
_args: string,
): Promise<MessageActionReturn> => {
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: t('Config not loaded.'),
};
}
const hookSystem = config.getHookSystem();
if (!hookSystem) {
return {
type: 'message',
messageType: 'info',
content: t(
'Hooks are not enabled. Enable hooks in settings to use this feature.',
),
};
}
const registry = hookSystem.getRegistry();
const allHooks = registry.getAllHooks();
if (allHooks.length === 0) {
return {
type: 'message',
messageType: 'info',
content: t(
'No hooks configured. Add hooks in your settings.json file.',
),
};
}
// Group hooks by event
const hooksByEvent = new Map<string, HookRegistryEntry[]>();
for (const hook of allHooks) {
const eventName = hook.eventName;
if (!hooksByEvent.has(eventName)) {
hooksByEvent.set(eventName, []);
}
hooksByEvent.get(eventName)!.push(hook);
}
let output = `**Configured Hooks (${allHooks.length} total)**\n\n`;
for (const [eventName, hooks] of hooksByEvent) {
output += `### ${eventName}\n`;
for (const hook of hooks) {
const name = hook.config.name || hook.config.command || 'unnamed';
const source = formatHookSource(hook.source);
const status = formatHookStatus(hook.enabled);
const matcher = hook.matcher ? ` (matcher: ${hook.matcher})` : '';
output += `- **${name}** [${source}] ${status}${matcher}\n`;
}
output += '\n';
}
return {
type: 'message',
messageType: 'info',
content: output,
};
},
};
const enableCommand: SlashCommand = {
name: 'enable',
get description() {
return t('Enable a disabled hook');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
const hookName = args.trim();
if (!hookName) {
return {
type: 'message',
messageType: 'error',
content: t(
'Please specify a hook name. Usage: /hooks enable <hook-name>',
),
};
}
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: t('Config not loaded.'),
};
}
const hookSystem = config.getHookSystem();
if (!hookSystem) {
return {
type: 'message',
messageType: 'error',
content: t('Hooks are not enabled.'),
};
}
const registry = hookSystem.getRegistry();
registry.setHookEnabled(hookName, true);
return {
type: 'message',
messageType: 'info',
content: t('Hook "{{name}}" has been enabled for this session.', {
name: hookName,
}),
};
},
completion: async (context: CommandContext, partialArg: string) => {
const { config } = context.services;
if (!config) return [];
const hookSystem = config.getHookSystem();
if (!hookSystem) return [];
const registry = hookSystem.getRegistry();
const allHooks = registry.getAllHooks();
// Return disabled hooks for enable command (deduplicated by name)
const disabledHookNames = allHooks
.filter((hook) => !hook.enabled)
.map((hook) => hook.config.name || hook.config.command || '')
.filter((name) => name && name.startsWith(partialArg));
return [...new Set(disabledHookNames)];
},
};
const disableCommand: SlashCommand = {
name: 'disable',
get description() {
return t('Disable an active hook');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
const hookName = args.trim();
if (!hookName) {
return {
type: 'message',
messageType: 'error',
content: t(
'Please specify a hook name. Usage: /hooks disable <hook-name>',
),
};
}
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: t('Config not loaded.'),
};
}
const hookSystem = config.getHookSystem();
if (!hookSystem) {
return {
type: 'message',
messageType: 'error',
content: t('Hooks are not enabled.'),
};
}
const registry = hookSystem.getRegistry();
registry.setHookEnabled(hookName, false);
return {
type: 'message',
messageType: 'info',
content: t('Hook "{{name}}" has been disabled for this session.', {
name: hookName,
}),
};
},
completion: async (context: CommandContext, partialArg: string) => {
const { config } = context.services;
if (!config) return [];
const hookSystem = config.getHookSystem();
if (!hookSystem) return [];
const registry = hookSystem.getRegistry();
const allHooks = registry.getAllHooks();
// Return enabled hooks for disable command (deduplicated by name)
const enabledHookNames = allHooks
.filter((hook) => hook.enabled)
.map((hook) => hook.config.name || hook.config.command || '')
.filter((name) => name && name.startsWith(partialArg));
return [...new Set(enabledHookNames)];
},
};
export const hooksCommand: SlashCommand = {
name: 'hooks',
get description() {
return t('Manage Qwen Code hooks');
},
kind: CommandKind.BUILT_IN,
subCommands: [listCommand, enableCommand, disableCommand],
action: async (
context: CommandContext,
args: string,
): Promise<SlashCommandActionReturn> => {
// If no subcommand provided, show list
if (!args.trim()) {
const result = await listCommand.action?.(context, '');
return result ?? { type: 'message', messageType: 'info', content: '' };
}
const [subcommand, ...rest] = args.trim().split(/\s+/);
const subArgs = rest.join(' ');
let result: SlashCommandActionReturn | void;
switch (subcommand.toLowerCase()) {
case 'list':
result = await listCommand.action?.(context, subArgs);
break;
case 'enable':
result = await enableCommand.action?.(context, subArgs);
break;
case 'disable':
result = await disableCommand.action?.(context, subArgs);
break;
default:
return {
type: 'message',
messageType: 'error',
content: t(
'Unknown subcommand: {{cmd}}. Available: list, enable, disable',
{
cmd: subcommand,
},
),
};
}
return result ?? { type: 'message', messageType: 'info', content: '' };
},
completion: async (context: CommandContext, partialArg: string) => {
const subcommands = ['list', 'enable', 'disable'];
const parts = partialArg.split(/\s+/);
if (parts.length <= 1) {
// Complete subcommand
return subcommands.filter((cmd) => cmd.startsWith(partialArg));
}
// Complete subcommand arguments
const [subcommand, ...rest] = parts;
const subArgs = rest.join(' ');
switch (subcommand.toLowerCase()) {
case 'enable':
return enableCommand.completion?.(context, subArgs) ?? [];
case 'disable':
return disableCommand.completion?.(context, subArgs) ?? [];
default:
return [];
}
},
};

View file

@ -0,0 +1,132 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandContext, SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import { MessageType } from '../types.js';
import type { HistoryItemInsightProgress } from '../types.js';
import { t } from '../../i18n/index.js';
import { join } from 'path';
import os from 'os';
import { StaticInsightGenerator } from '../../services/insight/generators/StaticInsightGenerator.js';
import { createDebugLogger } from '@qwen-code/qwen-code-core';
import open from 'open';
const logger = createDebugLogger('DataProcessor');
export const insightCommand: SlashCommand = {
name: 'insight',
get description() {
return t(
'generate personalized programming insights from your chat history',
);
},
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext) => {
try {
context.ui.setDebugMessage(t('Generating insights...'));
const projectsDir = join(os.homedir(), '.qwen', 'projects');
if (!context.services.config) {
throw new Error('Config service is not available');
}
const insightGenerator = new StaticInsightGenerator(
context.services.config,
);
const updateProgress = (
stage: string,
progress: number,
detail?: string,
) => {
const progressItem: HistoryItemInsightProgress = {
type: MessageType.INSIGHT_PROGRESS,
progress: {
stage,
progress,
detail,
},
};
context.ui.setPendingItem(progressItem);
};
context.ui.addItem(
{
type: MessageType.INFO,
text: t('This may take a couple minutes. Sit tight!'),
},
Date.now(),
);
// Initial progress
updateProgress(t('Starting insight generation...'), 0);
// Generate the static insight HTML file
const outputPath = await insightGenerator.generateStaticInsight(
projectsDir,
updateProgress,
);
// Clear pending item
context.ui.setPendingItem(null);
context.ui.addItem(
{
type: MessageType.INFO,
text: t('Insight report generated successfully!'),
},
Date.now(),
);
// Open the file in the default browser
try {
await open(outputPath);
context.ui.addItem(
{
type: MessageType.INFO,
text: t('Opening insights in your browser: {{path}}', {
path: outputPath,
}),
},
Date.now(),
);
} catch (browserError) {
logger.error('Failed to open browser automatically:', browserError);
context.ui.addItem(
{
type: MessageType.INFO,
text: t(
'Insights generated at: {{path}}. Please open this file in your browser.',
{
path: outputPath,
},
),
},
Date.now(),
);
}
context.ui.setDebugMessage(t('Insights ready.'));
} catch (error) {
// Clear pending item on error
context.ui.setPendingItem(null);
context.ui.addItem(
{
type: MessageType.ERROR,
text: t('Failed to generate insights: {{error}}', {
error: (error as Error).message,
}),
},
Date.now(),
);
logger.error('Insight generation error:', error);
}
},
};

View file

@ -104,6 +104,15 @@ export const setupGithubCommand: SlashCommand = {
): Promise<SlashCommandActionReturn> => {
const abortController = new AbortController();
// If we have a context abort signal (from ESC cancellation), link it to our controller
if (context.abortSignal) {
context.abortSignal.addEventListener(
'abort',
() => abortController.abort(),
{ once: true },
);
}
if (!isGitHubRepository()) {
throw new Error(
'Unable to determine the GitHub repository. /setup-github must be run from a git repository.',

View file

@ -27,6 +27,7 @@ export const summaryCommand: SlashCommand = {
const { config } = context.services;
const { ui } = context;
const executionMode = context.executionMode ?? 'interactive';
const abortSignal = context.abortSignal;
if (!config) {
return {
@ -101,7 +102,7 @@ export const summaryCommand: SlashCommand = {
},
],
{},
new AbortController().signal,
abortSignal ?? new AbortController().signal,
config.getModel(),
);
@ -197,6 +198,10 @@ export const summaryCommand: SlashCommand = {
if (executionMode !== 'interactive') {
return;
}
// If cancelled via ESC, don't show error — cancelSlashCommand already handled UI
if (abortSignal?.aborted) {
return;
}
ui.setPendingItem(null);
ui.addItem(
{
@ -241,6 +246,9 @@ export const summaryCommand: SlashCommand = {
}> => {
emitInteractivePending('generating');
const markdownSummary = await generateSummaryMarkdown(history);
if (abortSignal?.aborted) {
throw new DOMException('Summary generation cancelled.', 'AbortError');
}
emitInteractivePending('saving');
const { filePathForDisplay } = await saveSummaryToDisk(markdownSummary);
completeInteractive(filePathForDisplay);

View file

@ -89,6 +89,8 @@ export interface CommandContext {
};
// Flag to indicate if an overwrite has been confirmed
overwriteConfirmed?: boolean;
/** Abort signal for cancelling long-running slash command operations via ESC. */
abortSignal?: AbortSignal;
}
/**

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>

View file

@ -1335,6 +1335,40 @@ describe('KeypressContext - Kitty Protocol', () => {
);
});
describe('Printable CSI-u keys', () => {
it('parses kitty CSI-u space as a space key with literal sequence', () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
act(() => stdin.sendKittySequence(`\x1b[32u`));
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'space',
sequence: ' ',
kittyProtocol: true,
}),
);
});
it('parses kitty CSI-u printable letters as literal input', () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
act(() => stdin.sendKittySequence(`\x1b[100u`)); // 'd'
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'd',
sequence: 'd',
kittyProtocol: true,
}),
);
});
});
describe('Shift+Tab forms', () => {
it.each([
{ sequence: `\x1b[Z`, description: 'legacy reverse Tab' },

View file

@ -332,6 +332,36 @@ export function KeypressProvider({
};
}
// Printable CSI-u keys (including space) should behave like regular
// character input so downstream text inputs receive the literal char.
if (
terminator === 'u' &&
!ctrl &&
keyCode >= 32 &&
keyCode !== 127 &&
keyCode <= 0x10ffff
) {
const char = String.fromCodePoint(keyCode);
const printableName =
char === ' '
? 'space'
: /^[A-Za-z]$/.test(char)
? char.toLowerCase()
: char;
return {
key: {
name: printableName,
ctrl: false,
meta: alt,
shift,
paste: false,
sequence: char,
kittyProtocol: true,
},
length: m[0].length,
};
}
// Ctrl+letters
if (
ctrl &&

View file

@ -17,7 +17,6 @@ import {
import { type SettingScope } from '../../config/settings.js';
import { type CodingPlanRegion } from '../../constants/codingPlan.js';
import type { AuthState } from '../types.js';
import { type VisionSwitchOutcome } from '../components/ModelSwitchDialog.js';
// OpenAICredentials type (previously imported from OpenAIKeyPrompt)
export interface OpenAICredentials {
apiKey: string;
@ -67,9 +66,8 @@ export interface UIActions {
onSuggestionsVisibilityChange: (visible: boolean) => void;
refreshStatic: () => void;
handleFinalSubmit: (value: string) => void;
handleRetryLastPrompt: () => void;
handleClearScreen: () => void;
// Vision switch dialog
handleVisionSwitchSelect: (outcome: VisionSwitchOutcome) => void;
// Welcome back dialog
handleWelcomeBackSelection: (choice: 'continue' | 'restart') => void;
handleWelcomeBackClose: () => void;

View file

@ -115,8 +115,6 @@ export interface UIState {
extensionsUpdateState: Map<string, ExtensionUpdateState>;
activePtyId: number | undefined;
embeddedShellFocused: boolean;
// Vision switch dialog
isVisionSwitchDialogOpen: boolean;
// Welcome back dialog
showWelcomeBackDialog: boolean;
welcomeBackInfo: {

View file

@ -88,6 +88,10 @@ vi.mock('../../utils/cleanup.js', () => ({
runExitCleanup: mockRunExitCleanup,
}));
vi.mock('./useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
function createTestCommand(
overrides: Partial<SlashCommand>,
kind: CommandKind = CommandKind.BUILT_IN,
@ -143,6 +147,7 @@ describe('useSlashCommandProcessor', () => {
mockLoadHistory,
vi.fn(), // refreshStatic
vi.fn(), // toggleVimEnabled
false, // isProcessing
setIsProcessing,
vi.fn(), // setGeminiMdFileCount
{
@ -151,9 +156,19 @@ describe('useSlashCommandProcessor', () => {
openEditorDialog: vi.fn(),
openSettingsDialog: vi.fn(),
openModelDialog: mockOpenModelDialog,
openPermissionsDialog: vi.fn(),
openApprovalModeDialog: vi.fn(),
openResumeDialog: vi.fn(),
quit: mockSetQuittingMessages,
setDebugMessage: vi.fn(),
dispatchExtensionStateUpdate: vi.fn(),
addConfirmUpdateExtensionRequest: vi.fn(),
openSubagentCreateDialog: vi.fn(),
openAgentsManagerDialog: vi.fn(),
},
new Map(), // extensionsUpdateState
true, // isConfigInitialized
null, // logger
),
);
@ -459,7 +474,7 @@ describe('useSlashCommandProcessor', () => {
name: 'loadwiththoughts',
action: vi.fn().mockResolvedValue({
type: 'load_history',
history: [{ type: MessageType.MODEL, text: 'response' }],
history: [{ type: MessageType.GEMINI, text: 'response' }],
clientHistory: historyWithThoughts,
}),
});
@ -904,18 +919,29 @@ describe('useSlashCommandProcessor', () => {
mockClearItems,
mockLoadHistory,
vi.fn(), // refreshStatic
vi.fn(), // onDebugMessage
vi.fn(), // openThemeDialog
mockOpenAuthDialog,
vi.fn(), // openEditorDialog
mockSetQuittingMessages,
vi.fn(), // openSettingsDialog
vi.fn(), // openModelSelectionDialog
vi.fn(), // openSubagentCreateDialog
vi.fn(), // openAgentsManagerDialog
vi.fn(), // toggleVimEnabled
false, // isProcessing
vi.fn(), // setIsProcessing
vi.fn(), // setGeminiMdFileCount
{
openAuthDialog: mockOpenAuthDialog,
openThemeDialog: mockOpenThemeDialog,
openEditorDialog: vi.fn(),
openSettingsDialog: vi.fn(),
openModelDialog: vi.fn(),
openPermissionsDialog: vi.fn(),
openApprovalModeDialog: vi.fn(),
openResumeDialog: vi.fn(),
quit: mockSetQuittingMessages,
setDebugMessage: vi.fn(),
dispatchExtensionStateUpdate: vi.fn(),
addConfirmUpdateExtensionRequest: vi.fn(),
openSubagentCreateDialog: vi.fn(),
openAgentsManagerDialog: vi.fn(),
},
new Map(), // extensionsUpdateState
true, // isConfigInitialized
null, // logger
),
);

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useCallback, useMemo, useEffect, useState } from 'react';
import { useCallback, useMemo, useEffect, useRef, useState } from 'react';
import { type PartListUnion } from '@google/genai';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import {
@ -35,6 +35,7 @@ import { FileCommandLoader } from '../../services/FileCommandLoader.js';
import { McpPromptLoader } from '../../services/McpPromptLoader.js';
import { parseSlashCommand } from '../../utils/commands.js';
import { clearScreen } from '../../utils/stdioHelpers.js';
import { useKeypress } from './useKeypress.js';
import {
type ExtensionUpdateAction,
type ExtensionUpdateStatus,
@ -91,6 +92,7 @@ export const useSlashCommandProcessor = (
loadHistory: UseHistoryManagerReturn['loadHistory'],
refreshStatic: () => void,
toggleVimEnabled: () => Promise<boolean>,
isProcessing: boolean,
setIsProcessing: (isProcessing: boolean) => void,
setGeminiMdFileCount: (count: number) => void,
actions: SlashCommandProcessorActions,
@ -132,6 +134,34 @@ export const useSlashCommandProcessor = (
null,
);
// AbortController for cancelling async slash commands via ESC
const abortControllerRef = useRef<AbortController | null>(null);
const cancelSlashCommand = useCallback(() => {
if (!abortControllerRef.current) {
return;
}
abortControllerRef.current.abort();
addItem(
{
type: MessageType.INFO,
text: 'Command cancelled.',
},
Date.now(),
);
setPendingItem(null);
setIsProcessing(false);
}, [addItem, setIsProcessing]);
useKeypress(
(key) => {
if (key.name === 'escape') {
cancelSlashCommand();
}
},
{ isActive: isProcessing },
);
const pendingHistoryItems = useMemo(() => {
const items: HistoryItemWithoutId[] = [];
if (pendingItem != null) {
@ -182,6 +212,11 @@ export const useSlashCommandProcessor = (
type: 'summary',
summary: message.summary,
};
} else if (message.type === MessageType.INSIGHT_PROGRESS) {
historyItemContent = {
type: 'insight_progress',
progress: message.progress,
};
} else {
historyItemContent = {
type: message.type,
@ -320,6 +355,10 @@ export const useSlashCommandProcessor = (
setIsProcessing(true);
// Create a new AbortController for this command execution
const abortController = new AbortController();
abortControllerRef.current = abortController;
const userMessageTimestamp = Date.now();
addItemWithRecording(
{ type: MessageType.USER, text: trimmed },
@ -353,6 +392,7 @@ export const useSlashCommandProcessor = (
args,
},
overwriteConfirmed,
abortSignal: abortController.signal,
};
// If a one-time list is provided for a "Proceed" action, temporarily
@ -366,10 +406,27 @@ export const useSlashCommandProcessor = (
]),
};
}
const result = await commandToExecute.action(
fullCommandContext,
args,
);
// Race the command action against the abort signal so that
// ESC cancellation immediately unblocks the await chain.
// Without this, commands like /compress whose underlying
// operation (tryCompressChat) doesn't accept an AbortSignal
// would keep submitQuery stuck until the operation completes.
const abortPromise = new Promise<undefined>((resolve) => {
abortController.signal.addEventListener(
'abort',
() => resolve(undefined),
{ once: true },
);
});
const result = await Promise.race([
commandToExecute.action(fullCommandContext, args),
abortPromise,
]);
// If the command was cancelled via ESC while executing, skip result processing
if (abortController.signal.aborted) {
return { type: 'handled' };
}
if (result) {
switch (result.type) {
@ -565,6 +622,10 @@ export const useSlashCommandProcessor = (
return { type: 'handled' };
} catch (e: unknown) {
// If cancelled via ESC, the cancelSlashCommand callback already handled cleanup
if (abortController.signal.aborted) {
return { type: 'handled' };
}
hasError = true;
if (config) {
const event = makeSlashCommandEvent({

View file

@ -112,7 +112,7 @@ describe('useCodingPlanUpdates', () => {
// Should prompt for China region since it defaults to China
expect(result.current.codingPlanUpdateRequest?.prompt).toContain(
chinaConfig.regionName,
'Alibaba Cloud Coding Plan',
);
});
@ -135,7 +135,7 @@ describe('useCodingPlanUpdates', () => {
});
expect(result.current.codingPlanUpdateRequest?.prompt).toContain(
chinaConfig.regionName,
'Alibaba Cloud Coding Plan',
);
});
@ -158,7 +158,7 @@ describe('useCodingPlanUpdates', () => {
});
expect(result.current.codingPlanUpdateRequest?.prompt).toContain(
globalConfig.regionName,
'Alibaba Cloud Coding Plan',
);
});
});
@ -228,7 +228,7 @@ describe('useCodingPlanUpdates', () => {
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'info',
text: expect.stringContaining(chinaConfig.regionName),
text: expect.stringContaining('Alibaba Cloud Coding Plan'),
}),
expect.any(Number),
);
@ -297,7 +297,7 @@ describe('useCodingPlanUpdates', () => {
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'info',
text: expect.stringContaining(globalConfig.regionName),
text: expect.stringContaining('Alibaba Cloud Coding Plan'),
}),
expect.any(Number),
);

View file

@ -68,7 +68,7 @@ export function useCodingPlanUpdates(
);
// Get the configuration for the current region
const { template, version, regionName } = getCodingPlanConfig(region);
const { template, version } = getCodingPlanConfig(region);
// Generate new configs from template
const newConfigs = template.map((templateConfig) => ({
@ -117,7 +117,7 @@ export function useCodingPlanUpdates(
type: 'info',
text: t(
'{{region}} configuration updated successfully. Model switched to "{{model}}".',
{ region: regionName, model: activeModel },
{ region: t('Alibaba Cloud Coding Plan'), model: activeModel },
),
},
Date.now(),
@ -170,11 +170,10 @@ export function useCodingPlanUpdates(
// Check if version matches
if (savedVersion !== currentVersion) {
const { regionName } = getCodingPlanConfig(region);
setUpdateRequest({
prompt: t(
'New model configurations are available for {{region}}. Update now?',
{ region: regionName },
{ region: t('Alibaba Cloud Coding Plan') },
),
onConfirm: async (confirmed: boolean) => {
setUpdateRequest(undefined);

View file

@ -74,14 +74,6 @@ const mockParseAndFormatApiError = vi.hoisted(() =>
);
const mockLogApiCancel = vi.hoisted(() => vi.fn());
// Vision auto-switch mocks (hoisted)
const mockHandleVisionSwitch = vi.hoisted(() =>
vi.fn().mockResolvedValue({ shouldProceed: true }),
);
const mockRestoreOriginalModel = vi.hoisted(() =>
vi.fn().mockResolvedValue(undefined),
);
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actualCoreModule = (await importOriginal()) as any;
return {
@ -104,13 +96,6 @@ vi.mock('./useReactToolScheduler.js', async (importOriginal) => {
};
});
vi.mock('./useVisionAutoSwitch.js', () => ({
useVisionAutoSwitch: vi.fn(() => ({
handleVisionSwitch: mockHandleVisionSwitch,
restoreOriginalModel: mockRestoreOriginalModel,
})),
}));
vi.mock('./shellCommandProcessor.js', () => ({
useShellCommandProcessor: vi.fn().mockReturnValue({
handleShellCommand: vi.fn(),
@ -306,7 +291,6 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
() => {},
80,
24,
@ -472,7 +456,6 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
() => {},
80,
24,
@ -557,7 +540,6 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
() => {},
80,
24,
@ -670,7 +652,6 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
() => {},
80,
24,
@ -784,7 +765,6 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
() => {},
80,
24,
@ -901,7 +881,6 @@ describe('useGeminiStream', () => {
() => {},
() => {},
cancelSubmitSpy,
false, // visionModelPreviewEnabled
() => {},
80,
24,
@ -945,7 +924,6 @@ describe('useGeminiStream', () => {
() => {},
() => {},
vi.fn(),
false,
setShellInputFocusedSpy, // Pass the spy here
80,
24,
@ -1273,7 +1251,6 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
() => {},
80,
24,
@ -1331,7 +1308,6 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
() => {},
80,
24,
@ -1825,7 +1801,6 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
() => {},
80,
24,
@ -1881,7 +1856,6 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
() => {},
80,
24,
@ -1938,7 +1912,6 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
() => {},
80,
24,
@ -2035,7 +2008,6 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
vi.fn(),
80,
24,
@ -2090,7 +2062,6 @@ describe('useGeminiStream', () => {
vi.fn(), // setModelSwitched
vi.fn(), // onEditorClose
vi.fn(), // onCancelSubmit
false, // visionModelPreviewEnabled
vi.fn(), // setShellInputFocused
80, // terminalWidth
24, // terminalHeight
@ -2164,7 +2135,6 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
() => {},
80,
24,
@ -2256,7 +2226,6 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
() => {},
80,
24,
@ -2317,7 +2286,6 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
() => {},
80,
24,
@ -2336,40 +2304,30 @@ describe('useGeminiStream', () => {
result.current.pendingHistoryItems.find(
(item) => item.type === MessageType.ERROR,
);
const findCountdownItem = () =>
result.current.pendingHistoryItems.find(
(item) => item.type === 'retry_countdown',
);
let errorItem = findErrorItem();
let countdownItem = findCountdownItem();
for (
let attempts = 0;
attempts < 5 && (!errorItem || !countdownItem);
attempts++
) {
for (let attempts = 0; attempts < 5 && !errorItem; attempts++) {
await act(async () => {
await Promise.resolve();
});
errorItem = findErrorItem();
countdownItem = findCountdownItem();
}
// Error line should be rendered as ERROR type (wrapped by parseAndFormatApiError)
// Error item should contain the error text and a retry hint
expect(errorItem?.text).toContain('Rate limit exceeded');
// Countdown line should be rendered as retry_countdown type
expect(countdownItem?.text).toContain('Retrying in 3 seconds');
// Countdown hint should be inline on the error item (not a separate item)
expect((errorItem as { hint?: string })?.hint).toContain('3s');
expect((errorItem as { hint?: string })?.hint).toContain('attempt 1/3');
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
const countdownAfterOneSecond = result.current.pendingHistoryItems.find(
(item) => item.type === 'retry_countdown',
const errorAfterOneSecond = result.current.pendingHistoryItems.find(
(item) => item.type === MessageType.ERROR,
);
expect(countdownAfterOneSecond?.text).toContain(
'Retrying in 2 seconds',
expect((errorAfterOneSecond as { hint?: string })?.hint).toContain(
'2s',
);
resolveStream?.();
@ -2379,15 +2337,11 @@ describe('useGeminiStream', () => {
await vi.runAllTimersAsync();
});
// Both error and countdown should be cleared after retry succeeds
// Error item (with hint) should be cleared after retry succeeds
const remainingError = result.current.pendingHistoryItems.find(
(item) => item.type === MessageType.ERROR,
);
const remainingCountdown = result.current.pendingHistoryItems.find(
(item) => item.type === 'retry_countdown',
);
expect(remainingError).toBeUndefined();
expect(remainingCountdown).toBeUndefined();
} finally {
vi.useRealTimers();
}
@ -2418,7 +2372,6 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
vi.fn(), // setShellInputFocused
80,
24,
@ -2489,7 +2442,6 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
() => {},
80,
24,
@ -2548,7 +2500,6 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
() => {},
80,
24,
@ -2560,14 +2511,13 @@ describe('useGeminiStream', () => {
await result.current.submitQuery('Test query');
});
// Verify error message was added
// Verify error message appears in pending history items (not via addItem,
// since errors with retry hints are now stored as pending items)
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
}),
expect.any(Number),
const errorItem = result.current.pendingHistoryItems.find(
(item) => item.type === 'error',
);
expect(errorItem).toBeDefined();
});
// Verify parseAndFormatApiError was called
@ -2736,187 +2686,6 @@ describe('useGeminiStream', () => {
});
// --- New tests focused on recent modifications ---
describe('Vision Auto Switch Integration', () => {
it('should call handleVisionSwitch and proceed to send when allowed', async () => {
mockHandleVisionSwitch.mockResolvedValueOnce({ shouldProceed: true });
mockSendMessageStream.mockReturnValue(
(async function* () {
yield { type: ServerGeminiEventType.Content, value: 'ok' };
yield { type: ServerGeminiEventType.Finished, value: 'STOP' };
})(),
);
const { result } = renderHook(() =>
useGeminiStream(
new MockedGeminiClientClass(mockConfig),
[],
mockAddItem,
mockConfig,
mockLoadedSettings,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
() => Promise.resolve(),
false,
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
vi.fn(), // setShellInputFocused
80,
24,
),
);
await act(async () => {
await result.current.submitQuery('image prompt');
});
await waitFor(() => {
expect(mockHandleVisionSwitch).toHaveBeenCalled();
expect(mockSendMessageStream).toHaveBeenCalled();
});
});
it('should gate submission when handleVisionSwitch returns shouldProceed=false', async () => {
mockHandleVisionSwitch.mockResolvedValueOnce({ shouldProceed: false });
const { result } = renderHook(() =>
useGeminiStream(
new MockedGeminiClientClass(mockConfig),
[],
mockAddItem,
mockConfig,
mockLoadedSettings,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
() => Promise.resolve(),
false,
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
vi.fn(), // setShellInputFocused
80,
24,
),
);
await act(async () => {
await result.current.submitQuery('vision-gated');
});
// No call to API, no restoreOriginalModel needed since no override occurred
expect(mockSendMessageStream).not.toHaveBeenCalled();
expect(mockRestoreOriginalModel).not.toHaveBeenCalled();
// Next call allowed (flag reset path)
mockHandleVisionSwitch.mockResolvedValueOnce({ shouldProceed: true });
mockSendMessageStream.mockReturnValue(
(async function* () {
yield { type: ServerGeminiEventType.Content, value: 'ok' };
yield { type: ServerGeminiEventType.Finished, value: 'STOP' };
})(),
);
await act(async () => {
await result.current.submitQuery('after-gate');
});
await waitFor(() => {
expect(mockSendMessageStream).toHaveBeenCalled();
});
});
});
describe('Model restore on completion and errors', () => {
it('should restore model after successful stream completion', async () => {
mockSendMessageStream.mockReturnValue(
(async function* () {
yield { type: ServerGeminiEventType.Content, value: 'content' };
yield { type: ServerGeminiEventType.Finished, value: 'STOP' };
})(),
);
const { result } = renderHook(() =>
useGeminiStream(
new MockedGeminiClientClass(mockConfig),
[],
mockAddItem,
mockConfig,
mockLoadedSettings,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
() => Promise.resolve(),
false,
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
vi.fn(), // setShellInputFocused
80,
24,
),
);
await act(async () => {
await result.current.submitQuery('restore-success');
});
await waitFor(() => {
expect(mockRestoreOriginalModel).toHaveBeenCalledTimes(1);
});
});
it('should restore model when an error occurs during streaming', async () => {
const testError = new Error('stream failure');
mockSendMessageStream.mockReturnValue(
(async function* () {
yield { type: ServerGeminiEventType.Content, value: 'content' };
throw testError;
})(),
);
const { result } = renderHook(() =>
useGeminiStream(
new MockedGeminiClientClass(mockConfig),
[],
mockAddItem,
mockConfig,
mockLoadedSettings,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
() => Promise.resolve(),
false,
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
vi.fn(), // setShellInputFocused
80,
24,
),
);
await act(async () => {
await result.current.submitQuery('restore-error');
});
await waitFor(() => {
expect(mockRestoreOriginalModel).toHaveBeenCalledTimes(1);
});
});
});
describe('Loop Detection Confirmation', () => {
beforeEach(() => {
// Add mock for getLoopDetectionService to the config

View file

@ -35,6 +35,8 @@ import {
ToolConfirmationOutcome,
logApiCancel,
ApiCancelEvent,
isSupportedImageMimeType,
getUnsupportedImageFormatWarning,
} from '@qwen-code/qwen-code-core';
import { type Part, type PartListUnion, FinishReason } from '@google/genai';
import type {
@ -46,7 +48,6 @@ import type {
import { StreamingState, MessageType, ToolCallStatus } from '../types.js';
import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js';
import { useShellCommandProcessor } from './shellCommandProcessor.js';
import { useVisionAutoSwitch } from './useVisionAutoSwitch.js';
import { handleAtCommand } from './atCommandProcessor.js';
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
import { useStateAndRef } from './useStateAndRef.js';
@ -68,6 +69,60 @@ import { t } from '../../i18n/index.js';
const debugLogger = createDebugLogger('GEMINI_STREAM');
/**
* Checks if image parts have supported formats and returns unsupported ones
*/
function checkImageFormatsSupport(parts: PartListUnion): {
hasImages: boolean;
hasUnsupportedFormats: boolean;
unsupportedMimeTypes: string[];
} {
const unsupportedMimeTypes: string[] = [];
let hasImages = false;
if (typeof parts === 'string') {
return {
hasImages: false,
hasUnsupportedFormats: false,
unsupportedMimeTypes: [],
};
}
const partsArray = Array.isArray(parts) ? parts : [parts];
for (const part of partsArray) {
if (typeof part === 'string') continue;
let mimeType: string | undefined;
// Check inlineData
if (
'inlineData' in part &&
part.inlineData?.mimeType?.startsWith('image/')
) {
hasImages = true;
mimeType = part.inlineData.mimeType;
}
// Check fileData
if ('fileData' in part && part.fileData?.mimeType?.startsWith('image/')) {
hasImages = true;
mimeType = part.fileData.mimeType;
}
// Check if the mime type is supported
if (mimeType && !isSupportedImageMimeType(mimeType)) {
unsupportedMimeTypes.push(mimeType);
}
}
return {
hasImages,
hasUnsupportedFormats: unsupportedMimeTypes.length > 0,
unsupportedMimeTypes,
};
}
enum StreamProcessingStatus {
Completed,
UserCancelled,
@ -106,26 +161,25 @@ export const useGeminiStream = (
setModelSwitchedFromQuotaError: React.Dispatch<React.SetStateAction<boolean>>,
onEditorClose: () => void,
onCancelSubmit: () => void,
visionModelPreviewEnabled: boolean,
setShellInputFocused: (value: boolean) => void,
terminalWidth: number,
terminalHeight: number,
onVisionSwitchRequired?: (query: PartListUnion) => Promise<{
modelOverride?: string;
persistSessionModel?: string;
showGuidance?: boolean;
}>,
) => {
const [initError, setInitError] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const turnCancelledRef = useRef(false);
const isSubmittingQueryRef = useRef(false);
const lastPromptRef = useRef<PartListUnion | null>(null);
const lastPromptErroredRef = useRef(false);
const [isResponding, setIsResponding] = useState<boolean>(false);
const [thought, setThought] = useState<ThoughtSummary | null>(null);
const [pendingHistoryItem, pendingHistoryItemRef, setPendingHistoryItem] =
useStateAndRef<HistoryItemWithoutId | null>(null);
const [pendingRetryErrorItem, setPendingRetryErrorItem] =
useState<HistoryItemWithoutId | null>(null);
const [
pendingRetryErrorItem,
pendingRetryErrorItemRef,
setPendingRetryErrorItem,
] = useStateAndRef<HistoryItemWithoutId | null>(null);
const [
pendingRetryCountdownItem,
pendingRetryCountdownItemRef,
@ -205,11 +259,18 @@ export const useGeminiStream = (
}
}, []);
/**
* Clears the retry countdown timer and pending retry items.
*/
const clearRetryCountdown = useCallback(() => {
stopRetryCountdownTimer();
setPendingRetryErrorItem(null);
setPendingRetryCountdownItem(null);
}, [setPendingRetryCountdownItem, stopRetryCountdownTimer]);
}, [
setPendingRetryErrorItem,
setPendingRetryCountdownItem,
stopRetryCountdownTimer,
]);
const startRetryCountdown = useCallback(
(retryInfo: {
@ -224,18 +285,21 @@ export const useGeminiStream = (
const retryReasonText =
message ?? t('Rate limit exceeded. Please wait and try again.');
// Error line stays static (red with ✕ prefix)
setPendingRetryErrorItem({
type: MessageType.ERROR,
text: retryReasonText,
});
// Countdown line updates every second (dim/secondary color)
const updateCountdown = () => {
const elapsedMs = Date.now() - startTime;
const remainingMs = Math.max(0, delayMs - elapsedMs);
const remainingSec = Math.ceil(remainingMs / 1000);
// Update error item with hint containing countdown info (short format)
const hintText = `Retrying in ${remainingSec}s… (attempt ${attempt}/${maxRetries})`;
setPendingRetryErrorItem({
type: MessageType.ERROR,
text: retryReasonText,
hint: hintText,
});
setPendingRetryCountdownItem({
type: 'retry_countdown',
text: t(
@ -256,7 +320,11 @@ export const useGeminiStream = (
updateCountdown();
retryCountdownTimerRef.current = setInterval(updateCountdown, 1000);
},
[setPendingRetryCountdownItem, stopRetryCountdownTimer],
[
setPendingRetryErrorItem,
setPendingRetryCountdownItem,
stopRetryCountdownTimer,
],
);
useEffect(() => () => stopRetryCountdownTimer(), [stopRetryCountdownTimer]);
@ -278,12 +346,6 @@ export const useGeminiStream = (
terminalHeight,
);
const { handleVisionSwitch, restoreOriginalModel } = useVisionAutoSwitch(
config,
addItem,
visionModelPreviewEnabled,
onVisionSwitchRequired,
);
const activePtyId = activeShellPtyId || activeToolPtyId;
useEffect(() => {
@ -650,6 +712,7 @@ export const useGeminiStream = (
return;
}
lastPromptErroredRef.current = false;
if (pendingHistoryItemRef.current) {
if (pendingHistoryItemRef.current.type === 'tool_group') {
const updatedTools = pendingHistoryItemRef.current.tools.map(
@ -689,27 +752,36 @@ export const useGeminiStream = (
const handleErrorEvent = useCallback(
(eventValue: GeminiErrorEventValue, userMessageTimestamp: number) => {
lastPromptErroredRef.current = true;
if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
setPendingHistoryItem(null);
}
addItem(
{
type: MessageType.ERROR,
// Only show Ctrl+Y hint if not already showing an auto-retry countdown
// (auto-retry countdown is shown when retryCountdownTimerRef is active)
const isShowingAutoRetry = retryCountdownTimerRef.current !== null;
clearRetryCountdown();
if (!isShowingAutoRetry) {
const retryHint = t('Press Ctrl+Y to retry');
// Store error with hint as a pending item (not in history).
// This allows the hint to be removed when the user retries with Ctrl+Y,
// since pending items are in the dynamic rendering area (not <Static>).
setPendingRetryErrorItem({
type: 'error' as const,
text: parseAndFormatApiError(
eventValue.error,
config.getContentGeneratorConfig()?.authType,
),
},
userMessageTimestamp,
);
clearRetryCountdown();
hint: retryHint,
});
}
setThought(null); // Reset thought when there's an error
},
[
addItem,
pendingHistoryItemRef,
setPendingHistoryItem,
setPendingRetryErrorItem,
config,
setThought,
clearRetryCountdown,
@ -773,7 +845,10 @@ export const useGeminiStream = (
userMessageTimestamp,
);
}
clearRetryCountdown();
// Only clear auto-retry countdown errors (those with active timer)
if (retryCountdownTimerRef.current) {
clearRetryCountdown();
}
},
[addItem, clearRetryCountdown],
);
@ -945,6 +1020,15 @@ export const useGeminiStream = (
clearRetryCountdown();
}
break;
case ServerGeminiEventType.HookSystemMessage:
// Display system message from hooks (e.g., Ralph Loop iteration info)
// This is handled as a content event to show in the UI
geminiMessageBuffer = handleContentEvent(
event.value + '\n',
geminiMessageBuffer,
userMessageTimestamp,
);
break;
default: {
// enforces exhaustive switch-case
const unreachable: never = event;
@ -980,7 +1064,7 @@ export const useGeminiStream = (
const submitQuery = useCallback(
async (
query: PartListUnion,
options?: { isContinuation: boolean },
options?: { isContinuation: boolean; skipPreparation?: boolean },
prompt_id?: string,
) => {
// Prevent concurrent executions of submitQuery, but allow continuations
@ -1004,7 +1088,11 @@ export const useGeminiStream = (
// Reset quota error flag when starting a new query (not a continuation)
if (!options?.isContinuation) {
setModelSwitchedFromQuotaError(false);
// No quota-error / fallback routing mechanism currently; keep state minimal.
// Commit any pending retry error to history (without hint) since the
// user is starting a new conversation turn
if (pendingRetryCountdownItemRef.current) {
clearRetryCountdown();
}
}
abortControllerRef.current = new AbortController();
@ -1016,31 +1104,37 @@ export const useGeminiStream = (
}
return promptIdContext.run(prompt_id, async () => {
const { queryToSend, shouldProceed } = await prepareQueryForGemini(
query,
userMessageTimestamp,
abortSignal,
prompt_id!,
);
const { queryToSend, shouldProceed } = options?.skipPreparation
? { queryToSend: query, shouldProceed: true }
: await prepareQueryForGemini(
query,
userMessageTimestamp,
abortSignal,
prompt_id!,
);
if (!shouldProceed || queryToSend === null) {
isSubmittingQueryRef.current = false;
return;
}
// Handle vision switch requirement
const visionSwitchResult = await handleVisionSwitch(
queryToSend,
userMessageTimestamp,
options?.isContinuation || false,
);
if (!visionSwitchResult.shouldProceed) {
isSubmittingQueryRef.current = false;
return;
// Check image format support for non-continuations
if (!options?.isContinuation) {
const formatCheck = checkImageFormatsSupport(queryToSend);
if (formatCheck.hasUnsupportedFormats) {
addItem(
{
type: MessageType.INFO,
text: getUnsupportedImageFormatWarning(),
},
userMessageTimestamp,
);
}
}
const finalQueryToSend = queryToSend;
lastPromptRef.current = finalQueryToSend;
lastPromptErroredRef.current = false;
if (!options?.isContinuation) {
// trigger new prompt event for session stats in CLI
@ -1081,10 +1175,6 @@ export const useGeminiStream = (
);
if (processingStatus === StreamProcessingStatus.UserCancelled) {
// Restore original model if it was temporarily overridden
restoreOriginalModel().catch((error) => {
debugLogger.error('Failed to restore original model:', error);
});
isSubmittingQueryRef.current = false;
return;
}
@ -1093,34 +1183,31 @@ export const useGeminiStream = (
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
setPendingHistoryItem(null);
}
// Only clear auto-retry countdown errors (those with an active timer).
// Do NOT clear static error+hint from handleErrorEvent — those should
// remain visible until the user presses Ctrl+Y to retry.
if (retryCountdownTimerRef.current) {
clearRetryCountdown();
}
if (loopDetectedRef.current) {
loopDetectedRef.current = false;
handleLoopDetectedEvent();
}
// Restore original model if it was temporarily overridden
restoreOriginalModel().catch((error) => {
debugLogger.error('Failed to restore original model:', error);
});
} catch (error: unknown) {
// Restore original model if it was temporarily overridden
restoreOriginalModel().catch((error) => {
debugLogger.error('Failed to restore original model:', error);
});
if (error instanceof UnauthorizedError) {
onAuthError('Session expired or is unauthorized.');
} else if (!isNodeError(error) || error.name !== 'AbortError') {
addItem(
{
type: MessageType.ERROR,
text: parseAndFormatApiError(
getErrorMessage(error) || 'Unknown error',
config.getContentGeneratorConfig()?.authType,
),
},
userMessageTimestamp,
);
lastPromptErroredRef.current = true;
const retryHint = t('Press Ctrl+Y to retry');
// Store error with hint as a pending item (same as handleErrorEvent)
setPendingRetryErrorItem({
type: 'error' as const,
text: parseAndFormatApiError(
getErrorMessage(error) || 'Unknown error',
config.getContentGeneratorConfig()?.authType,
),
hint: retryHint,
});
}
} finally {
setIsResponding(false);
@ -1143,11 +1230,71 @@ export const useGeminiStream = (
startNewPrompt,
getPromptCount,
handleLoopDetectedEvent,
handleVisionSwitch,
restoreOriginalModel,
clearRetryCountdown,
pendingRetryCountdownItemRef,
setPendingRetryErrorItem,
],
);
/**
* Retries the last failed prompt when the user presses Ctrl+Y.
*
* Activation conditions for Ctrl+Y shortcut:
* 1. The last request must have failed (lastPromptErroredRef.current === true)
* 2. Current streaming state must NOT be "Responding" (avoid interrupting ongoing stream)
* 3. Current streaming state must NOT be "WaitingForConfirmation" (avoid conflicting with tool confirmation flow)
* 4. There must be a stored lastPrompt in lastPromptRef.current
*
* When conditions are not met:
* - If streaming is active (Responding/WaitingForConfirmation): silently return without action
* - If no failed request exists: display "No failed request to retry." info message
*
* When conditions are met:
* - Clears any pending auto-retry countdown to avoid duplicate retries
* - Re-submits the last query with skipPreparation: true for faster retry
*
* This function is exposed via UIActionsContext and triggered by InputPrompt
* when the user presses Ctrl+Y (bound to Command.RETRY_LAST in keyBindings.ts).
*/
const retryLastPrompt = useCallback(async () => {
if (
streamingState === StreamingState.Responding ||
streamingState === StreamingState.WaitingForConfirmation
) {
return;
}
const lastPrompt = lastPromptRef.current;
if (!lastPrompt || !lastPromptErroredRef.current) {
addItem(
{
type: MessageType.INFO,
text: t('No failed request to retry.'),
},
Date.now(),
);
return;
}
// Commit the error to history (without hint) before clearing
const errorItem = pendingRetryErrorItemRef.current;
if (errorItem) {
addItem({ type: errorItem.type, text: errorItem.text }, Date.now());
}
clearRetryCountdown();
await submitQuery(lastPrompt, {
isContinuation: false,
skipPreparation: true,
});
}, [
streamingState,
addItem,
clearRetryCountdown,
submitQuery,
pendingRetryErrorItemRef,
]);
const handleApprovalModeChange = useCallback(
async (newApprovalMode: ApprovalMode) => {
// Auto-approve pending tool calls when switching to auto-approval modes
@ -1451,6 +1598,7 @@ export const useGeminiStream = (
pendingHistoryItems,
thought,
cancelOngoingRequest,
retryLastPrompt,
pendingToolCalls: toolCalls,
handleApprovalModeChange,
activePtyId,

View file

@ -1,874 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import type { Part, PartListUnion } from '@google/genai';
import { AuthType, type Config, ApprovalMode } from '@qwen-code/qwen-code-core';
// Mock the image format functions from core package
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
isSupportedImageMimeType: vi.fn((mimeType: string) =>
[
'image/png',
'image/jpeg',
'image/jpg',
'image/gif',
'image/webp',
].includes(mimeType),
),
getUnsupportedImageFormatWarning: vi.fn(
() =>
'Only the following image formats are supported: BMP, JPEG, JPG, PNG, TIFF, WEBP, HEIC. Other formats may not work as expected.',
),
};
});
import {
shouldOfferVisionSwitch,
processVisionSwitchOutcome,
getVisionSwitchGuidanceMessage,
useVisionAutoSwitch,
} from './useVisionAutoSwitch.js';
import { VisionSwitchOutcome } from '../components/ModelSwitchDialog.js';
import { MessageType } from '../types.js';
import { getDefaultVisionModel } from '../models/availableModels.js';
describe('useVisionAutoSwitch helpers', () => {
describe('shouldOfferVisionSwitch', () => {
it('returns false when authType is not QWEN_OAUTH', () => {
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
const result = shouldOfferVisionSwitch(
parts,
AuthType.USE_GEMINI,
'qwen3-coder-plus',
true,
);
expect(result).toBe(false);
});
it('returns false when current model is already a vision model', () => {
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
const result = shouldOfferVisionSwitch(
parts,
AuthType.QWEN_OAUTH,
'vision-model',
true,
);
expect(result).toBe(false);
});
it('returns true when image parts exist, QWEN_OAUTH, and model is not vision', () => {
const parts: PartListUnion = [
{ text: 'hello' },
{ inlineData: { mimeType: 'image/jpeg', data: '...' } },
];
const result = shouldOfferVisionSwitch(
parts,
AuthType.QWEN_OAUTH,
'qwen3-coder-plus',
true,
);
expect(result).toBe(true);
});
it('detects image when provided as a single Part object (non-array)', () => {
const singleImagePart: PartListUnion = {
fileData: { mimeType: 'image/gif', fileUri: 'file://image.gif' },
} as Part;
const result = shouldOfferVisionSwitch(
singleImagePart,
AuthType.QWEN_OAUTH,
'qwen3-coder-plus',
true,
);
expect(result).toBe(true);
});
it('returns false when parts contain no images', () => {
const parts: PartListUnion = [{ text: 'just text' }];
const result = shouldOfferVisionSwitch(
parts,
AuthType.QWEN_OAUTH,
'qwen3-coder-plus',
true,
);
expect(result).toBe(false);
});
it('returns false when parts is a plain string', () => {
const parts: PartListUnion = 'plain text';
const result = shouldOfferVisionSwitch(
parts,
AuthType.QWEN_OAUTH,
'qwen3-coder-plus',
true,
);
expect(result).toBe(false);
});
it('returns false when visionModelPreviewEnabled is false', () => {
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
const result = shouldOfferVisionSwitch(
parts,
AuthType.QWEN_OAUTH,
'qwen3-coder-plus',
false,
);
expect(result).toBe(false);
});
it('returns true when image parts exist in YOLO mode context', () => {
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
const result = shouldOfferVisionSwitch(
parts,
AuthType.QWEN_OAUTH,
'qwen3-coder-plus',
true,
);
expect(result).toBe(true);
});
it('returns false when no image parts exist in YOLO mode context', () => {
const parts: PartListUnion = [{ text: 'just text' }];
const result = shouldOfferVisionSwitch(
parts,
AuthType.QWEN_OAUTH,
'qwen3-coder-plus',
true,
);
expect(result).toBe(false);
});
it('returns false when already using vision model in YOLO mode context', () => {
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
const result = shouldOfferVisionSwitch(
parts,
AuthType.QWEN_OAUTH,
'vision-model',
true,
);
expect(result).toBe(false);
});
it('returns false when authType is not QWEN_OAUTH in YOLO mode context', () => {
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
const result = shouldOfferVisionSwitch(
parts,
AuthType.USE_GEMINI,
'qwen3-coder-plus',
true,
);
expect(result).toBe(false);
});
});
describe('processVisionSwitchOutcome', () => {
it('maps SwitchOnce to a one-time model override', () => {
const vl = getDefaultVisionModel();
const result = processVisionSwitchOutcome(VisionSwitchOutcome.SwitchOnce);
expect(result).toEqual({ modelOverride: vl });
});
it('maps SwitchSessionToVL to a persistent session model', () => {
const vl = getDefaultVisionModel();
const result = processVisionSwitchOutcome(
VisionSwitchOutcome.SwitchSessionToVL,
);
expect(result).toEqual({ persistSessionModel: vl });
});
it('maps ContinueWithCurrentModel to empty result', () => {
const result = processVisionSwitchOutcome(
VisionSwitchOutcome.ContinueWithCurrentModel,
);
expect(result).toEqual({});
});
});
describe('getVisionSwitchGuidanceMessage', () => {
it('returns the expected guidance message', () => {
const vl = getDefaultVisionModel();
const expected =
'To use images with your query, you can:\n' +
`• Use /model set ${vl} to switch to a vision-capable model\n` +
'• Or remove the image and provide a text description instead';
expect(getVisionSwitchGuidanceMessage()).toBe(expected);
});
});
});
describe('useVisionAutoSwitch hook', () => {
type AddItemFn = (
item: { type: MessageType; text: string },
ts: number,
) => any;
const createMockConfig = (
authType: AuthType,
initialModel: string,
approvalMode: ApprovalMode = ApprovalMode.DEFAULT,
vlmSwitchMode?: string,
) => {
let currentModel = initialModel;
const mockConfig: Partial<Config> = {
getModel: vi.fn(() => currentModel),
setModel: vi.fn(async (m: string) => {
currentModel = m;
}),
getApprovalMode: vi.fn(() => approvalMode),
getVlmSwitchMode: vi.fn(() => vlmSwitchMode),
getContentGeneratorConfig: vi.fn(() => ({
authType,
model: currentModel,
apiKey: 'test-key',
vertexai: false,
})),
};
return mockConfig as Config;
};
let addItem: AddItemFn;
beforeEach(() => {
vi.clearAllMocks();
addItem = vi.fn();
});
it('returns shouldProceed=true immediately for continuations', async () => {
const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus');
const { result } = renderHook(() =>
useVisionAutoSwitch(config, addItem as any, true, vi.fn()),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
let res: any;
await act(async () => {
res = await result.current.handleVisionSwitch(parts, Date.now(), true);
});
expect(res).toEqual({ shouldProceed: true });
expect(addItem).not.toHaveBeenCalled();
});
it('does nothing when authType is not QWEN_OAUTH', async () => {
const config = createMockConfig(AuthType.USE_GEMINI, 'qwen3-coder-plus');
const onVisionSwitchRequired = vi.fn();
const { result } = renderHook(() =>
useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
let res: any;
await act(async () => {
res = await result.current.handleVisionSwitch(parts, 123, false);
});
expect(res).toEqual({ shouldProceed: true });
expect(onVisionSwitchRequired).not.toHaveBeenCalled();
});
it('does nothing when there are no image parts', async () => {
const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus');
const onVisionSwitchRequired = vi.fn();
const { result } = renderHook(() =>
useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired),
);
const parts: PartListUnion = [{ text: 'no images here' }];
let res: any;
await act(async () => {
res = await result.current.handleVisionSwitch(parts, 456, false);
});
expect(res).toEqual({ shouldProceed: true });
expect(onVisionSwitchRequired).not.toHaveBeenCalled();
});
it('continues with current model when dialog returns empty result', async () => {
const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus');
const onVisionSwitchRequired = vi.fn().mockResolvedValue({}); // Empty result for ContinueWithCurrentModel
const { result } = renderHook(() =>
useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
const userTs = 1010;
let res: any;
await act(async () => {
res = await result.current.handleVisionSwitch(parts, userTs, false);
});
// Should not add any guidance message
expect(addItem).not.toHaveBeenCalledWith(
{ type: MessageType.INFO, text: getVisionSwitchGuidanceMessage() },
userTs,
);
expect(res).toEqual({ shouldProceed: true });
expect(config.setModel).not.toHaveBeenCalled();
});
it('applies a one-time override and returns originalModel, then restores', async () => {
const initialModel = 'qwen3-coder-plus';
const config = createMockConfig(AuthType.QWEN_OAUTH, initialModel);
const onVisionSwitchRequired = vi
.fn()
.mockResolvedValue({ modelOverride: 'coder-model' });
const { result } = renderHook(() =>
useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
let res: any;
await act(async () => {
res = await result.current.handleVisionSwitch(parts, 2020, false);
});
expect(res).toEqual({ shouldProceed: true, originalModel: initialModel });
expect(config.setModel).toHaveBeenCalledWith('coder-model', {
reason: 'vision_auto_switch',
context: 'User-prompted vision switch (one-time override)',
});
// Now restore
await act(async () => {
await result.current.restoreOriginalModel();
});
expect(config.setModel).toHaveBeenLastCalledWith(initialModel, {
reason: 'vision_auto_switch',
context: 'Restoring original model after vision switch',
});
});
it('persists session model when dialog requests persistence', async () => {
const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus');
const onVisionSwitchRequired = vi
.fn()
.mockResolvedValue({ persistSessionModel: 'coder-model' });
const { result } = renderHook(() =>
useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
let res: any;
await act(async () => {
res = await result.current.handleVisionSwitch(parts, 3030, false);
});
expect(res).toEqual({ shouldProceed: true });
expect(config.setModel).toHaveBeenCalledWith('coder-model', {
reason: 'vision_auto_switch',
context: 'User-prompted vision switch (session persistent)',
});
// Restore should be a no-op since no one-time override was used
await act(async () => {
await result.current.restoreOriginalModel();
});
// Last call should still be the persisted model set
expect((config.setModel as any).mock.calls.pop()?.[0]).toBe('coder-model');
});
it('returns shouldProceed=true when dialog returns no special flags', async () => {
const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus');
const onVisionSwitchRequired = vi.fn().mockResolvedValue({});
const { result } = renderHook(() =>
useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
let res: any;
await act(async () => {
res = await result.current.handleVisionSwitch(parts, 4040, false);
});
expect(res).toEqual({ shouldProceed: true });
expect(config.setModel).not.toHaveBeenCalled();
});
it('blocks when dialog throws or is cancelled', async () => {
const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus');
const onVisionSwitchRequired = vi.fn().mockRejectedValue(new Error('x'));
const { result } = renderHook(() =>
useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
let res: any;
await act(async () => {
res = await result.current.handleVisionSwitch(parts, 5050, false);
});
expect(res).toEqual({ shouldProceed: false });
expect(config.setModel).not.toHaveBeenCalled();
});
it('does nothing when visionModelPreviewEnabled is false', async () => {
const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus');
const onVisionSwitchRequired = vi.fn();
const { result } = renderHook(() =>
useVisionAutoSwitch(
config,
addItem as any,
false,
onVisionSwitchRequired,
),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
let res: any;
await act(async () => {
res = await result.current.handleVisionSwitch(parts, 6060, false);
});
expect(res).toEqual({ shouldProceed: true });
expect(onVisionSwitchRequired).not.toHaveBeenCalled();
});
describe('YOLO mode behavior', () => {
it('automatically switches to vision model in YOLO mode without showing dialog', async () => {
const initialModel = 'qwen3-coder-plus';
const config = createMockConfig(
AuthType.QWEN_OAUTH,
initialModel,
ApprovalMode.YOLO,
);
const onVisionSwitchRequired = vi.fn(); // Should not be called in YOLO mode
const { result } = renderHook(() =>
useVisionAutoSwitch(
config,
addItem as any,
true,
onVisionSwitchRequired,
),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
let res: any;
await act(async () => {
res = await result.current.handleVisionSwitch(parts, 7070, false);
});
// Should automatically switch without calling the dialog
expect(onVisionSwitchRequired).not.toHaveBeenCalled();
expect(res).toEqual({
shouldProceed: true,
originalModel: initialModel,
});
expect(config.setModel).toHaveBeenCalledWith(getDefaultVisionModel(), {
reason: 'vision_auto_switch',
context: 'YOLO mode auto-switch for image content',
});
});
it('does not switch in YOLO mode when no images are present', async () => {
const config = createMockConfig(
AuthType.QWEN_OAUTH,
'qwen3-coder-plus',
ApprovalMode.YOLO,
);
const onVisionSwitchRequired = vi.fn();
const { result } = renderHook(() =>
useVisionAutoSwitch(
config,
addItem as any,
true,
onVisionSwitchRequired,
),
);
const parts: PartListUnion = [{ text: 'no images here' }];
let res: any;
await act(async () => {
res = await result.current.handleVisionSwitch(parts, 8080, false);
});
expect(res).toEqual({ shouldProceed: true });
expect(onVisionSwitchRequired).not.toHaveBeenCalled();
expect(config.setModel).not.toHaveBeenCalled();
});
it('does not switch in YOLO mode when already using vision model', async () => {
const config = createMockConfig(
AuthType.QWEN_OAUTH,
'vision-model',
ApprovalMode.YOLO,
);
const onVisionSwitchRequired = vi.fn();
const { result } = renderHook(() =>
useVisionAutoSwitch(
config,
addItem as any,
true,
onVisionSwitchRequired,
),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
let res: any;
await act(async () => {
res = await result.current.handleVisionSwitch(parts, 9090, false);
});
expect(res).toEqual({ shouldProceed: true });
expect(onVisionSwitchRequired).not.toHaveBeenCalled();
expect(config.setModel).not.toHaveBeenCalled();
});
it('restores original model after YOLO mode auto-switch', async () => {
const initialModel = 'qwen3-coder-plus';
const config = createMockConfig(
AuthType.QWEN_OAUTH,
initialModel,
ApprovalMode.YOLO,
);
const onVisionSwitchRequired = vi.fn();
const { result } = renderHook(() =>
useVisionAutoSwitch(
config,
addItem as any,
true,
onVisionSwitchRequired,
),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
// First, trigger the auto-switch
await act(async () => {
await result.current.handleVisionSwitch(parts, 10100, false);
});
// Verify model was switched
expect(config.setModel).toHaveBeenCalledWith(getDefaultVisionModel(), {
reason: 'vision_auto_switch',
context: 'YOLO mode auto-switch for image content',
});
// Now restore the original model
await act(async () => {
await result.current.restoreOriginalModel();
});
// Verify model was restored
expect(config.setModel).toHaveBeenLastCalledWith(initialModel, {
reason: 'vision_auto_switch',
context: 'Restoring original model after vision switch',
});
});
it('does not switch in YOLO mode when authType is not QWEN_OAUTH', async () => {
const config = createMockConfig(
AuthType.USE_GEMINI,
'qwen3-coder-plus',
ApprovalMode.YOLO,
);
const onVisionSwitchRequired = vi.fn();
const { result } = renderHook(() =>
useVisionAutoSwitch(
config,
addItem as any,
true,
onVisionSwitchRequired,
),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
let res: any;
await act(async () => {
res = await result.current.handleVisionSwitch(parts, 11110, false);
});
expect(res).toEqual({ shouldProceed: true });
expect(onVisionSwitchRequired).not.toHaveBeenCalled();
expect(config.setModel).not.toHaveBeenCalled();
});
it('does not switch in YOLO mode when visionModelPreviewEnabled is false', async () => {
const config = createMockConfig(
AuthType.QWEN_OAUTH,
'qwen3-coder-plus',
ApprovalMode.YOLO,
);
const onVisionSwitchRequired = vi.fn();
const { result } = renderHook(() =>
useVisionAutoSwitch(
config,
addItem as any,
false,
onVisionSwitchRequired,
),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
let res: any;
await act(async () => {
res = await result.current.handleVisionSwitch(parts, 12120, false);
});
expect(res).toEqual({ shouldProceed: true });
expect(onVisionSwitchRequired).not.toHaveBeenCalled();
expect(config.setModel).not.toHaveBeenCalled();
});
it('handles multiple image formats in YOLO mode', async () => {
const initialModel = 'qwen3-coder-plus';
const config = createMockConfig(
AuthType.QWEN_OAUTH,
initialModel,
ApprovalMode.YOLO,
);
const onVisionSwitchRequired = vi.fn();
const { result } = renderHook(() =>
useVisionAutoSwitch(
config,
addItem as any,
true,
onVisionSwitchRequired,
),
);
const parts: PartListUnion = [
{ text: 'Here are some images:' },
{ inlineData: { mimeType: 'image/jpeg', data: '...' } },
{ fileData: { mimeType: 'image/png', fileUri: 'file://image.png' } },
{ text: 'Please analyze them.' },
];
let res: any;
await act(async () => {
res = await result.current.handleVisionSwitch(parts, 13130, false);
});
expect(res).toEqual({
shouldProceed: true,
originalModel: initialModel,
});
expect(config.setModel).toHaveBeenCalledWith(getDefaultVisionModel(), {
reason: 'vision_auto_switch',
context: 'YOLO mode auto-switch for image content',
});
expect(onVisionSwitchRequired).not.toHaveBeenCalled();
});
});
describe('VLM switch mode default behavior', () => {
it('should automatically switch once when vlmSwitchMode is "once"', async () => {
const config = createMockConfig(
AuthType.QWEN_OAUTH,
'qwen3-coder-plus',
ApprovalMode.DEFAULT,
'once',
);
const onVisionSwitchRequired = vi.fn(); // Should not be called
const { result } = renderHook(() =>
useVisionAutoSwitch(
config,
addItem as any,
true,
onVisionSwitchRequired,
),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/jpeg', data: 'base64data' } },
];
const switchResult = await result.current.handleVisionSwitch(
parts,
Date.now(),
false,
);
expect(switchResult.shouldProceed).toBe(true);
expect(switchResult.originalModel).toBe('qwen3-coder-plus');
expect(config.setModel).toHaveBeenCalledWith('vision-model', {
reason: 'vision_auto_switch',
context: 'Default VLM switch mode: once (one-time override)',
});
expect(onVisionSwitchRequired).not.toHaveBeenCalled();
});
it('should switch session when vlmSwitchMode is "session"', async () => {
const config = createMockConfig(
AuthType.QWEN_OAUTH,
'qwen3-coder-plus',
ApprovalMode.DEFAULT,
'session',
);
const onVisionSwitchRequired = vi.fn(); // Should not be called
const { result } = renderHook(() =>
useVisionAutoSwitch(
config,
addItem as any,
true,
onVisionSwitchRequired,
),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/jpeg', data: 'base64data' } },
];
const switchResult = await result.current.handleVisionSwitch(
parts,
Date.now(),
false,
);
expect(switchResult.shouldProceed).toBe(true);
expect(switchResult.originalModel).toBeUndefined(); // No original model for session switch
expect(config.setModel).toHaveBeenCalledWith('vision-model', {
reason: 'vision_auto_switch',
context: 'Default VLM switch mode: session (session persistent)',
});
expect(onVisionSwitchRequired).not.toHaveBeenCalled();
});
it('should continue with current model when vlmSwitchMode is "persist"', async () => {
const config = createMockConfig(
AuthType.QWEN_OAUTH,
'qwen3-coder-plus',
ApprovalMode.DEFAULT,
'persist',
);
const onVisionSwitchRequired = vi.fn(); // Should not be called
const { result } = renderHook(() =>
useVisionAutoSwitch(
config,
addItem as any,
true,
onVisionSwitchRequired,
),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/jpeg', data: 'base64data' } },
];
const switchResult = await result.current.handleVisionSwitch(
parts,
Date.now(),
false,
);
expect(switchResult.shouldProceed).toBe(true);
expect(switchResult.originalModel).toBeUndefined();
expect(config.setModel).not.toHaveBeenCalled();
expect(onVisionSwitchRequired).not.toHaveBeenCalled();
});
it('should fall back to user prompt when vlmSwitchMode is not set', async () => {
const config = createMockConfig(
AuthType.QWEN_OAUTH,
'qwen3-coder-plus',
ApprovalMode.DEFAULT,
undefined, // No default mode
);
const onVisionSwitchRequired = vi
.fn()
.mockResolvedValue({ modelOverride: 'vision-model' });
const { result } = renderHook(() =>
useVisionAutoSwitch(
config,
addItem as any,
true,
onVisionSwitchRequired,
),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/jpeg', data: 'base64data' } },
];
const switchResult = await result.current.handleVisionSwitch(
parts,
Date.now(),
false,
);
expect(switchResult.shouldProceed).toBe(true);
expect(onVisionSwitchRequired).toHaveBeenCalledWith(parts);
});
it('should fall back to persist behavior when vlmSwitchMode has invalid value', async () => {
const config = createMockConfig(
AuthType.QWEN_OAUTH,
'qwen3-coder-plus',
ApprovalMode.DEFAULT,
'invalid-value',
);
const onVisionSwitchRequired = vi.fn(); // Should not be called
const { result } = renderHook(() =>
useVisionAutoSwitch(
config,
addItem as any,
true,
onVisionSwitchRequired,
),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/jpeg', data: 'base64data' } },
];
const switchResult = await result.current.handleVisionSwitch(
parts,
Date.now(),
false,
);
expect(switchResult.shouldProceed).toBe(true);
expect(switchResult.originalModel).toBeUndefined();
// For invalid values, it should continue with current model (persist behavior)
expect(config.setModel).not.toHaveBeenCalled();
expect(onVisionSwitchRequired).not.toHaveBeenCalled();
});
});
});

View file

@ -1,363 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { type PartListUnion, type Part } from '@google/genai';
import { AuthType, type Config, ApprovalMode } from '@qwen-code/qwen-code-core';
import { useCallback, useRef } from 'react';
import { VisionSwitchOutcome } from '../components/ModelSwitchDialog.js';
import {
getDefaultVisionModel,
isVisionModel,
} from '../models/availableModels.js';
import { MessageType } from '../types.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import {
isSupportedImageMimeType,
getUnsupportedImageFormatWarning,
} from '@qwen-code/qwen-code-core';
/**
* Checks if a PartListUnion contains image parts
*/
function hasImageParts(parts: PartListUnion): boolean {
if (typeof parts === 'string') {
return false;
}
if (Array.isArray(parts)) {
return parts.some((part) => {
// Skip string parts
if (typeof part === 'string') return false;
return isImagePart(part);
});
}
// If it's a single Part (not a string), check if it's an image
if (typeof parts === 'object') {
return isImagePart(parts);
}
return false;
}
/**
* Checks if a single Part is an image part
*/
function isImagePart(part: Part): boolean {
// Check for inlineData with image mime type
if ('inlineData' in part && part.inlineData?.mimeType?.startsWith('image/')) {
return true;
}
// Check for fileData with image mime type
if ('fileData' in part && part.fileData?.mimeType?.startsWith('image/')) {
return true;
}
return false;
}
/**
* Checks if image parts have supported formats and returns unsupported ones
*/
function checkImageFormatsSupport(parts: PartListUnion): {
hasImages: boolean;
hasUnsupportedFormats: boolean;
unsupportedMimeTypes: string[];
} {
const unsupportedMimeTypes: string[] = [];
let hasImages = false;
if (typeof parts === 'string') {
return {
hasImages: false,
hasUnsupportedFormats: false,
unsupportedMimeTypes: [],
};
}
const partsArray = Array.isArray(parts) ? parts : [parts];
for (const part of partsArray) {
if (typeof part === 'string') continue;
let mimeType: string | undefined;
// Check inlineData
if (
'inlineData' in part &&
part.inlineData?.mimeType?.startsWith('image/')
) {
hasImages = true;
mimeType = part.inlineData.mimeType;
}
// Check fileData
if ('fileData' in part && part.fileData?.mimeType?.startsWith('image/')) {
hasImages = true;
mimeType = part.fileData.mimeType;
}
// Check if the mime type is supported
if (mimeType && !isSupportedImageMimeType(mimeType)) {
unsupportedMimeTypes.push(mimeType);
}
}
return {
hasImages,
hasUnsupportedFormats: unsupportedMimeTypes.length > 0,
unsupportedMimeTypes,
};
}
/**
* Determines if we should offer vision switch for the given parts, auth type, and current model
*/
export function shouldOfferVisionSwitch(
parts: PartListUnion,
authType: AuthType,
currentModel: string,
visionModelPreviewEnabled: boolean = true,
): boolean {
// Only trigger for qwen-oauth
if (authType !== AuthType.QWEN_OAUTH) {
return false;
}
// If vision model preview is disabled, never offer vision switch
if (!visionModelPreviewEnabled) {
return false;
}
// If current model is already a vision model, no need to switch
if (isVisionModel(currentModel)) {
return false;
}
// Check if the current message contains image parts
return hasImageParts(parts);
}
/**
* Interface for vision switch result
*/
export interface VisionSwitchResult {
modelOverride?: string;
persistSessionModel?: string;
showGuidance?: boolean;
}
/**
* Processes the vision switch outcome and returns the appropriate result
*/
export function processVisionSwitchOutcome(
outcome: VisionSwitchOutcome,
): VisionSwitchResult {
const vlModelId = getDefaultVisionModel();
switch (outcome) {
case VisionSwitchOutcome.SwitchOnce:
return { modelOverride: vlModelId };
case VisionSwitchOutcome.SwitchSessionToVL:
return { persistSessionModel: vlModelId };
case VisionSwitchOutcome.ContinueWithCurrentModel:
return {}; // Continue with current model, no changes needed
default:
return {}; // Default to continuing with current model
}
}
/**
* Gets the guidance message for when vision switch is disallowed
*/
export function getVisionSwitchGuidanceMessage(): string {
const vlModelId = getDefaultVisionModel();
return `To use images with your query, you can:
Use /model set ${vlModelId} to switch to a vision-capable model
Or remove the image and provide a text description instead`;
}
/**
* Interface for vision switch handling result
*/
export interface VisionSwitchHandlingResult {
shouldProceed: boolean;
originalModel?: string;
}
/**
* Custom hook for handling vision model auto-switching
*/
export function useVisionAutoSwitch(
config: Config,
addItem: UseHistoryManagerReturn['addItem'],
visionModelPreviewEnabled: boolean = true,
onVisionSwitchRequired?: (query: PartListUnion) => Promise<{
modelOverride?: string;
persistSessionModel?: string;
showGuidance?: boolean;
}>,
) {
const originalModelRef = useRef<string | null>(null);
const handleVisionSwitch = useCallback(
async (
query: PartListUnion,
userMessageTimestamp: number,
isContinuation: boolean,
): Promise<VisionSwitchHandlingResult> => {
// Skip vision switch handling for continuations or if no handler provided
if (isContinuation || !onVisionSwitchRequired) {
return { shouldProceed: true };
}
const contentGeneratorConfig = config.getContentGeneratorConfig();
// Only handle qwen-oauth auth type
if (contentGeneratorConfig?.authType !== AuthType.QWEN_OAUTH) {
return { shouldProceed: true };
}
// Check image format support first
const formatCheck = checkImageFormatsSupport(query);
// If there are unsupported image formats, show warning
if (formatCheck.hasUnsupportedFormats) {
addItem(
{
type: MessageType.INFO,
text: getUnsupportedImageFormatWarning(),
},
userMessageTimestamp,
);
// Continue processing but with warning shown
}
// Check if vision switch is needed
if (
!shouldOfferVisionSwitch(
query,
contentGeneratorConfig.authType,
config.getModel(),
visionModelPreviewEnabled,
)
) {
return { shouldProceed: true };
}
// In YOLO mode, automatically switch to vision model without user interaction
if (config.getApprovalMode() === ApprovalMode.YOLO) {
const vlModelId = getDefaultVisionModel();
originalModelRef.current = config.getModel();
await config.setModel(vlModelId, {
reason: 'vision_auto_switch',
context: 'YOLO mode auto-switch for image content',
});
return {
shouldProceed: true,
originalModel: originalModelRef.current,
};
}
// Check if there's a default VLM switch mode configured
const defaultVlmSwitchMode = config.getVlmSwitchMode();
if (defaultVlmSwitchMode) {
// Convert string value to VisionSwitchOutcome enum
let outcome: VisionSwitchOutcome;
switch (defaultVlmSwitchMode) {
case 'once':
outcome = VisionSwitchOutcome.SwitchOnce;
break;
case 'session':
outcome = VisionSwitchOutcome.SwitchSessionToVL;
break;
case 'persist':
outcome = VisionSwitchOutcome.ContinueWithCurrentModel;
break;
default:
// Invalid value, fall back to prompting user
outcome = VisionSwitchOutcome.ContinueWithCurrentModel;
}
// Process the default outcome
const visionSwitchResult = processVisionSwitchOutcome(outcome);
if (visionSwitchResult.modelOverride) {
// One-time model override
originalModelRef.current = config.getModel();
await config.setModel(visionSwitchResult.modelOverride, {
reason: 'vision_auto_switch',
context: `Default VLM switch mode: ${defaultVlmSwitchMode} (one-time override)`,
});
return {
shouldProceed: true,
originalModel: originalModelRef.current,
};
} else if (visionSwitchResult.persistSessionModel) {
// Persistent session model change
await config.setModel(visionSwitchResult.persistSessionModel, {
reason: 'vision_auto_switch',
context: `Default VLM switch mode: ${defaultVlmSwitchMode} (session persistent)`,
});
return { shouldProceed: true };
}
// For ContinueWithCurrentModel or any other case, proceed with current model
return { shouldProceed: true };
}
try {
const visionSwitchResult = await onVisionSwitchRequired(query);
if (visionSwitchResult.modelOverride) {
// One-time model override
originalModelRef.current = config.getModel();
await config.setModel(visionSwitchResult.modelOverride, {
reason: 'vision_auto_switch',
context: 'User-prompted vision switch (one-time override)',
});
return {
shouldProceed: true,
originalModel: originalModelRef.current,
};
} else if (visionSwitchResult.persistSessionModel) {
// Persistent session model change
await config.setModel(visionSwitchResult.persistSessionModel, {
reason: 'vision_auto_switch',
context: 'User-prompted vision switch (session persistent)',
});
return { shouldProceed: true };
}
// For ContinueWithCurrentModel or any other case, proceed with current model
return { shouldProceed: true };
} catch (_error) {
// If vision switch dialog was cancelled or errored, don't proceed
return { shouldProceed: false };
}
},
[config, addItem, visionModelPreviewEnabled, onVisionSwitchRequired],
);
const restoreOriginalModel = useCallback(async () => {
if (originalModelRef.current) {
await config.setModel(originalModelRef.current, {
reason: 'vision_auto_switch',
context: 'Restoring original model after vision switch',
});
originalModelRef.current = null;
}
}, [config]);
return {
handleVisionSwitch,
restoreOriginalModel,
};
}

View file

@ -59,6 +59,7 @@ describe('keyMatchers', () => {
[Command.QUIT]: (key: Key) => key.ctrl && key.name === 'c',
[Command.EXIT]: (key: Key) => key.ctrl && key.name === 'd',
[Command.SHOW_MORE_LINES]: (key: Key) => key.ctrl && key.name === 's',
[Command.RETRY_LAST]: (key: Key) => key.ctrl && key.name === 'y',
[Command.REVERSE_SEARCH]: (key: Key) => key.ctrl && key.name === 'r',
[Command.SUBMIT_REVERSE_SEARCH]: (key: Key) =>
key.name === 'return' && !key.ctrl,
@ -252,6 +253,11 @@ describe('keyMatchers', () => {
positive: [createKey('s', { ctrl: true })],
negative: [createKey('s'), createKey('l', { ctrl: true })],
},
{
command: Command.RETRY_LAST,
positive: [createKey('y', { ctrl: true })],
negative: [createKey('y'), createKey('r', { ctrl: true })],
},
// Shell commands
{

View file

@ -9,42 +9,30 @@ import {
getAvailableModelsForAuthType,
getFilteredQwenModels,
getOpenAIAvailableModelFromEnv,
isVisionModel,
getDefaultVisionModel,
AVAILABLE_MODELS_QWEN,
MAINLINE_VLM,
MAINLINE_CODER,
} from './availableModels.js';
import { AuthType, type Config } from '@qwen-code/qwen-code-core';
describe('availableModels', () => {
describe('AVAILABLE_MODELS_QWEN', () => {
it('should include coder model', () => {
const coderModel = AVAILABLE_MODELS_QWEN.find(
(m) => m.id === MAINLINE_CODER,
);
expect(coderModel).toBeDefined();
expect(coderModel?.isVision).toBeFalsy();
describe('Qwen models', () => {
const qwenModels = getFilteredQwenModels();
it('should include only coder-model', () => {
expect(qwenModels.length).toBe(1);
expect(qwenModels[0].id).toBe('coder-model');
});
it('should include vision model', () => {
const visionModel = AVAILABLE_MODELS_QWEN.find(
(m) => m.id === MAINLINE_VLM,
);
expect(visionModel).toBeDefined();
expect(visionModel?.isVision).toBe(true);
it('should have coder-model with vision capability', () => {
const coderModel = qwenModels[0];
expect(coderModel.isVision).toBe(true);
});
});
describe('getFilteredQwenModels', () => {
it('should return all models when vision preview is enabled', () => {
const models = getFilteredQwenModels(true);
expect(models.length).toBe(AVAILABLE_MODELS_QWEN.length);
});
it('should filter out vision models when preview is disabled', () => {
const models = getFilteredQwenModels(false);
expect(models.every((m) => !m.isVision)).toBe(true);
it('should return coder-model with vision capability', () => {
const models = getFilteredQwenModels();
expect(models.length).toBe(1);
expect(models[0].id).toBe('coder-model');
expect(models[0].isVision).toBe(true);
});
});
@ -91,23 +79,36 @@ describe('availableModels', () => {
it('should return hard-coded qwen models for qwen-oauth', () => {
const models = getAvailableModelsForAuthType(AuthType.QWEN_OAUTH);
expect(models).toEqual(AVAILABLE_MODELS_QWEN);
expect(models.length).toBe(1);
expect(models[0].id).toBe('coder-model');
expect(models[0].isVision).toBe(true);
});
it('should return hard-coded qwen models even when config is provided', () => {
it('should use config models for qwen-oauth when config is provided', () => {
const mockConfig = {
getAvailableModels: vi
.fn()
.mockReturnValue([
{ id: 'custom', label: 'Custom', authType: AuthType.QWEN_OAUTH },
]),
getAvailableModelsForAuthType: vi.fn().mockReturnValue([
{
id: 'custom',
label: 'Custom',
description: 'Custom model',
authType: AuthType.QWEN_OAUTH,
isVision: false,
},
]),
} as unknown as Config;
const models = getAvailableModelsForAuthType(
AuthType.QWEN_OAUTH,
mockConfig,
);
expect(models).toEqual(AVAILABLE_MODELS_QWEN);
expect(models).toEqual([
{
id: 'custom',
label: 'Custom',
description: 'Custom model',
isVision: false,
},
]);
});
it('should use config.getAvailableModels for openai authType when available', () => {
@ -182,24 +183,4 @@ describe('availableModels', () => {
expect(models).toEqual([]);
});
});
describe('isVisionModel', () => {
it('should return true for vision model', () => {
expect(isVisionModel(MAINLINE_VLM)).toBe(true);
});
it('should return false for non-vision model', () => {
expect(isVisionModel(MAINLINE_CODER)).toBe(false);
});
it('should return false for unknown model', () => {
expect(isVisionModel('unknown-model')).toBe(false);
});
});
describe('getDefaultVisionModel', () => {
it('should return the vision model ID', () => {
expect(getDefaultVisionModel()).toBe(MAINLINE_VLM);
});
});
});

View file

@ -6,9 +6,9 @@
import {
AuthType,
DEFAULT_QWEN_MODEL,
type Config,
type AvailableModel as CoreAvailableModel,
QWEN_OAUTH_MODELS,
} from '@qwen-code/qwen-code-core';
import { t } from '../../i18n/index.js';
@ -19,41 +19,25 @@ export type AvailableModel = {
isVision?: boolean;
};
export const MAINLINE_VLM = 'vision-model';
export const MAINLINE_CODER = DEFAULT_QWEN_MODEL;
const CACHED_QWEN_OAUTH_MODELS: AvailableModel[] = QWEN_OAUTH_MODELS.map(
(model) => ({
id: model.id,
label: model.name ?? model.id,
description: model.description,
isVision: model.capabilities?.vision ?? false,
}),
);
export const AVAILABLE_MODELS_QWEN: AvailableModel[] = [
{
id: MAINLINE_CODER,
label: MAINLINE_CODER,
get description() {
return t(
'Qwen 3.5 Plus — efficient hybrid model with leading coding performance',
);
},
},
{
id: MAINLINE_VLM,
label: MAINLINE_VLM,
get description() {
return t(
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)',
);
},
isVision: true,
},
];
function getQwenOAuthModels(): readonly AvailableModel[] {
return CACHED_QWEN_OAUTH_MODELS;
}
/**
* Get available Qwen models filtered by vision model preview setting
* Get available Qwen models
* coder-model now has vision capabilities by default.
*/
export function getFilteredQwenModels(
visionModelPreviewEnabled: boolean,
): AvailableModel[] {
if (visionModelPreviewEnabled) {
return AVAILABLE_MODELS_QWEN;
}
return AVAILABLE_MODELS_QWEN.filter((model) => !model.isVision);
export function getFilteredQwenModels(): AvailableModel[] {
return [...getQwenOAuthModels()];
}
/**
@ -104,18 +88,12 @@ function convertCoreModelToCliModel(
* Get available models for the given authType.
*
* If a Config object is provided, uses config.getAvailableModelsForAuthType().
* For qwen-oauth, always returns the hard-coded models.
* Falls back to environment variables only when no config is provided.
*/
export function getAvailableModelsForAuthType(
authType: AuthType,
config?: Config,
): AvailableModel[] {
// For qwen-oauth, always use hard-coded models, this aligns with the API gateway.
if (authType === AuthType.QWEN_OAUTH) {
return AVAILABLE_MODELS_QWEN;
}
// Use config's model registry when available
if (config) {
try {
@ -134,6 +112,9 @@ export function getAvailableModelsForAuthType(
// Fall back to environment variables for specific auth types (no config provided)
switch (authType) {
case AuthType.QWEN_OAUTH: {
return [...getQwenOAuthModels()];
}
case AuthType.USE_OPENAI: {
const openAIModel = getOpenAIAvailableModelFromEnv();
return openAIModel ? [openAIModel] : [];
@ -146,17 +127,3 @@ export function getAvailableModelsForAuthType(
return [];
}
}
/**
* Hard code the default vision model as a string literal,
* until our coding model supports multimodal.
*/
export function getDefaultVisionModel(): string {
return MAINLINE_VLM;
}
export function isVisionModel(modelId: string): boolean {
return AVAILABLE_MODELS_QWEN.some(
(model) => model.id === modelId && model.isVision,
);
}

View file

@ -33,6 +33,7 @@ const noColorSemanticColors: SemanticColors = {
secondary: '',
link: '',
accent: '',
code: '',
},
background: {
primary: '',

View file

@ -12,6 +12,7 @@ export interface SemanticColors {
secondary: string;
link: string;
accent: string;
code: string;
};
background: {
primary: string;
@ -45,6 +46,7 @@ export const lightSemanticColors: SemanticColors = {
secondary: lightTheme.Gray,
link: lightTheme.AccentBlue,
accent: lightTheme.AccentPurple,
code: lightTheme.LightBlue,
},
background: {
primary: lightTheme.Background,
@ -77,6 +79,7 @@ export const darkSemanticColors: SemanticColors = {
secondary: darkTheme.Gray,
link: darkTheme.AccentBlue,
accent: darkTheme.AccentPurple,
code: darkTheme.LightBlue,
},
background: {
primary: darkTheme.Background,
@ -109,6 +112,7 @@ export const ansiSemanticColors: SemanticColors = {
secondary: ansiTheme.Gray,
link: ansiTheme.AccentBlue,
accent: ansiTheme.AccentPurple,
code: ansiTheme.LightBlue,
},
background: {
primary: ansiTheme.Background,

View file

@ -40,6 +40,7 @@ export interface CustomTheme {
secondary?: string;
link?: string;
accent?: string;
code?: string;
};
background?: {
primary?: string;
@ -174,6 +175,7 @@ export class Theme {
secondary: this.colors.Gray,
link: this.colors.AccentBlue,
accent: this.colors.AccentPurple,
code: this.colors.LightBlue,
},
background: {
primary: this.colors.Background,
@ -269,7 +271,7 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
type: 'custom',
Background: customTheme.background?.primary ?? customTheme.Background ?? '',
Foreground: customTheme.text?.primary ?? customTheme.Foreground ?? '',
LightBlue: customTheme.text?.link ?? customTheme.LightBlue ?? '',
LightBlue: customTheme.text?.code ?? customTheme.LightBlue ?? '',
AccentBlue: customTheme.text?.link ?? customTheme.AccentBlue ?? '',
AccentPurple: customTheme.text?.accent ?? customTheme.AccentPurple ?? '',
AccentCyan: customTheme.text?.link ?? customTheme.AccentCyan ?? '',
@ -433,6 +435,7 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
secondary: customTheme.text?.secondary ?? colors.Gray,
link: customTheme.text?.link ?? colors.AccentBlue,
accent: customTheme.text?.accent ?? colors.AccentPurple,
code: customTheme.text?.code ?? colors.LightBlue,
},
background: {
primary: customTheme.background?.primary ?? colors.Background,

View file

@ -121,6 +121,7 @@ export type HistoryItemInfo = HistoryItemBase & {
export type HistoryItemError = HistoryItemBase & {
type: 'error';
text: string;
hint?: string; // Optional inline hint (e.g., retry countdown) displayed in secondary color
};
export type HistoryItemWarning = HistoryItemBase & {
@ -256,6 +257,11 @@ export type HistoryItemMcpStatus = HistoryItemBase & {
showTips: boolean;
};
export type HistoryItemInsightProgress = HistoryItemBase & {
type: 'insight_progress';
progress: InsightProgressProps;
};
// Using Omit<HistoryItem, 'id'> seems to have some issues with typescript's
// type inference e.g. historyItem.type === 'tool_group' isn't auto-inferring that
// 'tools' in historyItem.
@ -284,7 +290,8 @@ export type HistoryItemWithoutId =
| HistoryItemExtensionsList
| HistoryItemToolsList
| HistoryItemSkillsList
| HistoryItemMcpStatus;
| HistoryItemMcpStatus
| HistoryItemInsightProgress;
export type HistoryItem = HistoryItemWithoutId & { id: number };
@ -307,6 +314,15 @@ export enum MessageType {
TOOLS_LIST = 'tools_list',
SKILLS_LIST = 'skills_list',
MCP_STATUS = 'mcp_status',
INSIGHT_PROGRESS = 'insight_progress',
}
export interface InsightProgressProps {
stage: string;
progress: number;
detail?: string;
isComplete?: boolean;
error?: string;
}
// Simplified message structure for internal feedback
@ -373,6 +389,11 @@ export type Message =
type: MessageType.SUMMARY;
summary: SummaryProps;
timestamp: Date;
}
| {
type: MessageType.INSIGHT_PROGRESS;
progress: InsightProgressProps;
timestamp: Date;
};
export interface ConsoleMessageItem {

View file

@ -125,6 +125,7 @@ export function colorizeLine(
*
* @param code The code string to highlight.
* @param language The language identifier (e.g., 'javascript', 'css', 'html')
* @param tabWidth The number of spaces to replace each tab character with, default is 4
* @returns A React.ReactNode containing Ink <Text> elements for the highlighted code.
*/
export function colorizeCode(
@ -134,8 +135,11 @@ export function colorizeCode(
maxWidth?: number,
theme?: Theme,
settings?: LoadedSettings,
tabWidth = 4,
): React.ReactNode {
const codeToHighlight = code.replace(/\n$/, '');
const codeToHighlight = code
.replace(/\n$/, '')
.replace(/\t/g, ' '.repeat(tabWidth));
const activeTheme = theme || themeManager.getActiveTheme();
const showLineNumbers = settings?.merged.ui?.showLineNumbers ?? true;

View file

@ -5,7 +5,7 @@
*/
import type { ExportSessionData } from '../types.js';
import { HTML_TEMPLATE } from './htmlTemplate.js';
import { EXPORT_HTML_TEMPLATE as HTML_TEMPLATE } from '@qwen-code/web-templates';
/**
* Escapes JSON for safe embedding in HTML.

File diff suppressed because one or more lines are too long