Merge branch 'main' into feat/support-insight-command

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
tanzhenxin 2026-02-26 21:05:41 +08:00
commit a172696b86
150 changed files with 9730 additions and 2047 deletions

View file

@ -94,6 +94,7 @@ import {
useSettingInputRequests,
usePluginChoiceRequests,
} from './hooks/useExtensionUpdates.js';
import { useCodingPlanUpdates } from './hooks/useCodingPlanUpdates.js';
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
import { t } from '../i18n/index.js';
import { useWelcomeBack } from './hooks/useWelcomeBack.js';
@ -232,6 +233,9 @@ export const AppContainer = (props: AppContainerProps) => {
config.getWorkingDir(),
);
const { codingPlanUpdateRequest, dismissCodingPlanUpdate } =
useCodingPlanUpdates(settings, config, historyManager.addItem);
const [isPermissionsDialogOpen, setPermissionsDialogOpen] = useState(false);
const openPermissionsDialog = useCallback(
() => setPermissionsDialogOpen(true),
@ -402,6 +406,7 @@ export const AppContainer = (props: AppContainerProps) => {
pendingAuthType,
qwenAuthState,
handleAuthSelect,
handleCodingPlanSubmit,
openAuthDialog,
cancelAuthentication,
} = useAuthCommand(settings, config, historyManager.addItem, refreshStatic);
@ -687,7 +692,6 @@ export const AppContainer = (props: AppContainerProps) => {
terminalWidth,
terminalHeight,
handleVisionSwitchRequired, // onVisionSwitchRequired
embeddedShellFocused,
);
// Track whether suggestions are visible for Tab key handling
@ -895,6 +899,8 @@ export const AppContainer = (props: AppContainerProps) => {
const ctrlCTimerRef = useRef<NodeJS.Timeout | null>(null);
const [ctrlDPressedOnce, setCtrlDPressedOnce] = useState(false);
const ctrlDTimerRef = useRef<NodeJS.Timeout | null>(null);
const [escapePressedOnce, setEscapePressedOnce] = useState(false);
const escapeTimerRef = useRef<NodeJS.Timeout | null>(null);
const [constrainHeight, setConstrainHeight] = useState<boolean>(true);
const [ideContextState, setIdeContextState] = useState<
IdeContext | undefined
@ -1171,6 +1177,47 @@ export const AppContainer = (props: AppContainerProps) => {
}
handleExit(ctrlDPressedOnce, setCtrlDPressedOnce, ctrlDTimerRef);
return;
} else if (keyMatchers[Command.ESCAPE](key)) {
// Escape key handling
// Skip if shell is focused (to allow shell's own escape handling)
if (embeddedShellFocused) {
return;
}
// If input has content, use double-press to clear
if (buffer.text.length > 0) {
if (escapePressedOnce) {
// Second press: clear input, keep the flag to allow immediate cancel
buffer.setText('');
return;
}
// First press: set flag and show prompt
setEscapePressedOnce(true);
escapeTimerRef.current = setTimeout(() => {
setEscapePressedOnce(false);
escapeTimerRef.current = null;
}, CTRL_EXIT_PROMPT_DURATION_MS);
return;
}
// Input is empty, cancel request immediately (no double-press needed)
if (streamingState === StreamingState.Responding) {
if (escapeTimerRef.current) {
clearTimeout(escapeTimerRef.current);
escapeTimerRef.current = null;
}
cancelOngoingRequest?.();
setEscapePressedOnce(false);
return;
}
// No action available, reset the flag
if (escapeTimerRef.current) {
clearTimeout(escapeTimerRef.current);
escapeTimerRef.current = null;
}
setEscapePressedOnce(false);
return;
}
let enteringConstrainHeightMode = false;
@ -1215,10 +1262,15 @@ export const AppContainer = (props: AppContainerProps) => {
ctrlCPressedOnce,
setCtrlCPressedOnce,
ctrlCTimerRef,
buffer.text.length,
ctrlDPressedOnce,
setCtrlDPressedOnce,
ctrlDTimerRef,
escapePressedOnce,
setEscapePressedOnce,
escapeTimerRef,
streamingState,
cancelOngoingRequest,
buffer,
handleSlashCommand,
activePtyId,
embeddedShellFocused,
@ -1275,6 +1327,7 @@ export const AppContainer = (props: AppContainerProps) => {
!!shellConfirmationRequest ||
!!confirmationRequest ||
confirmUpdateExtensionRequests.length > 0 ||
!!codingPlanUpdateRequest ||
settingInputRequests.length > 0 ||
pluginChoiceRequests.length > 0 ||
!!loopDetectionConfirmationRequest ||
@ -1339,6 +1392,7 @@ export const AppContainer = (props: AppContainerProps) => {
shellConfirmationRequest,
confirmationRequest,
confirmUpdateExtensionRequests,
codingPlanUpdateRequest,
settingInputRequests,
pluginChoiceRequests,
loopDetectionConfirmationRequest,
@ -1429,6 +1483,7 @@ export const AppContainer = (props: AppContainerProps) => {
shellConfirmationRequest,
confirmationRequest,
confirmUpdateExtensionRequests,
codingPlanUpdateRequest,
settingInputRequests,
pluginChoiceRequests,
loopDetectionConfirmationRequest,
@ -1508,10 +1563,12 @@ export const AppContainer = (props: AppContainerProps) => {
setAuthState,
onAuthError,
cancelAuthentication,
handleCodingPlanSubmit,
handleEditorSelect,
exitEditorDialog,
closeSettingsDialog,
closeModelDialog,
dismissCodingPlanUpdate,
closePermissionsDialog,
setShellModeActive,
vimHandleInput,
@ -1552,10 +1609,12 @@ export const AppContainer = (props: AppContainerProps) => {
setAuthState,
onAuthError,
cancelAuthentication,
handleCodingPlanSubmit,
handleEditorSelect,
exitEditorDialog,
closeSettingsDialog,
closeModelDialog,
dismissCodingPlanUpdate,
closePermissionsDialog,
setShellModeActive,
vimHandleInput,

View file

@ -169,9 +169,9 @@ describe('AuthDialog', () => {
const { lastFrame } = renderAuthDialog(settings);
// Since the auth dialog only shows OpenAI option now,
// Since the auth dialog shows API-KEY option now,
// it won't show GEMINI_API_KEY messages
expect(lastFrame()).toContain('OpenAI');
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,15 +257,17 @@ describe('AuthDialog', () => {
const { lastFrame } = renderAuthDialog(settings);
// Since the auth dialog only shows OpenAI option now,
// Since the auth dialog shows API-KEY option now,
// it won't show GEMINI_API_KEY messages
expect(lastFrame()).toContain('OpenAI');
expect(lastFrame()).toContain('API-KEY');
});
});
describe('QWEN_DEFAULT_AUTH_TYPE environment variable', () => {
it('should select the auth type specified by QWEN_DEFAULT_AUTH_TYPE', () => {
process.env['QWEN_DEFAULT_AUTH_TYPE'] = AuthType.USE_OPENAI;
// QWEN_OAUTH is the only valid AuthType that can be selected via env var
// API-KEY is not an AuthType enum value, so it cannot be selected this way
process.env['QWEN_DEFAULT_AUTH_TYPE'] = AuthType.QWEN_OAUTH;
const settings: LoadedSettings = new LoadedSettings(
{
@ -302,8 +304,8 @@ describe('AuthDialog', () => {
const { lastFrame } = renderAuthDialog(settings);
// This is a bit brittle, but it's the best way to check which item is selected.
expect(lastFrame()).toContain('● 2. OpenAI');
// QWEN_OAUTH is the first option, so it should be selected
expect(lastFrame()).toContain('● 1. Qwen OAuth');
});
it('should fall back to default if QWEN_DEFAULT_AUTH_TYPE is not set', () => {

View file

@ -8,13 +8,19 @@ import type React from 'react';
import { useState } from 'react';
import { AuthType } from '@qwen-code/qwen-code-core';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
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 { 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';
const MODEL_PROVIDERS_DOCUMENTATION_URL =
'https://qwenlm.github.io/qwen-code-docs/en/users/configuration/settings/#modelproviders';
function parseDefaultAuthType(
defaultAuthType: string | undefined,
@ -28,30 +34,65 @@ function parseDefaultAuthType(
return null;
}
// Sub-mode types for API-KEY authentication
type ApiKeySubMode = 'coding-plan' | 'coding-plan-intl' | 'custom';
// View level for navigation
type ViewLevel = 'main' | 'api-key-sub' | 'api-key-input' | 'custom-info';
export function AuthDialog(): React.JSX.Element {
const { pendingAuthType, authError } = useUIState();
const { handleAuthSelect: onAuthSelect } = useUIActions();
const {
handleAuthSelect: onAuthSelect,
handleCodingPlanSubmit,
onAuthError,
} = useUIActions();
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 [region, setRegion] = useState<CodingPlanRegion>(
CodingPlanRegion.CHINA,
);
const items = [
// Main authentication entries
const mainItems = [
{
key: AuthType.QWEN_OAUTH,
label: t('Qwen OAuth'),
value: AuthType.QWEN_OAUTH,
},
{
key: AuthType.USE_OPENAI,
label: t('OpenAI'),
value: AuthType.USE_OPENAI,
key: 'API-KEY',
label: t('API-KEY'),
value: 'API-KEY' as const,
},
];
// API-KEY sub-mode entries
const apiKeySubItems = [
{
key: 'coding-plan',
label: t('Coding Plan (Bailian, China)'),
value: 'coding-plan' as ApiKeySubMode,
},
{
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,
},
];
const initialAuthIndex = Math.max(
0,
items.findIndex((item) => {
mainItems.findIndex((item) => {
// Priority 1: pendingAuthType
if (pendingAuthType) {
return item.value === pendingAuthType;
@ -79,29 +120,84 @@ export function AuthDialog(): React.JSX.Element {
const hasApiKey = Boolean(config.getContentGeneratorConfig()?.apiKey);
const currentSelectedAuthType =
selectedIndex !== null
? items[selectedIndex]?.value
: items[initialAuthIndex]?.value;
? mainItems[selectedIndex]?.value
: mainItems[initialAuthIndex]?.value;
const handleAuthSelect = async (authMethod: AuthType) => {
const handleMainSelect = async (
value: (typeof mainItems)[number]['value'],
) => {
setErrorMessage(null);
await onAuthSelect(authMethod);
onAuthError(null);
if (value === 'API-KEY') {
// Navigate to API-KEY sub-mode selection
setViewLevel('api-key-sub');
return;
}
// For Qwen OAuth, proceed directly
await onAuthSelect(value);
};
const handleHighlight = (authMethod: AuthType) => {
const index = items.findIndex((item) => item.value === authMethod);
setSelectedIndex(index);
const handleApiKeySubSelect = async (subMode: ApiKeySubMode) => {
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');
}
};
const handleApiKeyInputSubmit = async (apiKey: string) => {
setErrorMessage(null);
if (!apiKey.trim()) {
setErrorMessage(t('API key cannot be empty.'));
return;
}
// Submit to parent for processing with region info
await handleCodingPlanSubmit(apiKey, region);
};
const handleGoBack = () => {
setErrorMessage(null);
onAuthError(null);
if (viewLevel === 'api-key-sub') {
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');
}
};
useKeypress(
(key) => {
if (key.name === 'escape') {
// Prevent exit if there is an error message.
// This means they user is not authenticated yet.
// Handle Escape based on current view level
if (viewLevel === 'api-key-sub') {
handleGoBack();
return;
}
if (viewLevel === 'api-key-input' || viewLevel === 'custom-info') {
handleGoBack();
return;
}
// For main view, use existing logic
if (errorMessage) {
return;
}
if (config.getAuthType() === undefined) {
// Prevent exiting if no auth method is set
setErrorMessage(
t(
'You must select an auth method to proceed. Press Ctrl+C again to exit.',
@ -115,51 +211,218 @@ export function AuthDialog(): React.JSX.Element {
{ isActive: true },
);
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold>{t('Get started')}</Text>
// Render main auth selection
const renderMainView = () => (
<>
<Box marginTop={1}>
<Text>{t('How would you like to authenticate for this project?')}</Text>
</Box>
<Box marginTop={1}>
<RadioButtonSelect
items={items}
items={mainItems}
initialIndex={initialAuthIndex}
onSelect={handleAuthSelect}
onHighlight={handleHighlight}
onSelect={handleMainSelect}
onHighlight={(value) => {
const index = mainItems.findIndex((item) => item.value === value);
setSelectedIndex(index);
}}
/>
</Box>
{(authError || errorMessage) && (
<Box marginTop={1}>
<Text color={Colors.AccentRed}>{authError || errorMessage}</Text>
</Box>
)}
<Box marginTop={1}>
<Text color={Colors.AccentPurple}>{t('(Use Enter to Set Auth)')}</Text>
</Box>
{hasApiKey && currentSelectedAuthType === AuthType.QWEN_OAUTH && (
<Box marginTop={1}>
<Text color={Colors.Gray}>
{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.',
)}
</Text>
</Box>
)}
<Box marginTop={1}>
<Text>{t('Terms of Services and Privacy Notice for Qwen Code')}</Text>
</Box>
<Box marginTop={1}>
<Text color={Colors.AccentBlue}>
{'https://github.com/QwenLM/Qwen3-Coder/blob/main/README.md'}
<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 = () => (
<>
<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>
</Box>
<Box marginTop={1}>
<Text color={theme?.text?.secondary}>
{t('(Press Escape to go back)')}
</Text>
</Box>
</>
);
// Render API key input for coding-plan mode
const renderApiKeyInputView = () => (
<Box marginTop={1}>
<ApiKeyInput
onSubmit={handleApiKeyInputSubmit}
onCancel={handleGoBack}
region={region}
/>
</Box>
);
// Render custom mode info
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>
</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>
</Box>
<Box marginTop={0}>
<Link url={MODEL_PROVIDERS_DOCUMENTATION_URL} fallback={false}>
<Text color={theme.status.success} underline>
{MODEL_PROVIDERS_DOCUMENTATION_URL}
</Text>
</Link>
</Box>
<Box marginTop={1}>
<Text color={theme?.text?.secondary}>
{t('(Press Escape to go back)')}
</Text>
</Box>
</>
);
const getViewTitle = () => {
switch (viewLevel) {
case 'main':
return t('Get started');
case 'api-key-sub':
return t('API-KEY Configuration');
case 'api-key-input':
return t('Coding Plan Setup');
case 'custom-info':
return t('Custom Configuration');
default:
return t('Get started');
}
};
return (
<Box
borderStyle="round"
borderColor={theme?.border?.default}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold>{getViewTitle()}</Text>
{viewLevel === 'main' && renderMainView()}
{viewLevel === 'api-key-sub' && renderApiKeySubView()}
{viewLevel === 'api-key-input' && renderApiKeyInputView()}
{viewLevel === 'custom-info' && renderCustomInfoView()}
{(authError || errorMessage) && (
<Box marginTop={1}>
<Text color={theme.status.error}>{authError || errorMessage}</Text>
</Box>
)}
{viewLevel === 'main' && (
<>
<Box marginTop={1}>
<Text color={theme.text.accent}>
{t('(Use Enter to Set Auth)')}
</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.',
)}
</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>
</Box>
</>
)}
</Box>
);
}

View file

@ -8,6 +8,7 @@ import type {
Config,
ContentGeneratorConfig,
ModelProvidersConfig,
ProviderModelConfig,
} from '@qwen-code/qwen-code-core';
import {
AuthEvent,
@ -18,11 +19,22 @@ import {
import { useCallback, useEffect, useState } from 'react';
import type { LoadedSettings } from '../../config/settings.js';
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
// OpenAICredentials type (previously imported from OpenAIKeyPrompt)
export interface OpenAICredentials {
apiKey: string;
baseUrl?: string;
model?: string;
}
import { useQwenAuth } from '../hooks/useQwenAuth.js';
import { AuthState, MessageType } from '../types.js';
import type { HistoryItem } from '../types.js';
import { t } from '../../i18n/index.js';
import {
getCodingPlanConfig,
isCodingPlanConfig,
CodingPlanRegion,
CODING_PLAN_ENV_KEY,
} from '../../constants/codingPlan.js';
export type { QwenAuthState } from '../hooks/useQwenAuth.js';
@ -272,6 +284,127 @@ export const useAuthCommand = (
setAuthError(null);
}, [isAuthenticating, pendingAuthType, cancelQwenAuth, config]);
/**
* Handle coding plan submission - generates configs from template and stores api-key
* @param apiKey - The API key to store
* @param region - The region to use (default: CHINA)
*/
const handleCodingPlanSubmit = useCallback(
async (
apiKey: string,
region: CodingPlanRegion = CodingPlanRegion.CHINA,
) => {
try {
setIsAuthenticating(true);
setAuthError(null);
// Get configuration based on region
const { template, version, regionName } = getCodingPlanConfig(region);
// Get persist scope
const persistScope = getPersistScopeForModelSelection(settings);
// Store api-key in settings.env (unified env key)
settings.setValue(persistScope, `env.${CODING_PLAN_ENV_KEY}`, apiKey);
// Sync to process.env immediately so refreshAuth can read the apiKey
process.env[CODING_PLAN_ENV_KEY] = apiKey;
// Generate model configs from template
const newConfigs: ProviderModelConfig[] = template.map(
(templateConfig) => ({
...templateConfig,
envKey: CODING_PLAN_ENV_KEY,
}),
);
// Get existing configs
const existingConfigs =
(
settings.merged.modelProviders as ModelProvidersConfig | undefined
)?.[AuthType.USE_OPENAI] || [];
// Filter out all existing Coding Plan configs (mutually exclusive)
const nonCodingPlanConfigs = existingConfigs.filter(
(existing) => !isCodingPlanConfig(existing.baseUrl, existing.envKey),
);
// Add new Coding Plan configs at the beginning
const updatedConfigs = [...newConfigs, ...nonCodingPlanConfigs];
// Persist to modelProviders
settings.setValue(
persistScope,
`modelProviders.${AuthType.USE_OPENAI}`,
updatedConfigs,
);
// Also persist authType
settings.setValue(
persistScope,
'security.auth.selectedType',
AuthType.USE_OPENAI,
);
// Persist coding plan region
settings.setValue(persistScope, 'codingPlan.region', region);
// Persist coding plan version (single field for backward compatibility)
settings.setValue(persistScope, 'codingPlan.version', version);
// If there are configs, use the first one as the model
if (updatedConfigs.length > 0 && updatedConfigs[0]?.id) {
settings.setValue(persistScope, 'model.name', updatedConfigs[0].id);
}
// Hot-reload model providers configuration before refreshAuth
// This ensures ModelsConfig has the latest configuration from settings.json
const updatedModelProviders: ModelProvidersConfig = {
...(settings.merged.modelProviders as
| ModelProvidersConfig
| undefined),
[AuthType.USE_OPENAI]: updatedConfigs,
};
config.reloadModelProvidersConfig(updatedModelProviders);
// Refresh auth with the new configuration
await config.refreshAuth(AuthType.USE_OPENAI);
// Success handling
setAuthError(null);
setAuthState(AuthState.Authenticated);
setIsAuthDialogOpen(false);
setIsAuthenticating(false);
// Trigger UI refresh
onAuthChange?.();
// Add success message
addItem(
{
type: MessageType.INFO,
text: t(
'Authenticated successfully with {{region}}. API key is stored in settings.env.',
{ region: regionName },
),
},
Date.now(),
);
// Log success
const authEvent = new AuthEvent(
AuthType.USE_OPENAI,
'coding-plan',
'success',
);
logAuth(config, authEvent);
} catch (error) {
handleAuthFailure(error);
}
},
[settings, config, handleAuthFailure, addItem, onAuthChange],
);
/**
/**
* We previously used a useEffect to trigger authentication automatically when
@ -322,6 +455,7 @@ export const useAuthCommand = (
pendingAuthType,
qwenAuthState,
handleAuthSelect,
handleCodingPlanSubmit,
openAuthDialog,
cancelAuthentication,
};

View file

@ -31,6 +31,8 @@ describe('authCommand', () => {
it('should have the correct name and description', () => {
expect(authCommand.name).toBe('auth');
expect(authCommand.description).toBe('change the auth method');
expect(authCommand.description).toBe(
'Configure authentication information for login',
);
});
});

View file

@ -10,8 +10,9 @@ import { t } from '../../i18n/index.js';
export const authCommand: SlashCommand = {
name: 'auth',
altNames: ['login'],
get description() {
return t('change the auth method');
return t('Configure authentication information for login');
},
kind: CommandKind.BUILT_IN,
action: (_context, _args): OpenDialogActionReturn => ({

View file

@ -0,0 +1,86 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useState } from 'react';
import { Box, Text } from 'ink';
import { TextInput } from './shared/TextInput.js';
import { theme } from '../semantic-colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { t } from '../../i18n/index.js';
import { CodingPlanRegion } from '../../constants/codingPlan.js';
import Link from 'ink-link';
interface ApiKeyInputProps {
onSubmit: (apiKey: string) => void;
onCancel: () => void;
region?: CodingPlanRegion;
}
const CODING_PLAN_API_KEY_URL =
'https://bailian.console.aliyun.com/?tab=model#/efm/coding_plan';
const CODING_PLAN_INTL_API_KEY_URL =
'https://modelstudio.console.alibabacloud.com/?tab=dashboard#/efm/coding_plan';
export function ApiKeyInput({
onSubmit,
onCancel,
region = CodingPlanRegion.CHINA,
}: ApiKeyInputProps): React.JSX.Element {
const [apiKey, setApiKey] = useState('');
const [error, setError] = useState<string | null>(null);
const apiKeyUrl =
region === CodingPlanRegion.GLOBAL
? CODING_PLAN_INTL_API_KEY_URL
: CODING_PLAN_API_KEY_URL;
useKeypress(
(key) => {
if (key.name === 'escape') {
onCancel();
} else if (key.name === 'return') {
const trimmedKey = apiKey.trim();
if (!trimmedKey) {
setError(t('API key cannot be empty.'));
return;
}
onSubmit(trimmedKey);
}
},
{ isActive: true },
);
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}>
<Text color={theme.status.error}>{error}</Text>
</Box>
)}
<Box marginTop={1}>
<Text>{t('You can get your exclusive Coding Plan API-KEY here:')}</Text>
</Box>
<Box marginTop={0}>
<Link url={apiKeyUrl} fallback={false}>
<Text color={theme.status.success} underline>
{apiKeyUrl}
</Text>
</Link>
</Box>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('(Press Enter to submit, Escape to cancel)')}
</Text>
</Box>
</Box>
);
}

View file

@ -42,6 +42,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
branchName: 'main',
nightly: false,
debugMessage: '',
currentModel: 'gemini-pro',
sessionStats: {
lastPromptTokenCount: 0,
},

View file

@ -9,6 +9,7 @@ import { Header } 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';
interface AppHeaderProps {
version: string;
@ -17,10 +18,11 @@ interface AppHeaderProps {
export const AppHeader = ({ version }: AppHeaderProps) => {
const settings = useSettings();
const config = useConfig();
const uiState = useUIState();
const contentGeneratorConfig = config.getContentGeneratorConfig();
const authType = contentGeneratorConfig?.authType;
const model = config.getModel();
const model = uiState.currentModel;
const targetDir = config.getTargetDir();
const showBanner = !config.getScreenReader();
const showTips = !(settings.merged.ui?.hideTips || config.getScreenReader());

View file

@ -17,7 +17,6 @@ import { ThemeDialog } from './ThemeDialog.js';
import { SettingsDialog } from './SettingsDialog.js';
import { QwenOAuthProgress } from './QwenOAuthProgress.js';
import { AuthDialog } from '../auth/AuthDialog.js';
import { OpenAIKeyPrompt } from './OpenAIKeyPrompt.js';
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
import { ModelDialog } from './ModelDialog.js';
@ -56,16 +55,6 @@ export const DialogManager = ({
const { constrainHeight, terminalHeight, staticExtraHeight, mainAreaWidth } =
uiState;
const getDefaultOpenAIConfig = () => {
const fromSettings = settings.merged.security?.auth;
const modelSettings = settings.merged.model;
return {
apiKey: fromSettings?.apiKey || process.env['OPENAI_API_KEY'] || '',
baseUrl: fromSettings?.baseUrl || process.env['OPENAI_BASE_URL'] || '',
model: modelSettings?.name || process.env['OPENAI_MODEL'] || '',
};
};
if (uiState.showWelcomeBackDialog && uiState.welcomeBackInfo?.hasHistory) {
return (
<WelcomeBackDialog
@ -133,6 +122,15 @@ export const DialogManager = ({
/>
);
}
if (uiState.codingPlanUpdateRequest) {
return (
<ConsentPrompt
prompt={uiState.codingPlanUpdateRequest.prompt}
onConfirm={uiState.codingPlanUpdateRequest.onConfirm}
terminalWidth={terminalWidth}
/>
);
}
if (uiState.settingInputRequests.length > 0) {
const request = uiState.settingInputRequests[0];
// Use settingName as key to force re-mount when switching between different settings
@ -251,28 +249,8 @@ export const DialogManager = ({
}
if (uiState.isAuthenticating) {
if (uiState.pendingAuthType === AuthType.USE_OPENAI) {
const defaults = getDefaultOpenAIConfig();
return (
<OpenAIKeyPrompt
onSubmit={(apiKey, baseUrl, model) => {
uiActions.handleAuthSelect(AuthType.USE_OPENAI, {
apiKey,
baseUrl,
model,
});
}}
onCancel={() => {
uiActions.cancelAuthentication();
uiActions.setAuthState(AuthState.Updating);
}}
defaultApiKey={defaults.apiKey}
defaultBaseUrl={defaults.baseUrl}
defaultModel={defaults.model}
/>
);
}
// OpenAI authentication now handled through AuthDialog with coding-plan/custom sub-modes
// Qwen OAuth remains as a separate flow
if (uiState.pendingAuthType === AuthType.QWEN_OAUTH) {
return (
<QwenOAuthProgress

View file

@ -20,6 +20,7 @@ import { GeminiThoughtMessageContent } from './messages/GeminiThoughtMessageCont
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 { Box } from 'ink';
import { AboutBox } from './AboutBox.js';
import { StatsDisplay } from './StatsDisplay.js';
@ -127,6 +128,9 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'error' && (
<ErrorMessage text={itemForDisplay.text} />
)}
{itemForDisplay.type === 'retry_countdown' && (
<RetryCountdownMessage text={itemForDisplay.text} />
)}
{itemForDisplay.type === 'about' && (
<AboutBox {...itemForDisplay.systemInfo} width={boxWidth} />
)}

View file

@ -370,6 +370,8 @@ describe('InputPrompt', () => {
});
describe('clipboard image paste', () => {
const isWindows = process.platform === 'win32';
beforeEach(() => {
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(null);
@ -378,10 +380,37 @@ describe('InputPrompt', () => {
);
});
it('should handle Ctrl+V when clipboard has an image', async () => {
// Windows uses Alt+V (\x1Bv), non-Windows uses Ctrl+V (\x16)
const describeConditional = isWindows ? it.skip : it;
describeConditional(
'should handle Ctrl+V when clipboard has an image',
async () => {
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(
'/Users/mochi/.qwen/tmp/clipboard-123.png',
);
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
// Send Ctrl+V
stdin.write('\x16'); // Ctrl+V
await wait();
expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled();
expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled();
expect(clipboardUtils.cleanupOldClipboardImages).toHaveBeenCalled();
// Note: The new implementation adds images as attachments rather than inserting into buffer
unmount();
},
);
it('should handle Cmd+V when clipboard has an image', async () => {
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(
'/test/.qwen-clipboard/clipboard-123.png',
'/Users/mochi/.qwen/tmp/clipboard-456.png',
);
const { stdin, unmount } = renderWithProviders(
@ -389,18 +418,15 @@ describe('InputPrompt', () => {
);
await wait();
// Send Ctrl+V
stdin.write('\x16'); // Ctrl+V
// Send Cmd+V (meta key) / Alt+V on Windows
// In terminals, Cmd+V or Alt+V is typically sent as ESC followed by 'v'
stdin.write('\x1Bv');
await wait();
expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled();
expect(clipboardUtils.saveClipboardImage).toHaveBeenCalledWith(
props.config.getTargetDir(),
);
expect(clipboardUtils.cleanupOldClipboardImages).toHaveBeenCalledWith(
props.config.getTargetDir(),
);
expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalled();
expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled();
expect(clipboardUtils.cleanupOldClipboardImages).toHaveBeenCalled();
// Note: The new implementation adds images as attachments rather than inserting into buffer
unmount();
});
@ -412,7 +438,8 @@ describe('InputPrompt', () => {
);
await wait();
stdin.write('\x16'); // Ctrl+V
// Use platform-appropriate key combination
stdin.write(isWindows ? '\x1Bv' : '\x16');
await wait();
expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled();
@ -430,7 +457,8 @@ describe('InputPrompt', () => {
);
await wait();
stdin.write('\x16'); // Ctrl+V
// Use platform-appropriate key combination
stdin.write(isWindows ? '\x1Bv' : '\x16');
await wait();
expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled();
@ -439,11 +467,7 @@ describe('InputPrompt', () => {
});
it('should insert image path at cursor position with proper spacing', async () => {
const imagePath = path.join(
'test',
'.qwen-clipboard',
'clipboard-456.png',
);
const imagePath = '/Users/mochi/.qwen/tmp/clipboard-456.png';
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(imagePath);
@ -451,27 +475,20 @@ describe('InputPrompt', () => {
mockBuffer.text = 'Hello world';
mockBuffer.cursor = [0, 5]; // Cursor after "Hello"
mockBuffer.lines = ['Hello world'];
mockBuffer.replaceRangeByOffset = vi.fn();
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
stdin.write('\x16'); // Ctrl+V
// Use platform-appropriate key combination
stdin.write(isWindows ? '\x1Bv' : '\x16');
await wait();
// Should insert at cursor position with spaces
expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalled();
// Get the actual call to see what path was used
const actualCall = vi.mocked(mockBuffer.replaceRangeByOffset).mock
.calls[0];
expect(actualCall[0]).toBe(5); // start offset
expect(actualCall[1]).toBe(5); // end offset
expect(actualCall[2]).toBe(
' @' + path.relative(path.join('test', 'project', 'src'), imagePath),
);
// The new implementation adds images as attachments rather than inserting into buffer
// So we verify that saveClipboardImage was called instead
expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled();
expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled();
unmount();
});
@ -485,7 +502,8 @@ describe('InputPrompt', () => {
);
await wait();
stdin.write('\x16'); // Ctrl+V
// Use platform-appropriate key combination
stdin.write(isWindows ? '\x1Bv' : '\x16');
await wait();
// Should not throw and should not set buffer text on error

View file

@ -22,7 +22,11 @@ import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import type { Config } from '@qwen-code/qwen-code-core';
import { ApprovalMode, createDebugLogger } from '@qwen-code/qwen-code-core';
import {
ApprovalMode,
Storage,
createDebugLogger,
} from '@qwen-code/qwen-code-core';
import {
parseInputForHighlighting,
buildSegmentsForVisualSlice,
@ -41,6 +45,15 @@ import { useUIActions } from '../contexts/UIActionsContext.js';
import { useKeypressContext } from '../contexts/KeypressContext.js';
import { FEEDBACK_DIALOG_KEYS } from '../FeedbackDialog.js';
/**
* Represents an attachment (e.g., pasted image) displayed above the input prompt
*/
export interface Attachment {
id: string; // Unique identifier (timestamp)
path: string; // Full file path
filename: string; // Filename only (for display)
}
const debugLogger = createDebugLogger('INPUT_PROMPT');
export interface InputPromptProps {
buffer: TextBuffer;
@ -126,6 +139,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const [recentPasteTime, setRecentPasteTime] = useState<number | null>(null);
const pasteTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Attachment state for clipboard images
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [isAttachmentMode, setIsAttachmentMode] = useState(false);
const [selectedAttachmentIndex, setSelectedAttachmentIndex] = useState(-1);
// Large paste placeholder handling
const [pendingPastes, setPendingPastes] = useState<Map<string, string>>(
new Map(),
@ -281,10 +298,25 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (shellModeActive) {
shellHistory.addCommandToHistory(finalValue);
}
// Convert attachments to @references and prepend to the message
if (attachments.length > 0) {
const attachmentRefs = attachments
.map((att) => `@${path.relative(config.getTargetDir(), att.path)}`)
.join(' ');
finalValue = `${attachmentRefs}\n\n${finalValue.trim()}`;
}
// Clear the buffer *before* calling onSubmit to prevent potential re-submission
// if onSubmit triggers a re-render while the buffer still holds the old value.
buffer.setText('');
onSubmit(finalValue);
// Clear attachments after submit
setAttachments([]);
setIsAttachmentMode(false);
setSelectedAttachmentIndex(-1);
resetCompletionState();
resetReverseSearchCompletionState();
},
@ -295,6 +327,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
shellModeActive,
shellHistory,
resetReverseSearchCompletionState,
attachments,
config,
pendingPastes,
],
);
@ -336,52 +370,45 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
]);
// Handle clipboard image pasting with Ctrl+V
const handleClipboardImage = useCallback(async () => {
const handleClipboardImage = useCallback(async (validated = false) => {
try {
if (await clipboardHasImage()) {
const imagePath = await saveClipboardImage(config.getTargetDir());
const hasImage = validated || (await clipboardHasImage());
if (hasImage) {
const imagePath = await saveClipboardImage(Storage.getGlobalTempDir());
if (imagePath) {
// Clean up old images
cleanupOldClipboardImages(config.getTargetDir()).catch(() => {
cleanupOldClipboardImages(Storage.getGlobalTempDir()).catch(() => {
// Ignore cleanup errors
});
// Get relative path from current directory
const relativePath = path.relative(config.getTargetDir(), imagePath);
// Insert @path reference at cursor position
const insertText = `@${relativePath}`;
const currentText = buffer.text;
const [row, col] = buffer.cursor;
// Calculate offset from row/col
let offset = 0;
for (let i = 0; i < row; i++) {
offset += buffer.lines[i].length + 1; // +1 for newline
}
offset += col;
// Add spaces around the path if needed
let textToInsert = insertText;
const charBefore = offset > 0 ? currentText[offset - 1] : '';
const charAfter =
offset < currentText.length ? currentText[offset] : '';
if (charBefore && charBefore !== ' ' && charBefore !== '\n') {
textToInsert = ' ' + textToInsert;
}
if (!charAfter || (charAfter !== ' ' && charAfter !== '\n')) {
textToInsert = textToInsert + ' ';
}
// Insert at cursor position
buffer.replaceRangeByOffset(offset, offset, textToInsert);
// Add as attachment instead of inserting @reference into text
const filename = path.basename(imagePath);
const newAttachment: Attachment = {
id: String(Date.now()),
path: imagePath,
filename,
};
setAttachments((prev) => [...prev, newAttachment]);
}
}
} catch (error) {
debugLogger.error('Error handling clipboard image:', error);
}
}, [buffer, config]);
}, []);
// Handle deletion of an attachment from the list
const handleAttachmentDelete = useCallback((index: number) => {
setAttachments((prev) => {
const newList = prev.filter((_, i) => i !== index);
if (newList.length === 0) {
setIsAttachmentMode(false);
setSelectedAttachmentIndex(-1);
} else {
setSelectedAttachmentIndex(Math.min(index, newList.length - 1));
}
return newList;
});
}, []);
const handleInput = useCallback(
(key: Key) => {
@ -412,7 +439,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const pasted = key.sequence.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const charCount = [...pasted].length; // Proper Unicode char count
const lineCount = pasted.split('\n').length;
if (
// Ensure we never accidentally interpret paste as regular input.
if (key.pasteImage) {
handleClipboardImage(true);
} else if (
charCount > LARGE_PASTE_CHAR_THRESHOLD ||
lineCount > LARGE_PASTE_LINE_THRESHOLD
) {
@ -666,6 +697,55 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
}
// Attachment mode handling - process before history navigation
if (isAttachmentMode && attachments.length > 0) {
if (key.name === 'left') {
setSelectedAttachmentIndex((i) => Math.max(0, i - 1));
return;
}
if (key.name === 'right') {
setSelectedAttachmentIndex((i) =>
Math.min(attachments.length - 1, i + 1),
);
return;
}
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
// Exit attachment mode and return to input
setIsAttachmentMode(false);
setSelectedAttachmentIndex(-1);
return;
}
if (key.name === 'backspace' || key.name === 'delete') {
handleAttachmentDelete(selectedAttachmentIndex);
return;
}
if (key.name === 'return' || key.name === 'escape') {
setIsAttachmentMode(false);
setSelectedAttachmentIndex(-1);
return;
}
// For other keys, exit attachment mode and let input handle them
setIsAttachmentMode(false);
setSelectedAttachmentIndex(-1);
// Continue to process the key in input
}
// Enter attachment mode when pressing up at the first line with attachments
if (
!isAttachmentMode &&
attachments.length > 0 &&
!shellModeActive &&
!reverseSearchActive &&
!commandSearchActive &&
buffer.visualCursor[0] === 0 &&
buffer.visualScrollRow === 0 &&
keyMatchers[Command.NAVIGATION_UP](key)
) {
setIsAttachmentMode(true);
setSelectedAttachmentIndex(attachments.length - 1);
return;
}
if (!shellModeActive) {
if (keyMatchers[Command.REVERSE_SEARCH](key)) {
setCommandSearchActive(true);
@ -864,6 +944,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
onToggleShortcuts,
showShortcuts,
uiState,
isAttachmentMode,
attachments,
selectedAttachmentIndex,
handleAttachmentDelete,
uiActions,
pasteWorkaround,
nextLargePastePlaceholder,
@ -921,6 +1005,23 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return (
<>
{attachments.length > 0 && (
<Box marginLeft={2} marginBottom={0}>
<Text color={theme.text.secondary}>{t('Attachments: ')}</Text>
{attachments.map((att, idx) => (
<Text
key={att.id}
color={
isAttachmentMode && idx === selectedAttachmentIndex
? theme.status.success
: theme.text.secondary
}
>
[{att.filename}]{idx < attachments.length - 1 ? ' ' : ''}
</Text>
))}
</Box>
)}
<Box
borderStyle="single"
borderTop={true}
@ -1077,6 +1178,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
/>
</Box>
)}
{/* Attachment hints - show when there are attachments and no suggestions visible */}
{attachments.length > 0 && !shouldShowSuggestions && (
<Box marginLeft={2} marginRight={2}>
<Text color={theme.text.secondary}>
{isAttachmentMode
? t('← → select, Delete to remove, ↓ to exit')
: t('↑ to manage attachments')}
</Text>
</Box>
)}
</>
);
};

View file

@ -18,7 +18,10 @@ interface Shortcut {
// Platform-specific key mappings
const getNewlineKey = () =>
process.platform === 'win32' ? 'ctrl+enter' : 'ctrl+j';
const getPasteKey = () => (process.platform === 'darwin' ? 'cmd+v' : 'ctrl+v');
const getPasteKey = () => {
if (process.platform === 'win32') return 'alt+v';
return process.platform === 'darwin' ? 'cmd+v' : 'ctrl+v';
};
const getExternalEditorKey = () =>
process.platform === 'darwin' ? 'ctrl+x' : 'ctrl+x';

View file

@ -34,7 +34,7 @@ export const MainContent = () => {
return (
<>
<Static
key={uiState.historyRemountKey}
key={`${uiState.historyRemountKey}-${uiState.currentModel}`}
items={[
<AppHeader key="app-header" version={version} />,
<DebugModeNotification key="debug-notification" />,

View file

@ -1,74 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { OpenAIKeyPrompt } from './OpenAIKeyPrompt.js';
// Mock useKeypress hook
vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
describe('OpenAIKeyPrompt', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should render the prompt correctly', () => {
const onSubmit = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<OpenAIKeyPrompt onSubmit={onSubmit} onCancel={onCancel} />,
);
expect(lastFrame()).toContain('OpenAI Configuration Required');
expect(lastFrame()).toContain(
'https://bailian.console.aliyun.com/?tab=model#/api-key',
);
expect(lastFrame()).toContain(
'Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel',
);
});
it('should show the component with proper styling', () => {
const onSubmit = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<OpenAIKeyPrompt onSubmit={onSubmit} onCancel={onCancel} />,
);
const output = lastFrame();
expect(output).toContain('OpenAI Configuration Required');
expect(output).toContain('API Key:');
expect(output).toContain('Base URL:');
expect(output).toContain('Model:');
expect(output).toContain(
'Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel',
);
});
it('should handle paste with control characters', async () => {
const onSubmit = vi.fn();
const onCancel = vi.fn();
const { stdin } = render(
<OpenAIKeyPrompt onSubmit={onSubmit} onCancel={onCancel} />,
);
// Simulate paste with control characters
const pasteWithControlChars = '\x1b[200~sk-test123\x1b[201~';
stdin.write(pasteWithControlChars);
// Wait a bit for processing
await new Promise((resolve) => setTimeout(resolve, 50));
// The component should have filtered out the control characters
// and only kept 'sk-test123'
expect(onSubmit).not.toHaveBeenCalled(); // Should not submit yet
});
});

View file

@ -1,280 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useState } from 'react';
import { z } from 'zod';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { t } from '../../i18n/index.js';
interface OpenAIKeyPromptProps {
onSubmit: (apiKey: string, baseUrl: string, model: string) => void;
onCancel: () => void;
defaultApiKey?: string;
defaultBaseUrl?: string;
defaultModel?: string;
}
export const credentialSchema = z.object({
apiKey: z.string().min(1, 'API key is required'),
baseUrl: z
.union([z.string().url('Base URL must be a valid URL'), z.literal('')])
.optional(),
model: z.string().min(1, 'Model must be a non-empty string').optional(),
});
export type OpenAICredentials = z.infer<typeof credentialSchema>;
export function OpenAIKeyPrompt({
onSubmit,
onCancel,
defaultApiKey,
defaultBaseUrl,
defaultModel,
}: OpenAIKeyPromptProps): React.JSX.Element {
const [apiKey, setApiKey] = useState(defaultApiKey || '');
const [baseUrl, setBaseUrl] = useState(defaultBaseUrl || '');
const [model, setModel] = useState(defaultModel || '');
const [currentField, setCurrentField] = useState<
'apiKey' | 'baseUrl' | 'model'
>('apiKey');
const [validationError, setValidationError] = useState<string | null>(null);
const validateAndSubmit = () => {
setValidationError(null);
try {
const validated = credentialSchema.parse({
apiKey: apiKey.trim(),
baseUrl: baseUrl.trim() || undefined,
model: model.trim() || undefined,
});
onSubmit(
validated.apiKey,
validated.baseUrl === '' ? '' : validated.baseUrl || '',
validated.model || '',
);
} catch (error) {
if (error instanceof z.ZodError) {
const errorMessage = error.errors
.map((e) => `${e.path.join('.')}: ${e.message}`)
.join(', ');
setValidationError(
t('Invalid credentials: {{errorMessage}}', { errorMessage }),
);
} else {
setValidationError(t('Failed to validate credentials'));
}
}
};
useKeypress(
(key) => {
// Handle escape
if (key.name === 'escape') {
onCancel();
return;
}
// Handle Enter key
if (key.name === 'return') {
if (currentField === 'apiKey') {
// 允许空 API key 跳转到下一个字段,让用户稍后可以返回修改
setCurrentField('baseUrl');
return;
} else if (currentField === 'baseUrl') {
setCurrentField('model');
return;
} else if (currentField === 'model') {
// 只有在提交时才检查 API key 是否为空
if (apiKey.trim()) {
validateAndSubmit();
} else {
// 如果 API key 为空,回到 API key 字段
setCurrentField('apiKey');
}
}
return;
}
// Handle Tab key for field navigation
if (key.name === 'tab') {
if (currentField === 'apiKey') {
setCurrentField('baseUrl');
} else if (currentField === 'baseUrl') {
setCurrentField('model');
} else if (currentField === 'model') {
setCurrentField('apiKey');
}
return;
}
// Handle arrow keys for field navigation
if (key.name === 'up') {
if (currentField === 'baseUrl') {
setCurrentField('apiKey');
} else if (currentField === 'model') {
setCurrentField('baseUrl');
}
return;
}
if (key.name === 'down') {
if (currentField === 'apiKey') {
setCurrentField('baseUrl');
} else if (currentField === 'baseUrl') {
setCurrentField('model');
}
return;
}
// Handle backspace/delete
if (key.name === 'backspace' || key.name === 'delete') {
if (currentField === 'apiKey') {
setApiKey((prev) => prev.slice(0, -1));
} else if (currentField === 'baseUrl') {
setBaseUrl((prev) => prev.slice(0, -1));
} else if (currentField === 'model') {
setModel((prev) => prev.slice(0, -1));
}
return;
}
// Handle paste mode - if it's a paste event with content
if (key.paste && key.sequence) {
// 过滤粘贴相关的控制序列
let cleanInput = key.sequence
// 过滤 ESC 开头的控制序列(如 \u001b[200~、\u001b[201~ 等)
.replace(/\u001b\[[0-9;]*[a-zA-Z]/g, '') // eslint-disable-line no-control-regex
// 过滤粘贴开始标记 [200~
.replace(/\[200~/g, '')
// 过滤粘贴结束标记 [201~
.replace(/\[201~/g, '')
// 过滤单独的 [ 和 ~ 字符(可能是粘贴标记的残留)
.replace(/^\[|~$/g, '');
// 再过滤所有不可见字符ASCII < 32除了回车换行
cleanInput = cleanInput
.split('')
.filter((ch) => ch.charCodeAt(0) >= 32)
.join('');
if (cleanInput.length > 0) {
if (currentField === 'apiKey') {
setApiKey((prev) => prev + cleanInput);
} else if (currentField === 'baseUrl') {
setBaseUrl((prev) => prev + cleanInput);
} else if (currentField === 'model') {
setModel((prev) => prev + cleanInput);
}
}
return;
}
// Handle regular character input
if (key.sequence && !key.ctrl && !key.meta) {
// Filter control characters
const cleanInput = key.sequence
.split('')
.filter((ch) => ch.charCodeAt(0) >= 32)
.join('');
if (cleanInput.length > 0) {
if (currentField === 'apiKey') {
setApiKey((prev) => prev + cleanInput);
} else if (currentField === 'baseUrl') {
setBaseUrl((prev) => prev + cleanInput);
} else if (currentField === 'model') {
setModel((prev) => prev + cleanInput);
}
}
}
},
{ isActive: true },
);
return (
<Box
borderStyle="round"
borderColor={Colors.AccentBlue}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold color={Colors.AccentBlue}>
{t('OpenAI Configuration Required')}
</Text>
{validationError && (
<Box marginTop={1}>
<Text color={Colors.AccentRed}>{validationError}</Text>
</Box>
)}
<Box marginTop={1}>
<Text>
{t(
'Please enter your OpenAI configuration. You can get an API key from',
)}{' '}
<Text color={Colors.AccentBlue}>
https://bailian.console.aliyun.com/?tab=model#/api-key
</Text>
</Text>
</Box>
<Box marginTop={1} flexDirection="row">
<Box width={12}>
<Text
color={currentField === 'apiKey' ? Colors.AccentBlue : Colors.Gray}
>
{t('API Key:')}
</Text>
</Box>
<Box flexGrow={1}>
<Text>
{currentField === 'apiKey' ? '> ' : ' '}
{apiKey || ' '}
</Text>
</Box>
</Box>
<Box marginTop={1} flexDirection="row">
<Box width={12}>
<Text
color={currentField === 'baseUrl' ? Colors.AccentBlue : Colors.Gray}
>
{t('Base URL:')}
</Text>
</Box>
<Box flexGrow={1}>
<Text>
{currentField === 'baseUrl' ? '> ' : ' '}
{baseUrl}
</Text>
</Box>
</Box>
<Box marginTop={1} flexDirection="row">
<Box width={12}>
<Text
color={currentField === 'model' ? Colors.AccentBlue : Colors.Gray}
>
{t('Model:')}
</Text>
</Box>
<Box flexGrow={1}>
<Text>
{currentField === 'model' ? '> ' : ' '}
{model}
</Text>
</Box>
</Box>
<Box marginTop={1}>
<Text color={Colors.Gray}>
{t('Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel')}
</Text>
</Box>
</Box>
);
}

View file

@ -0,0 +1,41 @@
/**
* @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

@ -20,6 +20,7 @@ import type {
PlanResultDisplay,
AnsiOutput,
Config,
McpToolProgressData,
} from '@qwen-code/qwen-code-core';
import { AgentExecutionDisplay } from '../subagents/index.js';
import { PlanSummaryDisplay } from '../PlanSummaryDisplay.js';
@ -113,6 +114,22 @@ const useResultDisplayRenderer = (
};
}
// Check for McpToolProgressData
if (
typeof resultDisplay === 'object' &&
resultDisplay !== null &&
'type' in resultDisplay &&
resultDisplay.type === 'mcp_tool_progress'
) {
const progress = resultDisplay as McpToolProgressData;
const msg = progress.message ?? `Progress: ${progress.progress}`;
const totalStr = progress.total != null ? `/${progress.total}` : '';
return {
type: 'string',
data: `⏳ [${progress.progress}${totalStr}] ${msg}`,
};
}
// Check for AnsiOutput
if (
typeof resultDisplay === 'object' &&

View file

@ -36,6 +36,7 @@ import {
MODIFIER_ALT_BIT,
MODIFIER_CTRL_BIT,
} from '../utils/platformConstants.js';
import { clipboardHasImage } from '../utils/clipboardUtils.js';
import { FOCUS_IN, FOCUS_OUT } from '../hooks/useFocus.js';
@ -54,6 +55,7 @@ export interface Key {
paste: boolean;
sequence: string;
kittyProtocol?: boolean;
pasteImage?: boolean;
}
export type KeypressHandler = (key: Key) => void;
@ -390,7 +392,7 @@ export function KeypressProvider({
}
};
const handleKeypress = (_: unknown, key: Key) => {
const handleKeypress = async (_: unknown, key: Key) => {
if (key.sequence === FOCUS_IN || key.sequence === FOCUS_OUT) {
return;
}
@ -400,14 +402,28 @@ export function KeypressProvider({
}
if (key.name === 'paste-end') {
isPaste = false;
broadcast({
name: '',
ctrl: false,
meta: false,
shift: false,
paste: true,
sequence: pasteBuffer.toString(),
});
if (pasteBuffer.toString().length > 0) {
broadcast({
name: '',
ctrl: false,
meta: false,
shift: false,
paste: true,
sequence: pasteBuffer.toString(),
});
} else {
const hasImage = await clipboardHasImage();
broadcast({
name: '',
ctrl: false,
meta: false,
shift: false,
paste: true,
pasteImage: hasImage,
sequence: pasteBuffer.toString(),
});
}
pasteBuffer = Buffer.alloc(0);
return;
}
@ -722,6 +738,7 @@ export function KeypressProvider({
};
let rl: readline.Interface;
if (usePassthrough) {
rl = readline.createInterface({
input: keypressStream,

View file

@ -15,9 +15,15 @@ import {
type ApprovalMode,
} from '@qwen-code/qwen-code-core';
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';
import { type OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
// OpenAICredentials type (previously imported from OpenAIKeyPrompt)
export interface OpenAICredentials {
apiKey: string;
baseUrl?: string;
model?: string;
}
export interface UIActions {
openThemeDialog: () => void;
@ -35,8 +41,12 @@ export interface UIActions {
authType: AuthType | undefined,
credentials?: OpenAICredentials,
) => Promise<void>;
handleCodingPlanSubmit: (
apiKey: string,
region?: CodingPlanRegion,
) => Promise<void>;
setAuthState: (state: AuthState) => void;
onAuthError: (error: string) => void;
onAuthError: (error: string | null) => void;
cancelAuthentication: () => void;
handleEditorSelect: (
editorType: EditorType | undefined,
@ -45,6 +55,7 @@ export interface UIActions {
exitEditorDialog: () => void;
closeSettingsDialog: () => void;
closeModelDialog: () => void;
dismissCodingPlanUpdate: () => void;
closePermissionsDialog: () => void;
setShellModeActive: (value: boolean) => void;
vimHandleInput: (key: Key) => boolean;

View file

@ -32,6 +32,7 @@ import type { UpdateObject } from '../utils/updateCheck.js';
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
import { type CodingPlanUpdateRequest } from '../hooks/useCodingPlanUpdates.js';
export interface UIState {
history: HistoryItem[];
@ -60,6 +61,7 @@ export interface UIState {
shellConfirmationRequest: ShellConfirmationRequest | null;
confirmationRequest: ConfirmationRequest | null;
confirmUpdateExtensionRequests: ConfirmationRequest[];
codingPlanUpdateRequest: CodingPlanUpdateRequest | undefined;
settingInputRequests: SettingInputRequest[];
pluginChoiceRequests: PluginChoiceRequest[];
loopDetectionConfirmationRequest: LoopDetectionConfirmationRequest | null;

View file

@ -11,6 +11,7 @@ import type { Config } from '@qwen-code/qwen-code-core';
import {
getErrorMessage,
isNodeError,
Storage,
unescapePath,
readManyFiles,
} from '@qwen-code/qwen-code-core';
@ -181,7 +182,17 @@ export async function handleAtCommand({
// Check if path should be ignored based on filtering options
const workspaceContext = config.getWorkspaceContext();
if (!workspaceContext.isPathWithinWorkspace(pathName)) {
// Check if path is in project temp directory
const projectTempDir = Storage.getGlobalTempDir();
const absolutePathName = path.isAbsolute(pathName)
? pathName
: path.resolve(workspaceContext.getDirectories()[0] || '', pathName);
if (
!absolutePathName.startsWith(projectTempDir) &&
!workspaceContext.isPathWithinWorkspace(pathName)
) {
onDebugMessage(
`Path ${pathName} is not in the workspace and will be skipped.`,
);

View file

@ -0,0 +1,553 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { useCodingPlanUpdates } from './useCodingPlanUpdates.js';
import {
CODING_PLAN_ENV_KEY,
getCodingPlanConfig,
CodingPlanRegion,
} from '../../constants/codingPlan.js';
import { AuthType } from '@qwen-code/qwen-code-core';
// Get region configs for testing
const chinaConfig = getCodingPlanConfig(CodingPlanRegion.CHINA);
const globalConfig = getCodingPlanConfig(CodingPlanRegion.GLOBAL);
describe('useCodingPlanUpdates', () => {
const mockSettings = {
merged: {
modelProviders: {},
codingPlan: {},
},
setValue: vi.fn(),
isTrusted: true,
workspace: { settings: {} },
user: { settings: {} },
};
const mockConfig = {
reloadModelProvidersConfig: vi.fn(),
refreshAuth: vi.fn(),
getModel: vi.fn().mockReturnValue('qwen-max'),
};
const mockAddItem = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
delete process.env[CODING_PLAN_ENV_KEY];
});
describe('version comparison', () => {
it('should not show update prompt when no version is stored', () => {
mockSettings.merged.codingPlan = {};
const { result } = renderHook(() =>
useCodingPlanUpdates(
mockSettings as never,
mockConfig as never,
mockAddItem,
),
);
expect(result.current.codingPlanUpdateRequest).toBeUndefined();
});
it('should not show update prompt when China region versions match', () => {
mockSettings.merged.codingPlan = {
region: CodingPlanRegion.CHINA,
version: chinaConfig.version,
};
const { result } = renderHook(() =>
useCodingPlanUpdates(
mockSettings as never,
mockConfig as never,
mockAddItem,
),
);
expect(result.current.codingPlanUpdateRequest).toBeUndefined();
});
it('should not show update prompt when Global region versions match', () => {
mockSettings.merged.codingPlan = {
region: CodingPlanRegion.GLOBAL,
version: globalConfig.version,
};
const { result } = renderHook(() =>
useCodingPlanUpdates(
mockSettings as never,
mockConfig as never,
mockAddItem,
),
);
expect(result.current.codingPlanUpdateRequest).toBeUndefined();
});
it('should default to China region when region is not specified', async () => {
// No region specified, should default to China
mockSettings.merged.codingPlan = {
version: 'old-version-hash',
};
const { result } = renderHook(() =>
useCodingPlanUpdates(
mockSettings as never,
mockConfig as never,
mockAddItem,
),
);
await waitFor(() => {
expect(result.current.codingPlanUpdateRequest).toBeDefined();
});
// Should prompt for China region since it defaults to China
expect(result.current.codingPlanUpdateRequest?.prompt).toContain(
chinaConfig.regionName,
);
});
it('should show update prompt when China region versions differ', async () => {
mockSettings.merged.codingPlan = {
region: CodingPlanRegion.CHINA,
version: 'old-version-hash',
};
const { result } = renderHook(() =>
useCodingPlanUpdates(
mockSettings as never,
mockConfig as never,
mockAddItem,
),
);
await waitFor(() => {
expect(result.current.codingPlanUpdateRequest).toBeDefined();
});
expect(result.current.codingPlanUpdateRequest?.prompt).toContain(
chinaConfig.regionName,
);
});
it('should show update prompt when Global region versions differ', async () => {
mockSettings.merged.codingPlan = {
region: CodingPlanRegion.GLOBAL,
version: 'old-version-hash',
};
const { result } = renderHook(() =>
useCodingPlanUpdates(
mockSettings as never,
mockConfig as never,
mockAddItem,
),
);
await waitFor(() => {
expect(result.current.codingPlanUpdateRequest).toBeDefined();
});
expect(result.current.codingPlanUpdateRequest?.prompt).toContain(
globalConfig.regionName,
);
});
});
describe('update execution', () => {
it('should execute China region update when user confirms', async () => {
mockSettings.merged.codingPlan = {
region: CodingPlanRegion.CHINA,
version: 'old-version-hash',
};
mockSettings.merged.modelProviders = {
[AuthType.USE_OPENAI]: [
{
id: 'test-model-china-1',
baseUrl: chinaConfig.baseUrl,
envKey: CODING_PLAN_ENV_KEY,
},
{
id: 'custom-model',
baseUrl: 'https://custom.example.com',
envKey: 'CUSTOM_API_KEY',
},
],
};
mockConfig.refreshAuth.mockResolvedValue(undefined);
const { result } = renderHook(() =>
useCodingPlanUpdates(
mockSettings as never,
mockConfig as never,
mockAddItem,
),
);
await waitFor(() => {
expect(result.current.codingPlanUpdateRequest).toBeDefined();
});
// Confirm the update
await result.current.codingPlanUpdateRequest!.onConfirm(true);
// Wait for async update to complete
await waitFor(() => {
// Should update model providers (at least 2 calls: modelProviders + version + region)
expect(mockSettings.setValue).toHaveBeenCalled();
});
// Should update version with correct hash
expect(mockSettings.setValue).toHaveBeenCalledWith(
expect.anything(),
'codingPlan.version',
chinaConfig.version,
);
// Should update region
expect(mockSettings.setValue).toHaveBeenCalledWith(
expect.anything(),
'codingPlan.region',
CodingPlanRegion.CHINA,
);
// Should reload and refresh auth
expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalled();
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI);
// Should show success message with region info
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'info',
text: expect.stringContaining(chinaConfig.regionName),
}),
expect.any(Number),
);
});
it('should execute Global region update when user confirms', async () => {
mockSettings.merged.codingPlan = {
region: CodingPlanRegion.GLOBAL,
version: 'old-version-hash',
};
mockSettings.merged.modelProviders = {
[AuthType.USE_OPENAI]: [
{
id: 'test-model-global-1',
baseUrl: globalConfig.baseUrl,
envKey: CODING_PLAN_ENV_KEY,
},
{
id: 'custom-model',
baseUrl: 'https://custom.example.com',
envKey: 'CUSTOM_API_KEY',
},
],
};
mockConfig.refreshAuth.mockResolvedValue(undefined);
const { result } = renderHook(() =>
useCodingPlanUpdates(
mockSettings as never,
mockConfig as never,
mockAddItem,
),
);
await waitFor(() => {
expect(result.current.codingPlanUpdateRequest).toBeDefined();
});
// Confirm the update
await result.current.codingPlanUpdateRequest!.onConfirm(true);
// Wait for async update to complete
await waitFor(() => {
expect(mockSettings.setValue).toHaveBeenCalled();
});
// Should update version with correct hash (single version field)
expect(mockSettings.setValue).toHaveBeenCalledWith(
expect.anything(),
'codingPlan.version',
globalConfig.version,
);
// Should update region
expect(mockSettings.setValue).toHaveBeenCalledWith(
expect.anything(),
'codingPlan.region',
CodingPlanRegion.GLOBAL,
);
// Should reload and refresh auth
expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalled();
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI);
// Should show success message with Global region info
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'info',
text: expect.stringContaining(globalConfig.regionName),
}),
expect.any(Number),
);
});
it('should not execute update when user declines', async () => {
mockSettings.merged.codingPlan = {
region: CodingPlanRegion.CHINA,
version: 'old-version-hash',
};
const { result } = renderHook(() =>
useCodingPlanUpdates(
mockSettings as never,
mockConfig as never,
mockAddItem,
),
);
await waitFor(() => {
expect(result.current.codingPlanUpdateRequest).toBeDefined();
});
// Decline the update
await result.current.codingPlanUpdateRequest!.onConfirm(false);
// Should not update anything
expect(mockSettings.setValue).not.toHaveBeenCalled();
expect(mockConfig.reloadModelProvidersConfig).not.toHaveBeenCalled();
});
it('should replace all Coding Plan configs during update (mutually exclusive)', async () => {
// Since regions are mutually exclusive, when updating one region,
// all Coding Plan configs should be replaced (not preserving other region configs)
mockSettings.merged.codingPlan = {
region: CodingPlanRegion.CHINA,
version: 'old-version-hash',
};
const chinaModelConfig = {
id: 'test-model-china-1',
baseUrl: chinaConfig.baseUrl,
envKey: CODING_PLAN_ENV_KEY,
};
const globalModelConfig = {
id: 'test-model-global-1',
baseUrl: globalConfig.baseUrl,
envKey: CODING_PLAN_ENV_KEY,
};
const customConfig = {
id: 'custom-model',
baseUrl: 'https://custom.example.com',
envKey: 'CUSTOM_API_KEY',
};
mockSettings.merged.modelProviders = {
[AuthType.USE_OPENAI]: [
chinaModelConfig,
globalModelConfig,
customConfig,
],
};
mockConfig.refreshAuth.mockResolvedValue(undefined);
const { result } = renderHook(() =>
useCodingPlanUpdates(
mockSettings as never,
mockConfig as never,
mockAddItem,
),
);
await waitFor(() => {
expect(result.current.codingPlanUpdateRequest).toBeDefined();
});
await result.current.codingPlanUpdateRequest!.onConfirm(true);
// Wait for async update to complete
await waitFor(() => {
expect(mockSettings.setValue).toHaveBeenCalled();
});
// Get the updated configs passed to setValue
const setValueCalls = mockSettings.setValue.mock.calls;
const modelProvidersCall = setValueCalls.find((call: unknown[]) =>
(call[1] as string).includes('modelProviders'),
);
expect(modelProvidersCall).toBeDefined();
const updatedConfigs = modelProvidersCall![2] as Array<
Record<string, unknown>
>;
// Should have new China configs + custom config only (global config removed since regions are mutually exclusive)
// The China template has 8 models, so we expect 8 (from template) + 1 (custom) = 9
// Note: description field has been removed, only name field contains the branding
expect(updatedConfigs.length).toBe(9);
// Should NOT contain the Global config (mutually exclusive)
expect(
updatedConfigs.some(
(c: Record<string, unknown>) => c['baseUrl'] === globalConfig.baseUrl,
),
).toBe(false);
// Should contain the custom config
expect(
updatedConfigs.some(
(c: Record<string, unknown>) => c['id'] === 'custom-model',
),
).toBe(true);
// All configs should use the unified env key
updatedConfigs.forEach((config) => {
if (config['envKey'] === CODING_PLAN_ENV_KEY) {
expect(config['baseUrl']).toBe(chinaConfig.baseUrl);
}
});
// Should reload and refresh auth
expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalled();
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI);
});
it('should preserve non-Coding Plan configs during update', async () => {
mockSettings.merged.codingPlan = {
region: CodingPlanRegion.CHINA,
version: 'old-version-hash',
};
const customConfig = {
id: 'custom-model',
baseUrl: 'https://custom.example.com',
envKey: 'CUSTOM_API_KEY',
};
mockSettings.merged.modelProviders = {
[AuthType.USE_OPENAI]: [
{
id: 'test-model-china-1',
baseUrl: chinaConfig.baseUrl,
envKey: CODING_PLAN_ENV_KEY,
},
customConfig,
],
};
mockConfig.refreshAuth.mockResolvedValue(undefined);
const { result } = renderHook(() =>
useCodingPlanUpdates(
mockSettings as never,
mockConfig as never,
mockAddItem,
),
);
await waitFor(() => {
expect(result.current.codingPlanUpdateRequest).toBeDefined();
});
await result.current.codingPlanUpdateRequest!.onConfirm(true);
// Wait for async update to complete
await waitFor(() => {
// Should preserve custom config - verify setValue was called
expect(mockSettings.setValue).toHaveBeenCalled();
});
// Get the updated configs passed to setValue
const setValueCalls = mockSettings.setValue.mock.calls;
const modelProvidersCall = setValueCalls.find((call: unknown[]) =>
(call[1] as string).includes('modelProviders'),
);
// Should preserve custom config
expect(modelProvidersCall).toBeDefined();
const updatedConfigs = modelProvidersCall![2] as Array<
Record<string, unknown>
>;
expect(
updatedConfigs.some(
(c: Record<string, unknown>) => c['id'] === 'custom-model',
),
).toBe(true);
});
it('should handle update errors gracefully', async () => {
mockSettings.merged.codingPlan = {
region: CodingPlanRegion.CHINA,
version: 'old-version-hash',
};
mockSettings.merged.modelProviders = {
[AuthType.USE_OPENAI]: [
{
id: 'test-model-china-1',
baseUrl: chinaConfig.baseUrl,
envKey: CODING_PLAN_ENV_KEY,
},
],
};
// Simulate an error during refreshAuth
mockConfig.refreshAuth.mockRejectedValue(new Error('Network error'));
const { result } = renderHook(() =>
useCodingPlanUpdates(
mockSettings as never,
mockConfig as never,
mockAddItem,
),
);
await waitFor(() => {
expect(result.current.codingPlanUpdateRequest).toBeDefined();
});
await result.current.codingPlanUpdateRequest!.onConfirm(true);
// Should show error message
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
}),
expect.any(Number),
);
});
});
});
describe('dismissUpdate', () => {
it('should clear update request when dismissed', async () => {
mockSettings.merged.codingPlan = {
region: CodingPlanRegion.CHINA,
version: 'old-version-hash',
};
const { result } = renderHook(() =>
useCodingPlanUpdates(
mockSettings as never,
mockConfig as never,
mockAddItem,
),
);
await waitFor(() => {
expect(result.current.codingPlanUpdateRequest).toBeDefined();
});
result.current.dismissCodingPlanUpdate();
await waitFor(() => {
expect(result.current.codingPlanUpdateRequest).toBeUndefined();
});
});
});
});

View file

@ -0,0 +1,202 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { useCallback, useEffect, useState } from 'react';
import type { Config, ModelProvidersConfig } from '@qwen-code/qwen-code-core';
import { AuthType } from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from '../../config/settings.js';
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
import {
isCodingPlanConfig,
getCodingPlanConfig,
CodingPlanRegion,
CODING_PLAN_ENV_KEY,
} from '../../constants/codingPlan.js';
import { t } from '../../i18n/index.js';
export interface CodingPlanUpdateRequest {
prompt: string;
onConfirm: (confirmed: boolean) => void;
}
/**
* Hook for detecting and handling Coding Plan template updates.
* Compares the persisted version with the current template version
* and prompts the user to update if they differ.
*/
export function useCodingPlanUpdates(
settings: LoadedSettings,
config: Config,
addItem: (
item: { type: 'info' | 'error' | 'warning'; text: string },
timestamp: number,
) => void,
) {
const [updateRequest, setUpdateRequest] = useState<
CodingPlanUpdateRequest | undefined
>();
/**
* Execute the Coding Plan configuration update.
* Removes old Coding Plan configs and replaces them with new ones from the template.
* Uses the region from settings.codingPlan.region (defaults to CHINA).
*/
const executeUpdate = useCallback(
async (region: CodingPlanRegion = CodingPlanRegion.CHINA) => {
try {
const persistScope = getPersistScopeForModelSelection(settings);
// Get current configs
const currentConfigs =
(
settings.merged.modelProviders as
| Record<string, Array<Record<string, unknown>>>
| undefined
)?.[AuthType.USE_OPENAI] || [];
// Filter out all Coding Plan configs (since they are mutually exclusive)
// Keep only non-Coding-Plan user custom configs
const nonCodingPlanConfigs = currentConfigs.filter(
(cfg) =>
!isCodingPlanConfig(
cfg['baseUrl'] as string | undefined,
cfg['envKey'] as string | undefined,
),
);
// Get the configuration for the current region
const { template, version, regionName } = getCodingPlanConfig(region);
// Generate new configs from template
const newConfigs = template.map((templateConfig) => ({
...templateConfig,
envKey: CODING_PLAN_ENV_KEY,
}));
// Combine: new Coding Plan configs at the front, user configs preserved
const updatedConfigs = [
...newConfigs,
...(nonCodingPlanConfigs as Array<Record<string, unknown>>),
] as Array<Record<string, unknown>>;
// Hot-reload model providers configuration first (in-memory only)
const updatedModelProviders = {
...(settings.merged.modelProviders as
| Record<string, unknown>
| undefined),
[AuthType.USE_OPENAI]: updatedConfigs,
};
config.reloadModelProvidersConfig(
updatedModelProviders as unknown as ModelProvidersConfig,
);
// Refresh auth with the new configuration
// This validates the configuration before persisting
await config.refreshAuth(AuthType.USE_OPENAI);
// Persist to settings only after successful auth refresh
settings.setValue(
persistScope,
`modelProviders.${AuthType.USE_OPENAI}`,
updatedConfigs,
);
// Update the version (single version field for backward compatibility)
settings.setValue(persistScope, 'codingPlan.version', version);
// Update the region
settings.setValue(persistScope, 'codingPlan.region', region);
const activeModel = config.getModel();
addItem(
{
type: 'info',
text: t(
'{{region}} configuration updated successfully. Model switched to "{{model}}".',
{ region: regionName, model: activeModel },
),
},
Date.now(),
);
return true;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
addItem(
{
type: 'error',
text: t('Failed to update Coding Plan configuration: {{message}}', {
message: errorMessage,
}),
},
Date.now(),
);
return false;
}
},
[settings, config, addItem],
);
/**
* Check for version mismatch and prompt user for update if needed.
* Uses the region from settings.codingPlan.region (defaults to CHINA if not set).
*/
const checkForUpdates = useCallback(() => {
const mergedSettings = settings.merged as {
codingPlan?: {
version?: string;
region?: CodingPlanRegion;
};
};
// Get the region (default to CHINA if not set)
const region = mergedSettings.codingPlan?.region ?? CodingPlanRegion.CHINA;
// Get the saved version for the current region
const savedVersion = mergedSettings.codingPlan?.version;
// If no version is stored, user hasn't used Coding Plan yet - skip check
if (!savedVersion) {
return;
}
// Get current version for the region
const currentVersion = getCodingPlanConfig(region).version;
// 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 },
),
onConfirm: async (confirmed: boolean) => {
setUpdateRequest(undefined);
if (confirmed) {
await executeUpdate(region);
}
},
});
}
}, [settings, executeUpdate]);
// Check for updates on mount
useEffect(() => {
checkForUpdates();
}, [checkForUpdates]);
const dismissCodingPlanUpdate = useCallback(() => {
setUpdateRequest(undefined);
}, []);
return {
codingPlanUpdateRequest: updateRequest,
dismissCodingPlanUpdate,
};
}

View file

@ -7,7 +7,12 @@
import { useCallback } from 'react';
import { SettingScope } from '../../config/settings.js';
import type { AuthType, ApprovalMode } from '@qwen-code/qwen-code-core';
import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
// OpenAICredentials type (previously imported from OpenAIKeyPrompt)
interface OpenAICredentials {
apiKey: string;
baseUrl?: string;
model?: string;
}
export interface DialogCloseOptions {
// Theme dialog

View file

@ -9,7 +9,6 @@ import type { Mock, MockInstance } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import { useGeminiStream } from './useGeminiStream.js';
import { useKeypress } from './useKeypress.js';
import * as atCommandProcessor from './atCommandProcessor.js';
import type {
TrackedToolCall,
@ -67,7 +66,12 @@ const MockedUserPromptEvent = vi.hoisted(() =>
const MockedApiCancelEvent = vi.hoisted(() =>
vi.fn().mockImplementation(() => {}),
);
const mockParseAndFormatApiError = vi.hoisted(() => vi.fn());
const mockParseAndFormatApiError = vi.hoisted(() =>
vi.fn(
(msg: unknown) =>
`[API Error: ${typeof msg === 'string' ? msg : 'An unknown error occurred.'}]`,
),
);
const mockLogApiCancel = vi.hoisted(() => vi.fn());
// Vision auto-switch mocks (hoisted)
@ -107,10 +111,6 @@ vi.mock('./useVisionAutoSwitch.js', () => ({
})),
}));
vi.mock('./useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
vi.mock('./shellCommandProcessor.js', () => ({
useShellCommandProcessor: vi.fn().mockReturnValue({
handleShellCommand: vi.fn(),
@ -123,22 +123,6 @@ vi.mock('../utils/markdownUtilities.js', () => ({
findLastSafeSplitPoint: vi.fn((s: string) => s.length),
}));
vi.mock('./useStateAndRef.js', () => ({
useStateAndRef: vi.fn((initial) => {
let val = initial;
const ref = { current: val };
const setVal = vi.fn((updater) => {
if (typeof updater === 'function') {
val = updater(val);
} else {
val = updater;
}
ref.current = val;
});
return [val, ref, setVal];
}),
}));
vi.mock('./useLogger.js', () => ({
useLogger: vi.fn().mockReturnValue({
logMessage: vi.fn().mockResolvedValue(undefined),
@ -850,28 +834,8 @@ describe('useGeminiStream', () => {
expect(result.current.streamingState).toBe(StreamingState.Responding);
});
describe('User Cancellation', () => {
let keypressCallback: (key: any) => void;
const mockUseKeypress = useKeypress as Mock;
beforeEach(() => {
// Capture the callback passed to useKeypress
mockUseKeypress.mockImplementation((callback, options) => {
if (options.isActive) {
keypressCallback = callback;
} else {
keypressCallback = () => {};
}
});
});
const simulateEscapeKeyPress = () => {
act(() => {
keypressCallback({ name: 'escape' });
});
};
it('should cancel an in-progress stream when escape is pressed', async () => {
describe('Cancellation', () => {
it('should cancel an in-progress stream when cancelOngoingRequest is called', async () => {
const mockStream = (async function* () {
yield { type: 'content', value: 'Part 1' };
// Keep the stream open
@ -891,8 +855,10 @@ describe('useGeminiStream', () => {
expect(result.current.streamingState).toBe(StreamingState.Responding);
});
// Simulate escape key press
simulateEscapeKeyPress();
// Call cancelOngoingRequest directly
act(() => {
result.current.cancelOngoingRequest();
});
// Verify cancellation message is added
await waitFor(() => {
@ -909,7 +875,7 @@ describe('useGeminiStream', () => {
expect(result.current.streamingState).toBe(StreamingState.Idle);
});
it('should call onCancelSubmit handler when escape is pressed', async () => {
it('should call onCancelSubmit handler when cancelOngoingRequest is called', async () => {
const cancelSubmitSpy = vi.fn();
const mockStream = (async function* () {
yield { type: 'content', value: 'Part 1' };
@ -947,12 +913,14 @@ describe('useGeminiStream', () => {
result.current.submitQuery('test query');
});
simulateEscapeKeyPress();
act(() => {
result.current.cancelOngoingRequest();
});
expect(cancelSubmitSpy).toHaveBeenCalled();
});
it('should call setShellInputFocused(false) when escape is pressed', async () => {
it('should call setShellInputFocused(false) when cancelOngoingRequest is called', async () => {
const setShellInputFocusedSpy = vi.fn();
const mockStream = (async function* () {
yield { type: 'content', value: 'Part 1' };
@ -989,18 +957,22 @@ describe('useGeminiStream', () => {
result.current.submitQuery('test query');
});
simulateEscapeKeyPress();
act(() => {
result.current.cancelOngoingRequest();
});
expect(setShellInputFocusedSpy).toHaveBeenCalledWith(false);
});
it('should not do anything if escape is pressed when not responding', () => {
it('should not do anything if cancelOngoingRequest is called when not responding', () => {
const { result } = renderTestHook();
expect(result.current.streamingState).toBe(StreamingState.Idle);
// Simulate escape key press
simulateEscapeKeyPress();
// Call cancelOngoingRequest
act(() => {
result.current.cancelOngoingRequest();
});
// No change should happen, no cancellation message
expect(mockAddItem).not.toHaveBeenCalledWith(
@ -1035,7 +1007,9 @@ describe('useGeminiStream', () => {
});
// Cancel the request
simulateEscapeKeyPress();
act(() => {
result.current.cancelOngoingRequest();
});
// Allow the stream to continue
act(() => {
@ -1083,7 +1057,9 @@ describe('useGeminiStream', () => {
expect(result.current.streamingState).toBe(StreamingState.Responding);
// Try to cancel
simulateEscapeKeyPress();
act(() => {
result.current.cancelOngoingRequest();
});
// Nothing should happen because the state is not `Responding`
expect(abortSpy).not.toHaveBeenCalled();
@ -2296,6 +2272,127 @@ describe('useGeminiStream', () => {
});
});
it('should show a retry countdown and update pending history over time', async () => {
vi.useFakeTimers();
try {
let resolveStream: (() => void) | undefined;
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.Retry,
retryInfo: {
message: '[API Error: Rate limit exceeded]',
attempt: 1,
maxRetries: 3,
delayMs: 3000,
},
};
yield {
type: ServerGeminiEventType.Retry,
};
await new Promise<void>((resolve) => {
resolveStream = resolve;
});
yield {
type: ServerGeminiEventType.Finished,
value: { reason: 'STOP', usageMetadata: undefined },
};
})(),
);
const { result } = renderHook(() =>
useGeminiStream(
new MockedGeminiClientClass(mockConfig),
[],
mockAddItem,
mockConfig,
mockLoadedSettings,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
() => Promise.resolve(),
false,
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
() => {},
80,
24,
),
);
act(() => {
void result.current.submitQuery('Trigger retry');
});
await act(async () => {
await Promise.resolve();
});
const findErrorItem = () =>
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++
) {
await act(async () => {
await Promise.resolve();
});
errorItem = findErrorItem();
countdownItem = findCountdownItem();
}
// Error line should be rendered as ERROR type (wrapped by parseAndFormatApiError)
expect(errorItem?.text).toContain('Rate limit exceeded');
// Countdown line should be rendered as retry_countdown type
expect(countdownItem?.text).toContain('Retrying in 3 seconds');
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
const countdownAfterOneSecond = result.current.pendingHistoryItems.find(
(item) => item.type === 'retry_countdown',
);
expect(countdownAfterOneSecond?.text).toContain(
'Retrying in 2 seconds',
);
resolveStream?.();
await act(async () => {
await Promise.resolve();
await vi.runAllTimersAsync();
});
// Both error and countdown 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();
}
});
it('should memoize pendingHistoryItems', () => {
mockUseReactToolScheduler.mockReturnValue([
[],

View file

@ -63,8 +63,8 @@ import {
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { useSessionStats } from '../contexts/SessionContext.js';
import { useKeypress } from './useKeypress.js';
import type { LoadedSettings } from '../../config/settings.js';
import { t } from '../../i18n/index.js';
const debugLogger = createDebugLogger('GEMINI_STREAM');
@ -115,7 +115,6 @@ export const useGeminiStream = (
persistSessionModel?: string;
showGuidance?: boolean;
}>,
isShellFocused?: boolean,
) => {
const [initError, setInitError] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
@ -125,6 +124,16 @@ export const useGeminiStream = (
const [thought, setThought] = useState<ThoughtSummary | null>(null);
const [pendingHistoryItem, pendingHistoryItemRef, setPendingHistoryItem] =
useStateAndRef<HistoryItemWithoutId | null>(null);
const [pendingRetryErrorItem, setPendingRetryErrorItem] =
useState<HistoryItemWithoutId | null>(null);
const [
pendingRetryCountdownItem,
pendingRetryCountdownItemRef,
setPendingRetryCountdownItem,
] = useStateAndRef<HistoryItemWithoutId | null>(null);
const retryCountdownTimerRef = useRef<ReturnType<typeof setInterval> | null>(
null,
);
const processedMemoryToolsRef = useRef<Set<string>>(new Set());
const {
startNewPrompt,
@ -189,6 +198,69 @@ export const useGeminiStream = (
onComplete: (result: { userSelection: 'disable' | 'keep' }) => void;
} | null>(null);
const stopRetryCountdownTimer = useCallback(() => {
if (retryCountdownTimerRef.current) {
clearInterval(retryCountdownTimerRef.current);
retryCountdownTimerRef.current = null;
}
}, []);
const clearRetryCountdown = useCallback(() => {
stopRetryCountdownTimer();
setPendingRetryErrorItem(null);
setPendingRetryCountdownItem(null);
}, [setPendingRetryCountdownItem, stopRetryCountdownTimer]);
const startRetryCountdown = useCallback(
(retryInfo: {
message?: string;
attempt: number;
maxRetries: number;
delayMs: number;
}) => {
stopRetryCountdownTimer();
const startTime = Date.now();
const { message, attempt, maxRetries, delayMs } = retryInfo;
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);
setPendingRetryCountdownItem({
type: 'retry_countdown',
text: t(
'Retrying in {{seconds}} seconds… (attempt {{attempt}}/{{maxRetries}})',
{
seconds: String(remainingSec),
attempt: String(attempt),
maxRetries: String(maxRetries),
},
),
} as HistoryItemWithoutId);
if (remainingMs <= 0) {
stopRetryCountdownTimer();
}
};
updateCountdown();
retryCountdownTimerRef.current = setInterval(updateCountdown, 1000);
},
[setPendingRetryCountdownItem, stopRetryCountdownTimer],
);
useEffect(() => () => stopRetryCountdownTimer(), [stopRetryCountdownTimer]);
const onExec = useCallback(async (done: Promise<void>) => {
setIsResponding(true);
await done;
@ -295,6 +367,7 @@ export const useGeminiStream = (
Date.now(),
);
setPendingHistoryItem(null);
clearRetryCountdown();
onCancelSubmit();
setIsResponding(false);
setShellInputFocused(false);
@ -305,19 +378,11 @@ export const useGeminiStream = (
onCancelSubmit,
pendingHistoryItemRef,
setShellInputFocused,
clearRetryCountdown,
config,
getPromptCount,
]);
useKeypress(
(key) => {
if (key.name === 'escape' && !isShellFocused) {
cancelOngoingRequest();
}
},
{ isActive: streamingState === StreamingState.Responding },
);
const prepareQueryForGemini = useCallback(
async (
query: PartListUnion,
@ -609,10 +674,17 @@ export const useGeminiStream = (
{ type: MessageType.INFO, text: 'User cancelled the request.' },
userMessageTimestamp,
);
clearRetryCountdown();
setIsResponding(false);
setThought(null); // Reset thought when user cancels
},
[addItem, pendingHistoryItemRef, setPendingHistoryItem, setThought],
[
addItem,
pendingHistoryItemRef,
setPendingHistoryItem,
setThought,
clearRetryCountdown,
],
);
const handleErrorEvent = useCallback(
@ -631,9 +703,17 @@ export const useGeminiStream = (
},
userMessageTimestamp,
);
clearRetryCountdown();
setThought(null); // Reset thought when there's an error
},
[addItem, pendingHistoryItemRef, setPendingHistoryItem, config, setThought],
[
addItem,
pendingHistoryItemRef,
setPendingHistoryItem,
config,
setThought,
clearRetryCountdown,
],
);
const handleCitationEvent = useCallback(
@ -693,8 +773,9 @@ export const useGeminiStream = (
userMessageTimestamp,
);
}
clearRetryCountdown();
},
[addItem],
[addItem, clearRetryCountdown],
);
const handleChatCompressionEvent = useCallback(
@ -853,7 +934,16 @@ export const useGeminiStream = (
loopDetectedRef.current = true;
break;
case ServerGeminiEventType.Retry:
// Will add the missing logic later
// Clear any pending partial content from the failed attempt
if (pendingHistoryItemRef.current) {
setPendingHistoryItem(null);
}
// Show retry info if available (rate-limit / throttling errors)
if (event.retryInfo) {
startRetryCountdown(event.retryInfo);
} else if (!pendingRetryCountdownItemRef.current) {
clearRetryCountdown();
}
break;
default: {
// enforces exhaustive switch-case
@ -878,7 +968,12 @@ export const useGeminiStream = (
handleMaxSessionTurnsEvent,
handleSessionTokenLimitExceededEvent,
handleCitationEvent,
startRetryCountdown,
clearRetryCountdown,
setThought,
pendingHistoryItemRef,
setPendingHistoryItem,
pendingRetryCountdownItemRef,
],
);
@ -1216,10 +1311,18 @@ export const useGeminiStream = (
const pendingHistoryItems = useMemo(
() =>
[pendingHistoryItem, pendingToolCallGroupDisplay].filter(
(i) => i !== undefined && i !== null,
),
[pendingHistoryItem, pendingToolCallGroupDisplay],
[
pendingHistoryItem,
pendingRetryErrorItem,
pendingRetryCountdownItem,
pendingToolCallGroupDisplay,
].filter((i) => i !== undefined && i !== null),
[
pendingHistoryItem,
pendingRetryErrorItem,
pendingRetryCountdownItem,
pendingToolCallGroupDisplay,
],
);
useEffect(() => {

View file

@ -269,8 +269,11 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
return false; // Let InputPrompt handle completion
}
// Let InputPrompt handle Ctrl+V for clipboard image pasting
if (normalizedKey.ctrl && normalizedKey.name === 'v') {
// Let InputPrompt handle Ctrl+V or Cmd+V for clipboard image pasting
if (
(normalizedKey.ctrl || normalizedKey.meta) &&
normalizedKey.name === 'v'
) {
return false; // Let InputPrompt handle clipboard functionality
}

View file

@ -11,6 +11,7 @@ import { defaultKeyBindings } from '../config/keyBindings.js';
import type { Key } from './hooks/useKeypress.js';
describe('keyMatchers', () => {
const isWindows = process.platform === 'win32';
const createKey = (name: string, mods: Partial<Key> = {}): Key => ({
name,
ctrl: false,
@ -49,7 +50,8 @@ describe('keyMatchers', () => {
key.name === 'return' && (key.ctrl || key.meta || key.paste),
[Command.OPEN_EXTERNAL_EDITOR]: (key: Key) =>
key.ctrl && (key.name === 'x' || key.sequence === '\x18'),
[Command.PASTE_CLIPBOARD_IMAGE]: (key: Key) => key.ctrl && key.name === 'v',
[Command.PASTE_CLIPBOARD_IMAGE]: (key: Key) =>
(isWindows ? key.meta : key.ctrl || key.meta) && key.name === 'v',
[Command.TOGGLE_TOOL_DESCRIPTIONS]: (key: Key) =>
key.ctrl && key.name === 't',
[Command.TOGGLE_IDE_CONTEXT_DETAIL]: (key: Key) =>
@ -216,8 +218,12 @@ describe('keyMatchers', () => {
},
{
command: Command.PASTE_CLIPBOARD_IMAGE,
positive: [createKey('v', { ctrl: true })],
negative: [createKey('v'), createKey('c', { ctrl: true })],
positive: isWindows
? [createKey('v', { meta: true })]
: [createKey('v', { ctrl: true }), createKey('v', { meta: true })],
negative: isWindows
? [createKey('v', { ctrl: true }), createKey('v')]
: [createKey('v'), createKey('c', { ctrl: true })],
},
// App level bindings

View file

@ -50,6 +50,10 @@ function matchKeyBinding(keyBinding: KeyBinding, key: Key): boolean {
return false;
}
if (keyBinding.meta !== undefined && key.meta !== keyBinding.meta) {
return false;
}
return true;
}

View file

@ -28,7 +28,7 @@ export const AVAILABLE_MODELS_QWEN: AvailableModel[] = [
label: MAINLINE_CODER,
get description() {
return t(
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)',
'Qwen 3.5 Plus — efficient hybrid model with leading coding performance',
);
},
},

View file

@ -128,6 +128,11 @@ export type HistoryItemWarning = HistoryItemBase & {
text: string;
};
export type HistoryItemRetryCountdown = HistoryItemBase & {
type: 'retry_countdown';
text: string;
};
export type HistoryItemAbout = HistoryItemBase & {
type: 'about';
systemInfo: {
@ -270,6 +275,7 @@ export type HistoryItemWithoutId =
| HistoryItemInfo
| HistoryItemError
| HistoryItemWarning
| HistoryItemRetryCountdown
| HistoryItemAbout
| HistoryItemHelp
| HistoryItemToolGroup

View file

@ -4,66 +4,120 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
clipboardHasImage,
saveClipboardImage,
cleanupOldClipboardImages,
} from './clipboardUtils.js';
// Mock ClipboardManager
const mockHasFormat = vi.fn();
const mockGetImageData = vi.fn();
vi.mock('@teddyzhu/clipboard', () => ({
default: {
ClipboardManager: vi.fn().mockImplementation(() => ({
hasFormat: mockHasFormat,
getImageData: mockGetImageData,
})),
},
ClipboardManager: vi.fn().mockImplementation(() => ({
hasFormat: mockHasFormat,
getImageData: mockGetImageData,
})),
}));
describe('clipboardUtils', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('clipboardHasImage', () => {
it('should return false on non-macOS platforms', async () => {
if (process.platform !== 'darwin') {
const result = await clipboardHasImage();
expect(result).toBe(false);
} else {
// Skip on macOS as it would require actual clipboard state
expect(true).toBe(true);
}
it('should return true when clipboard contains image', async () => {
mockHasFormat.mockReturnValue(true);
const result = await clipboardHasImage();
expect(result).toBe(true);
expect(mockHasFormat).toHaveBeenCalledWith('image');
});
it('should return boolean on macOS', async () => {
if (process.platform === 'darwin') {
const result = await clipboardHasImage();
expect(typeof result).toBe('boolean');
} else {
// Skip on non-macOS
expect(true).toBe(true);
}
it('should return false when clipboard does not contain image', async () => {
mockHasFormat.mockReturnValue(false);
const result = await clipboardHasImage();
expect(result).toBe(false);
expect(mockHasFormat).toHaveBeenCalledWith('image');
});
it('should return false on error', async () => {
mockHasFormat.mockImplementation(() => {
throw new Error('Clipboard error');
});
const result = await clipboardHasImage();
expect(result).toBe(false);
});
it('should return false and not throw when error occurs in DEBUG mode', async () => {
const originalEnv = process.env;
vi.stubGlobal('process', {
...process,
env: { ...originalEnv, DEBUG: '1' },
});
mockHasFormat.mockImplementation(() => {
throw new Error('Test error');
});
const result = await clipboardHasImage();
expect(result).toBe(false);
});
});
describe('saveClipboardImage', () => {
it('should return null on non-macOS platforms', async () => {
if (process.platform !== 'darwin') {
const result = await saveClipboardImage();
expect(result).toBe(null);
} else {
// Skip on macOS
expect(true).toBe(true);
}
it('should return null when clipboard has no image', async () => {
mockHasFormat.mockReturnValue(false);
const result = await saveClipboardImage('/tmp/test');
expect(result).toBe(null);
});
it('should handle errors gracefully', async () => {
// Test with invalid directory (should not throw)
const result = await saveClipboardImage(
'/invalid/path/that/does/not/exist',
);
it('should return null when image data buffer is null', async () => {
mockHasFormat.mockReturnValue(true);
mockGetImageData.mockReturnValue({ data: null });
if (process.platform === 'darwin') {
// On macOS, might return null due to various errors
expect(result === null || typeof result === 'string').toBe(true);
} else {
// On other platforms, should always return null
expect(result).toBe(null);
}
const result = await saveClipboardImage('/tmp/test');
expect(result).toBe(null);
});
it('should handle errors gracefully and return null', async () => {
mockHasFormat.mockImplementation(() => {
throw new Error('Clipboard error');
});
const result = await saveClipboardImage('/tmp/test');
expect(result).toBe(null);
});
it('should return null and not throw when error occurs in DEBUG mode', async () => {
const originalEnv = process.env;
vi.stubGlobal('process', {
...process,
env: { ...originalEnv, DEBUG: '1' },
});
mockHasFormat.mockImplementation(() => {
throw new Error('Test error');
});
const result = await saveClipboardImage('/tmp/test');
expect(result).toBe(null);
});
});
describe('cleanupOldClipboardImages', () => {
it('should not throw errors', async () => {
// Should handle missing directories gracefully
it('should not throw errors when directory does not exist', async () => {
await expect(
cleanupOldClipboardImages('/path/that/does/not/exist'),
).resolves.not.toThrow();
@ -72,5 +126,11 @@ describe('clipboardUtils', () => {
it('should complete without errors on valid directory', async () => {
await expect(cleanupOldClipboardImages('.')).resolves.not.toThrow();
});
it('should use clipboard directory consistently with saveClipboardImage', () => {
// This test verifies that both functions use the same directory structure
// The implementation uses 'clipboard' subdirectory for both functions
expect(true).toBe(true);
});
});
});

View file

@ -6,116 +6,86 @@
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { createDebugLogger, execCommand } from '@qwen-code/qwen-code-core';
const MACOS_CLIPBOARD_TIMEOUT_MS = 1500;
import { createDebugLogger } from '@qwen-code/qwen-code-core';
const debugLogger = createDebugLogger('CLIPBOARD_UTILS');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type ClipboardModule = any;
let cachedClipboardModule: ClipboardModule | null = null;
let clipboardLoadAttempted = false;
async function getClipboardModule(): Promise<ClipboardModule | null> {
if (clipboardLoadAttempted) return cachedClipboardModule;
clipboardLoadAttempted = true;
try {
const modName = '@teddyzhu/clipboard';
cachedClipboardModule = await import(modName);
return cachedClipboardModule;
} catch (_e) {
debugLogger.error(
'Failed to load @teddyzhu/clipboard native module. Clipboard image features will be unavailable.',
);
return null;
}
}
/**
* Checks if the system clipboard contains an image (macOS only for now)
* Checks if the system clipboard contains an image
* @returns true if clipboard contains an image
*/
export async function clipboardHasImage(): Promise<boolean> {
if (process.platform !== 'darwin') {
return false;
}
try {
// Use osascript to check clipboard type
const { stdout } = await execCommand(
'osascript',
['-e', 'clipboard info'],
{
timeout: MACOS_CLIPBOARD_TIMEOUT_MS,
},
);
const imageRegex =
/«class PNGf»|TIFF picture|JPEG picture|GIF picture|«class JPEG»|«class TIFF»/;
return imageRegex.test(stdout);
} catch {
const mod = await getClipboardModule();
if (!mod) return false;
const clipboard = new mod.ClipboardManager();
return clipboard.hasFormat('image');
} catch (error) {
debugLogger.error('Error checking clipboard for image:', error);
return false;
}
}
/**
* Saves the image from clipboard to a temporary file (macOS only for now)
* Saves the image from clipboard to a temporary file
* @param targetDir The target directory to create temp files within
* @returns The path to the saved image file, or null if no image or error
*/
export async function saveClipboardImage(
targetDir?: string,
): Promise<string | null> {
if (process.platform !== 'darwin') {
return null;
}
try {
const mod = await getClipboardModule();
if (!mod) return null;
const clipboard = new mod.ClipboardManager();
if (!clipboard.hasFormat('image')) {
return null;
}
// Create a temporary directory for clipboard images within the target directory
// This avoids security restrictions on paths outside the target directory
const baseDir = targetDir || process.cwd();
const tempDir = path.join(baseDir, '.qwen-clipboard');
const tempDir = path.join(baseDir, 'clipboard');
await fs.mkdir(tempDir, { recursive: true });
// Generate a unique filename with timestamp
const timestamp = new Date().getTime();
const tempFilePath = path.join(tempDir, `clipboard-${timestamp}.png`);
// Try different image formats in order of preference
const formats = [
{ class: 'PNGf', extension: 'png' },
{ class: 'JPEG', extension: 'jpg' },
{ class: 'TIFF', extension: 'tiff' },
{ class: 'GIFf', extension: 'gif' },
];
const imageData = clipboard.getImageData();
// Use data buffer from the API
const buffer = imageData.data;
for (const format of formats) {
const tempFilePath = path.join(
tempDir,
`clipboard-${timestamp}.${format.extension}`,
);
// Try to save clipboard as this format
const script = `
try
set imageData to the clipboard as «class ${format.class}»
set fileRef to open for access POSIX file "${tempFilePath}" with write permission
write imageData to fileRef
close access fileRef
return "success"
on error errMsg
try
close access POSIX file "${tempFilePath}"
end try
return "error"
end try
`;
const { stdout } = await execCommand('osascript', ['-e', script], {
timeout: MACOS_CLIPBOARD_TIMEOUT_MS,
});
if (stdout.trim() === 'success') {
// Verify the file was created and has content
try {
const stats = await fs.stat(tempFilePath);
if (stats.size > 0) {
return tempFilePath;
}
} catch {
// File doesn't exist, continue to next format
}
}
// Clean up failed attempt
try {
await fs.unlink(tempFilePath);
} catch {
// Ignore cleanup errors
}
if (!buffer) {
return null;
}
// No format worked
return null;
await fs.writeFile(tempFilePath, buffer);
return tempFilePath;
} catch (error) {
debugLogger.error('Error saving clipboard image:', error);
return null;
@ -123,8 +93,8 @@ export async function saveClipboardImage(
}
/**
* Cleans up old temporary clipboard image files
* Removes files older than 1 hour
* Cleans up old temporary clipboard image files using LRU strategy
* Keeps maximum 100 images, when exceeding removes 50 oldest files to reduce cleanup frequency
* @param targetDir The target directory where temp files are stored
*/
export async function cleanupOldClipboardImages(
@ -132,23 +102,49 @@ export async function cleanupOldClipboardImages(
): Promise<void> {
try {
const baseDir = targetDir || process.cwd();
const tempDir = path.join(baseDir, '.qwen-clipboard');
const tempDir = path.join(baseDir, 'clipboard');
const files = await fs.readdir(tempDir);
const oneHourAgo = Date.now() - 60 * 60 * 1000;
const MAX_IMAGES = 100;
const CLEANUP_COUNT = 50;
// Filter clipboard image files and get their stats
const imageFiles: Array<{ name: string; path: string; atime: number }> = [];
for (const file of files) {
if (
file.startsWith('clipboard-') &&
(file.endsWith('.png') ||
file.endsWith('.jpg') ||
file.endsWith('.webp') ||
file.endsWith('.heic') ||
file.endsWith('.heif') ||
file.endsWith('.tiff') ||
file.endsWith('.gif'))
file.endsWith('.gif') ||
file.endsWith('.bmp'))
) {
const filePath = path.join(tempDir, file);
const stats = await fs.stat(filePath);
if (stats.mtimeMs < oneHourAgo) {
await fs.unlink(filePath);
}
imageFiles.push({
name: file,
path: filePath,
atime: stats.atimeMs,
});
}
}
// If exceeds limit, remove CLEANUP_COUNT oldest files to reduce cleanup frequency
if (imageFiles.length > MAX_IMAGES) {
// Sort by access time (oldest first)
imageFiles.sort((a, b) => a.atime - b.atime);
// Remove CLEANUP_COUNT oldest files (or all excess files if less than CLEANUP_COUNT)
const removeCount = Math.min(
CLEANUP_COUNT,
imageFiles.length - MAX_IMAGES + CLEANUP_COUNT,
);
const filesToRemove = imageFiles.slice(0, removeCount);
for (const file of filesToRemove) {
await fs.unlink(file.path);
}
}
} catch {