resolve comment

This commit is contained in:
DennisYu07 2026-03-02 06:11:11 -08:00
commit e4e21bb6b7
93 changed files with 3360 additions and 1604 deletions

View file

@ -107,6 +107,10 @@ class GeminiAgent {
audio: true,
embeddedContext: true,
},
sessionCapabilities: {
list: {},
resume: {},
},
},
};
}
@ -153,10 +157,14 @@ class GeminiAgent {
const session = await this.createAndStoreSession(config);
const availableModels = this.buildAvailableModels(config);
const modesData = this.buildModesData(config);
const configOptions = this.buildConfigOptions(config);
return {
sessionId: session.getId(),
models: availableModels,
modes: modesData,
configOptions,
};
}
@ -239,25 +247,31 @@ class GeminiAgent {
async listSessions(
params: acp.ListSessionsRequest,
): Promise<acp.ListSessionsResponse> {
const sessionService = new SessionService(params.cwd);
const cwd = params.cwd || process.cwd();
const sessionService = new SessionService(cwd);
const result = await sessionService.listSessions({
cursor: params.cursor,
size: params.size,
});
const sessions = result.items.map((item) => ({
cwd: item.cwd,
filePath: item.filePath,
gitBranch: item.gitBranch,
messageCount: item.messageCount,
mtime: item.mtime,
prompt: item.prompt,
sessionId: item.sessionId,
startTime: item.startTime,
title: item.prompt || '(session)',
updatedAt: new Date(item.mtime).toISOString(),
}));
return {
items: result.items.map((item) => ({
sessionId: item.sessionId,
cwd: item.cwd,
startTime: item.startTime,
mtime: item.mtime,
prompt: item.prompt,
gitBranch: item.gitBranch,
filePath: item.filePath,
messageCount: item.messageCount,
})),
nextCursor: result.nextCursor,
hasMore: result.hasMore,
items: sessions,
nextCursor: result.nextCursor,
sessions,
};
}
@ -449,6 +463,70 @@ class GeminiAgent {
};
}
private buildModesData(config: Config): acp.ModesData {
const currentApprovalMode = config.getApprovalMode();
const availableModes = APPROVAL_MODES.map((mode) => ({
id: mode as ApprovalModeValue,
name: APPROVAL_MODE_INFO[mode].name,
description: APPROVAL_MODE_INFO[mode].description,
}));
return {
currentModeId: currentApprovalMode as ApprovalModeValue,
availableModes,
};
}
private buildConfigOptions(config: Config): acp.ConfigOption[] {
const currentApprovalMode = config.getApprovalMode();
const currentModelId = this.formatCurrentModelId(
config.getModel() || this.config.getModel() || '',
config.getAuthType(),
);
const modeOptions = APPROVAL_MODES.map((mode) => ({
value: mode,
name: APPROVAL_MODE_INFO[mode].name,
description: APPROVAL_MODE_INFO[mode].description,
}));
const allConfiguredModels = config.getAllConfiguredModels();
const modelOptions = allConfiguredModels.map((model) => {
const effectiveModelId =
model.isRuntimeModel && model.runtimeSnapshotId
? model.runtimeSnapshotId
: model.id;
return {
value: formatAcpModelId(effectiveModelId, model.authType),
name: model.label,
description: model.description ?? '',
};
});
return [
{
id: 'mode',
name: 'Mode',
description: 'Session permission mode',
category: 'mode',
type: 'select',
currentValue: currentApprovalMode,
options: modeOptions,
},
{
id: 'model',
name: 'Model',
description: 'AI model to use',
category: 'model',
type: 'select',
currentValue: currentModelId,
options: modelOptions,
},
];
}
private formatCurrentModelId(
baseModelId: string,
authType?: AuthType,

View file

@ -59,7 +59,7 @@ export type CancelNotification = z.infer<typeof cancelNotificationSchema>;
export type AuthenticateRequest = z.infer<typeof authenticateRequestSchema>;
export type NewSessionResponse = z.infer<typeof newSessionResponseSchema>;
// Note: NewSessionResponse type is defined later after newSessionResponseSchema
export type LoadSessionResponse = z.infer<typeof loadSessionResponseSchema>;
@ -285,33 +285,33 @@ export const sessionModelStateSchema = z.object({
currentModelId: modelIdSchema,
});
export const newSessionResponseSchema = z.object({
sessionId: z.string(),
models: sessionModelStateSchema,
});
// Note: newSessionResponseSchema is defined later in the file after modesDataSchema
export const loadSessionResponseSchema = z.null();
export const sessionListItemSchema = z.object({
cwd: z.string(),
filePath: z.string(),
filePath: z.string().optional(),
gitBranch: z.string().optional(),
messageCount: z.number(),
mtime: z.number(),
prompt: z.string(),
messageCount: z.number().optional(),
mtime: z.number().optional(),
prompt: z.string().optional(),
sessionId: z.string(),
startTime: z.string(),
startTime: z.string().optional(),
title: z.string(),
updatedAt: z.string(),
});
export const listSessionsResponseSchema = z.object({
hasMore: z.boolean(),
items: z.array(sessionListItemSchema),
hasMore: z.boolean().optional(),
items: z.array(sessionListItemSchema).optional(),
nextCursor: z.number().optional(),
sessions: z.array(sessionListItemSchema),
});
export const listSessionsRequestSchema = z.object({
cursor: z.number().optional(),
cwd: z.string(),
cwd: z.string().optional(),
size: z.number().optional(),
});
@ -405,6 +405,12 @@ export const promptCapabilitiesSchema = z.object({
export const agentCapabilitiesSchema = z.object({
loadSession: z.boolean().optional(),
promptCapabilities: promptCapabilitiesSchema.optional(),
sessionCapabilities: z
.object({
list: z.object({}).optional(),
resume: z.object({}).optional(),
})
.optional(),
});
export const authMethodSchema = z.object({
@ -451,6 +457,34 @@ export const modesDataSchema = z.object({
availableModes: z.array(modeInfoSchema),
});
export const configOptionSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string(),
category: z.string(),
type: z.string(),
currentValue: z.string(),
options: z.array(
z.object({
value: z.string(),
name: z.string(),
description: z.string(),
}),
),
});
export type ConfigOption = z.infer<typeof configOptionSchema>;
// newSessionResponseSchema includes modes and configOptions for ACP/Zed integration
export const newSessionResponseSchema = z.object({
sessionId: z.string(),
models: sessionModelStateSchema,
modes: modesDataSchema,
configOptions: z.array(configOptionSchema),
});
export type NewSessionResponse = z.infer<typeof newSessionResponseSchema>;
export const agentInfoSchema = z.object({
name: z.string(),
title: z.string(),

View file

@ -698,14 +698,21 @@ export async function loadCliConfig(
}
// Automatically load output-language.md if it exists
let outputLanguageFilePath: string | undefined = path.join(
const projectStorage = new Storage(cwd);
const projectOutputLanguagePath = path.join(
projectStorage.getQwenDir(),
'output-language.md',
);
const globalOutputLanguagePath = path.join(
Storage.getGlobalQwenDir(),
'output-language.md',
);
if (fs.existsSync(outputLanguageFilePath)) {
// output-language.md found - will be added to context files
} else {
outputLanguageFilePath = undefined;
let outputLanguageFilePath: string | undefined;
if (fs.existsSync(projectOutputLanguagePath)) {
outputLanguageFilePath = projectOutputLanguagePath;
} else if (fs.existsSync(globalOutputLanguagePath)) {
outputLanguageFilePath = globalOutputLanguagePath;
}
const fileService = new FileDiscoveryService(cwd);

View file

@ -50,6 +50,7 @@ export enum Command {
QUIT = 'quit',
EXIT = 'exit',
SHOW_MORE_LINES = 'showMoreLines',
RETRY_LAST = 'retryLast',
// Shell commands
REVERSE_SEARCH = 'reverseSearch',
@ -170,6 +171,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.QUIT]: [{ key: 'c', ctrl: true }],
[Command.EXIT]: [{ key: 'd', ctrl: true }],
[Command.SHOW_MORE_LINES]: [{ key: 's', ctrl: true }],
[Command.RETRY_LAST]: [{ key: 'y', ctrl: true }],
// Shell commands
[Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }],

View file

@ -251,15 +251,9 @@ export function getCodingPlanConfig(region: CodingPlanRegion) {
region === CodingPlanRegion.CHINA
? 'https://coding.dashscope.aliyuncs.com/v1'
: 'https://coding-intl.dashscope.aliyuncs.com/v1';
const regionName =
region === CodingPlanRegion.CHINA
? 'Coding Plan (Bailian, China)'
: 'Coding Plan (Bailian, Global/Intl)';
return {
template,
baseUrl,
regionName,
version: computeCodingPlanVersion(template),
};
}

View file

@ -157,6 +157,7 @@ export default {
'Enter to confirm, Esc to cancel': 'Enter zum Bestätigen, Esc zum Abbrechen',
'Enter to select, ↑↓ to navigate, Esc to go back':
'Enter zum Auswählen, ↑↓ zum Navigieren, Esc zum Zurückgehen',
'Enter to submit, Esc to go back': 'Enter zum Absenden, Esc zum Zurückgehen',
'Invalid step: {{step}}': 'Ungültiger Schritt: {{step}}',
'No subagents found.': 'Keine Unteragenten gefunden.',
"Use '/agents create' to create your first subagent.":
@ -944,18 +945,22 @@ export default {
// Dialogs - Auth
// ============================================================================
'Get started': 'Loslegen',
'How would you like to authenticate for this project?':
'Wie möchten Sie sich für dieses Projekt authentifizieren?',
'Select Authentication Method': 'Authentifizierungsmethode auswählen',
'OpenAI API key is required to use OpenAI authentication.':
'OpenAI API-Schlüssel ist für die OpenAI-Authentifizierung erforderlich.',
'You must select an auth method to proceed. Press Ctrl+C again to exit.':
'Sie müssen eine Authentifizierungsmethode wählen, um fortzufahren. Drücken Sie erneut Strg+C zum Beenden.',
'(Use Enter to Set Auth)': '(Enter zum Festlegen der Authentifizierung)',
'Terms of Services and Privacy Notice for Qwen Code':
'Nutzungsbedingungen und Datenschutzhinweis für Qwen Code',
'Terms of Services and Privacy Notice':
'Nutzungsbedingungen und Datenschutzhinweis',
'Qwen OAuth': 'Qwen OAuth',
'Free \u00B7 Up to 1,000 requests/day \u00B7 Qwen latest models':
'Kostenlos \u00B7 Bis zu 1.000 Anfragen/Tag \u00B7 Qwen neueste Modelle',
'Login with QwenChat account to use daily free quota.':
'Melden Sie sich mit Ihrem QwenChat-Konto an, um das tägliche kostenlose Kontingent zu nutzen.',
'Paid \u00B7 Up to 6,000 requests/5 hrs \u00B7 All Alibaba Cloud Coding Plan Models':
'Kostenpflichtig \u00B7 Bis zu 6.000 Anfragen/5 Std. \u00B7 Alle Alibaba Cloud Coding Plan Modelle',
'Alibaba Cloud Coding Plan': 'Alibaba Cloud Coding Plan',
'Bring your own API key': 'Eigenen API-Schlüssel verwenden',
'API-KEY': 'API-KEY',
'Use coding plan credentials or your own api-keys/providers.':
'Verwenden Sie Coding Plan-Anmeldedaten oder Ihre eigenen API-Schlüssel/Anbieter.',
@ -985,6 +990,8 @@ export default {
'Warten auf Qwen OAuth-Authentifizierung...',
'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.':
'Hinweis: Ihr bestehender API-Schlüssel in settings.json wird bei Verwendung von Qwen OAuth nicht gelöscht. Sie können später bei Bedarf zur OpenAI-Authentifizierung zurückwechseln.',
'Note: Your existing API key will not be cleared when using Qwen OAuth.':
'Hinweis: Ihr bestehender API-Schlüssel wird bei Verwendung von Qwen OAuth nicht gelöscht.',
'Authentication timed out. Please try again.':
'Authentifizierung abgelaufen. Bitte versuchen Sie es erneut.',
'Waiting for auth... (Press ESC or CTRL+C to cancel)':
@ -1034,6 +1041,17 @@ export default {
'(default)': '(Standard)',
'(set)': '(gesetzt)',
'(not set)': '(nicht gesetzt)',
Modality: 'Modalität',
'Context Window': 'Kontextfenster',
text: 'Text',
'text-only': 'nur Text',
image: 'Bild',
pdf: 'PDF',
audio: 'Audio',
video: 'Video',
'not set': 'nicht gesetzt',
none: 'keine',
unknown: 'unbekannt',
"Failed to switch model to '{{modelId}}'.\n\n{{error}}":
"Modell konnte nicht auf '{{modelId}}' umgestellt werden.\n\n{{error}}",
'Qwen 3.5 Plus — efficient hybrid model with leading coding performance':
@ -1380,38 +1398,43 @@ export default {
'Erweiterungsseite wird im Browser geöffnet: {{url}}',
'Failed to open browser. Check out the extensions gallery at {{url}}':
'Browser konnte nicht geöffnet werden. Besuchen Sie die Erweiterungsgalerie unter {{url}}',
'Use /compress when the conversation gets long to summarize history and free up context.':
'Verwenden Sie /compress, wenn die Unterhaltung lang wird, um den Verlauf zusammenzufassen und Kontext freizugeben.',
'Start a fresh idea with /clear or /new; the previous session stays available in history.':
'Starten Sie eine neue Idee mit /clear oder /new; die vorherige Sitzung bleibt im Verlauf verfügbar.',
'Use /bug to submit issues to the maintainers when something goes off.':
'Verwenden Sie /bug, um Probleme an die Betreuer zu melden, wenn etwas schiefgeht.',
'Switch auth type quickly with /auth.':
'Wechseln Sie den Authentifizierungstyp schnell mit /auth.',
'You can run any shell commands from Qwen Code using ! (e.g. !ls).':
'Sie können beliebige Shell-Befehle in Qwen Code mit ! ausführen (z. B. !ls).',
'Type / to open the command popup; Tab autocompletes slash commands and saved prompts.':
'Geben Sie / ein, um das Befehlsmenü zu öffnen; Tab vervollständigt Slash-Befehle und gespeicherte Prompts.',
'You can resume a previous conversation by running qwen --continue or qwen --resume.':
'Sie können eine frühere Unterhaltung mit qwen --continue oder qwen --resume fortsetzen.',
'You can switch permission mode quickly with Shift+Tab or /approval-mode.':
'Sie können den Berechtigungsmodus schnell mit Shift+Tab oder /approval-mode wechseln.',
'You can switch permission mode quickly with Tab or /approval-mode.':
'Sie können den Berechtigungsmodus schnell mit Tab oder /approval-mode wechseln.',
'Try /insight to generate personalized insights from your chat history.':
'Probieren Sie /insight, um personalisierte Erkenntnisse aus Ihrem Chatverlauf zu erstellen.',
// ============================================================================
// Custom API-KEY Configuration
// Custom API Key Configuration
// ============================================================================
'For advanced users who want to configure models manually.':
'Für fortgeschrittene Benutzer, die Modelle manuell konfigurieren möchten.',
'Please configure your models in settings.json:':
'Bitte konfigurieren Sie Ihre Modelle in settings.json:',
'Set API key via environment variable (e.g., OPENAI_API_KEY)':
'API-Schlüssel über Umgebungsvariable setzen (z.B. OPENAI_API_KEY)',
"Add model configuration to modelProviders['openai'] (or other auth types)":
"Modellkonfiguration zu modelProviders['openai'] (oder anderen Authentifizierungstypen) hinzufügen",
'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig':
'Jeder Anbieter benötigt: id, envKey (erforderlich), plus optionale baseUrl, generationConfig',
'Use /model command to select your preferred model from the configured list':
'Verwenden Sie den /model-Befehl, um Ihr bevorzugtes Modell aus der konfigurierten Liste auszuwählen',
'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.':
'Unterstützte Authentifizierungstypen: openai, anthropic, gemini, vertex-ai, usw.',
'You can configure your API key and models in settings.json':
'Sie können Ihren API-Schlüssel und Modelle in settings.json konfigurieren',
'Refer to the documentation for setup instructions':
'Einrichtungsanweisungen finden Sie in der Dokumentation',
// ============================================================================
// Coding Plan Authentication
// ============================================================================
'Please enter your API key:': 'Bitte geben Sie Ihren API-Schlüssel ein:',
'API key cannot be empty.': 'API-Schlüssel darf nicht leer sein.',
'You can get your exclusive Coding Plan API-KEY here:':
'Hier können Sie Ihren exklusiven Coding Plan API-KEY erhalten:',
'New model configurations are available for Bailian Coding Plan. Update now?':
'Neue Modellkonfigurationen sind für Bailian Coding Plan verfügbar. Jetzt aktualisieren?',
'You can get your Coding Plan API key here':
'Sie können Ihren Coding-Plan-API-Schlüssel hier erhalten',
'New model configurations are available for Alibaba Cloud Coding Plan. Update now?':
'Neue Modellkonfigurationen sind für Alibaba Cloud Coding Plan verfügbar. Jetzt aktualisieren?',
'Coding Plan configuration updated successfully. New models are now available.':
'Coding Plan-Konfiguration erfolgreich aktualisiert. Neue Modelle sind jetzt verfügbar.',
'Coding Plan API key not found. Please re-authenticate with Coding Plan.':
@ -1422,32 +1445,16 @@ export default {
// ============================================================================
// Auth Dialog - View Titles and Labels
// ============================================================================
'Coding Plan': 'Coding Plan',
'Coding Plan (Bailian, China)': 'Coding Plan (Bailian, China)',
'Coding Plan (Bailian, Global/Intl)': 'Coding Plan (Bailian, Global/Intl)',
"Paste your api key of Bailian Coding Plan and you're all set!":
'Fügen Sie Ihren Bailian Coding Plan API-Schlüssel ein und Sie sind bereit!',
"Paste your api key of Coding Plan (Bailian, Global/Intl) and you're all set!":
'Fügen Sie Ihren Coding Plan (Bailian, Global/Intl) API-Schlüssel ein und Sie sind bereit!',
Custom: 'Benutzerdefiniert',
'More instructions about configuring `modelProviders` manually.':
'Weitere Anweisungen zur manuellen Konfiguration von `modelProviders`.',
'Select API-KEY configuration mode:':
'API-KEY-Konfigurationsmodus auswählen:',
'(Press Escape to go back)': '(Escape drücken zum Zurückgehen)',
'(Press Enter to submit, Escape to cancel)':
'(Enter zum Absenden, Escape zum Abbrechen)',
'More instructions please check:': 'Weitere Anweisungen finden Sie unter:',
'Select Region for Coding Plan': 'Region für Coding Plan auswählen',
'Choose based on where your account is registered':
'Wählen Sie basierend auf dem Registrierungsort Ihres Kontos',
'Enter Coding Plan API Key': 'Coding-Plan-API-Schlüssel eingeben',
// ============================================================================
// Coding Plan International Updates
// ============================================================================
'New model configurations are available for {{region}}. Update now?':
'Neue Modellkonfigurationen sind für {{region}} verfügbar. Jetzt aktualisieren?',
'New model configurations are available for Bailian Coding Plan (China). Update now?':
'Neue Modellkonfigurationen sind für Bailian Coding Plan (China) verfügbar. Jetzt aktualisieren?',
'New model configurations are available for Coding Plan (Bailian, Global/Intl). Update now?':
'Neue Modellkonfigurationen sind für Coding Plan (Bailian, Global/Intl) verfügbar. Jetzt aktualisieren?',
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
'{{region}}-Konfiguration erfolgreich aktualisiert. Modell auf "{{model}}" umgeschaltet.',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':

View file

@ -178,6 +178,7 @@ export default {
'Enter to confirm, Esc to cancel': 'Enter to confirm, Esc to cancel',
'Enter to select, ↑↓ to navigate, Esc to go back':
'Enter to select, ↑↓ to navigate, Esc to go back',
'Enter to submit, Esc to go back': 'Enter to submit, Esc to go back',
'Invalid step: {{step}}': 'Invalid step: {{step}}',
'No subagents found.': 'No subagents found.',
"Use '/agents create' to create your first subagent.":
@ -935,18 +936,22 @@ export default {
// Dialogs - Auth
// ============================================================================
'Get started': 'Get started',
'How would you like to authenticate for this project?':
'How would you like to authenticate for this project?',
'Select Authentication Method': 'Select Authentication Method',
'OpenAI API key is required to use OpenAI authentication.':
'OpenAI API key is required to use OpenAI authentication.',
'You must select an auth method to proceed. Press Ctrl+C again to exit.':
'You must select an auth method to proceed. Press Ctrl+C again to exit.',
'(Use Enter to Set Auth)': '(Use Enter to Set Auth)',
'Terms of Services and Privacy Notice for Qwen Code':
'Terms of Services and Privacy Notice for Qwen Code',
'Terms of Services and Privacy Notice':
'Terms of Services and Privacy Notice',
'Qwen OAuth': 'Qwen OAuth',
'Free \u00B7 Up to 1,000 requests/day \u00B7 Qwen latest models':
'Free \u00B7 Up to 1,000 requests/day \u00B7 Qwen latest models',
'Login with QwenChat account to use daily free quota.':
'Login with QwenChat account to use daily free quota.',
'Paid \u00B7 Up to 6,000 requests/5 hrs \u00B7 All Alibaba Cloud Coding Plan Models':
'Paid \u00B7 Up to 6,000 requests/5 hrs \u00B7 All Alibaba Cloud Coding Plan Models',
'Alibaba Cloud Coding Plan': 'Alibaba Cloud Coding Plan',
'Bring your own API key': 'Bring your own API key',
'API-KEY': 'API-KEY',
'Use coding plan credentials or your own api-keys/providers.':
'Use coding plan credentials or your own api-keys/providers.',
@ -974,6 +979,8 @@ export default {
'Waiting for Qwen OAuth authentication...',
'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.':
'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.',
'Note: Your existing API key will not be cleared when using Qwen OAuth.':
'Note: Your existing API key will not be cleared when using Qwen OAuth.',
'Authentication timed out. Please try again.':
'Authentication timed out. Please try again.',
'Waiting for auth... (Press ESC or CTRL+C to cancel)':
@ -1021,6 +1028,17 @@ export default {
'(default)': '(default)',
'(set)': '(set)',
'(not set)': '(not set)',
Modality: 'Modality',
'Context Window': 'Context Window',
text: 'text',
'text-only': 'text-only',
image: 'image',
pdf: 'pdf',
audio: 'audio',
video: 'video',
'not set': 'not set',
none: 'none',
unknown: 'unknown',
"Failed to switch model to '{{modelId}}'.\n\n{{error}}":
"Failed to switch model to '{{modelId}}'.\n\n{{error}}",
'Qwen 3.5 Plus — efficient hybrid model with leading coding performance':
@ -1115,6 +1133,8 @@ export default {
'You can switch permission mode quickly with Shift+Tab or /approval-mode.',
'You can switch permission mode quickly with Tab or /approval-mode.':
'You can switch permission mode quickly with Tab or /approval-mode.',
'Try /insight to generate personalized insights from your chat history.':
'Try /insight to generate personalized insights from your chat history.',
// ============================================================================
// Exit Screen / Stats
@ -1382,18 +1402,20 @@ export default {
'Rate limit error: {{reason}}': 'Rate limit error: {{reason}}',
'Retrying in {{seconds}} seconds… (attempt {{attempt}}/{{maxRetries}})':
'Retrying in {{seconds}} seconds… (attempt {{attempt}}/{{maxRetries}})',
'Press Ctrl+Y to retry': 'Press Ctrl+Y to retry',
'No failed request to retry.': 'No failed request to retry.',
'to retry last request': 'to retry last request',
// ============================================================================
// Coding Plan Authentication
// ============================================================================
'Please enter your API key:': 'Please enter your API key:',
'API key cannot be empty.': 'API key cannot be empty.',
'You can get your exclusive Coding Plan API-KEY here:':
'You can get your exclusive Coding Plan API-KEY here:',
'You can get your Coding Plan API key here':
'You can get your Coding Plan API key here',
'API key is stored in settings.env. You can migrate it to a .env file for better security.':
'API key is stored in settings.env. You can migrate it to a .env file for better security.',
'New model configurations are available for Bailian Coding Plan. Update now?':
'New model configurations are available for Bailian Coding Plan. Update now?',
'New model configurations are available for Alibaba Cloud Coding Plan. Update now?':
'New model configurations are available for Alibaba Cloud Coding Plan. Update now?',
'Coding Plan configuration updated successfully. New models are now available.':
'Coding Plan configuration updated successfully. New models are now available.',
'Coding Plan API key not found. Please re-authenticate with Coding Plan.':
@ -1402,51 +1424,26 @@ export default {
'Failed to update Coding Plan configuration: {{message}}',
// ============================================================================
// Custom API-KEY Configuration
// Custom API Key Configuration
// ============================================================================
'For advanced users who want to configure models manually.':
'For advanced users who want to configure models manually.',
'Please configure your models in settings.json:':
'Please configure your models in settings.json:',
'Set API key via environment variable (e.g., OPENAI_API_KEY)':
'Set API key via environment variable (e.g., OPENAI_API_KEY)',
"Add model configuration to modelProviders['openai'] (or other auth types)":
"Add model configuration to modelProviders['openai'] (or other auth types)",
'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig':
'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig',
'Use /model command to select your preferred model from the configured list':
'Use /model command to select your preferred model from the configured list',
'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.':
'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.',
'More instructions please check:': 'More instructions please check:',
'You can configure your API key and models in settings.json':
'You can configure your API key and models in settings.json',
'Refer to the documentation for setup instructions':
'Refer to the documentation for setup instructions',
// ============================================================================
// Auth Dialog - View Titles and Labels
// ============================================================================
'Coding Plan': 'Coding Plan',
'Coding Plan (Bailian, China)': 'Coding Plan (Bailian, China)',
'Coding Plan (Bailian, Global/Intl)': 'Coding Plan (Bailian, Global/Intl)',
"Paste your api key of Bailian Coding Plan and you're all set!":
"Paste your api key of Bailian Coding Plan and you're all set!",
"Paste your api key of Coding Plan (Bailian, Global/Intl) and you're all set!":
"Paste your api key of Coding Plan (Bailian, Global/Intl) and you're all set!",
Custom: 'Custom',
'More instructions about configuring `modelProviders` manually.':
'More instructions about configuring `modelProviders` manually.',
'Select API-KEY configuration mode:': 'Select API-KEY configuration mode:',
'(Press Escape to go back)': '(Press Escape to go back)',
'(Press Enter to submit, Escape to cancel)':
'(Press Enter to submit, Escape to cancel)',
'Select Region for Coding Plan': 'Select Region for Coding Plan',
'Choose based on where your account is registered':
'Choose based on where your account is registered',
'Enter Coding Plan API Key': 'Enter Coding Plan API Key',
// ============================================================================
// Coding Plan International Updates
// ============================================================================
'New model configurations are available for {{region}}. Update now?':
'New model configurations are available for {{region}}. Update now?',
'New model configurations are available for Bailian Coding Plan (China). Update now?':
'New model configurations are available for Bailian Coding Plan (China). Update now?',
'New model configurations are available for Coding Plan (Bailian, Global/Intl). Update now?':
'New model configurations are available for Coding Plan (Bailian, Global/Intl). Update now?',
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
'{{region}} configuration updated successfully. Model switched to "{{model}}".',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':

View file

@ -142,6 +142,7 @@ export default {
'Enter to confirm, Esc to cancel': 'Enter で確定、Esc でキャンセル',
'Enter to select, ↑↓ to navigate, Esc to go back':
'Enter で選択、↑↓ で移動、Esc で戻る',
'Enter to submit, Esc to go back': 'Enter で送信、Esc で戻る',
'Invalid step: {{step}}': '無効なステップ: {{step}}',
'No subagents found.': 'サブエージェントが見つかりません',
"Use '/agents create' to create your first subagent.":
@ -671,18 +672,21 @@ export default {
'🎯 Overall Goal:': '🎯 全体目標:',
// Dialogs - Auth
'Get started': '始める',
'How would you like to authenticate for this project?':
'このプロジェクトの認証方法を選択してください:',
'Select Authentication Method': '認証方法を選択',
'OpenAI API key is required to use OpenAI authentication.':
'OpenAI認証を使用するには OpenAI APIキーが必要です',
'You must select an auth method to proceed. Press Ctrl+C again to exit.':
'続行するには認証方法を選択してください。Ctrl+C をもう一度押すと終了します',
'(Use Enter to Set Auth)': '(Enter で認証を設定)',
'Terms of Services and Privacy Notice for Qwen Code':
'Qwen Code の利用規約とプライバシー通知',
'Terms of Services and Privacy Notice': '利用規約とプライバシー通知',
'Qwen OAuth': 'Qwen OAuth',
'Free \u00B7 Up to 1,000 requests/day \u00B7 Qwen latest models':
'無料 \u00B7 1日最大1,000リクエスト \u00B7 Qwen最新モデル',
'Login with QwenChat account to use daily free quota.':
'QwenChatアカウントでログインして、毎日の無料クォータをご利用ください。',
'Paid \u00B7 Up to 6,000 requests/5 hrs \u00B7 All Alibaba Cloud Coding Plan Models':
'有料 \u00B7 5時間最大6,000リクエスト \u00B7 すべての Alibaba Cloud Coding Plan モデル',
'Alibaba Cloud Coding Plan': 'Alibaba Cloud Coding Plan',
'Bring your own API key': '自分のAPIキーを使用',
'API-KEY': 'API-KEY',
'Use coding plan credentials or your own api-keys/providers.':
'Coding Planの認証情報またはご自身のAPIキー/プロバイダーをご利用ください。',
@ -710,6 +714,8 @@ export default {
'Waiting for Qwen OAuth authentication...': 'Qwen OAuth認証を待っています...',
'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.':
'注: Qwen OAuthを使用しても、settings.json内の既存のAPIキーはクリアされません。必要に応じて後でOpenAI認証に切り替えることができます',
'Note: Your existing API key will not be cleared when using Qwen OAuth.':
'注: Qwen OAuthを使用しても、既存のAPIキーはクリアされません。',
'Authentication timed out. Please try again.':
'認証がタイムアウトしました。再度お試しください',
'Waiting for auth... (Press ESC or CTRL+C to cancel)':
@ -731,6 +737,17 @@ export default {
// Dialogs - Model
'Select Model': 'モデルを選択',
'(Press Esc to close)': '(Esc で閉じる)',
Modality: 'モダリティ',
'Context Window': 'コンテキストウィンドウ',
text: 'テキスト',
'text-only': 'テキストのみ',
image: '画像',
pdf: 'PDF',
audio: '音声',
video: '動画',
'not set': '未設定',
none: 'なし',
unknown: '不明',
'Qwen 3.5 Plus — efficient hybrid model with leading coding performance':
'Qwen 3.5 Plus — 効率的なハイブリッドモデル、業界トップクラスのコーディング性能',
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)':
@ -783,6 +800,27 @@ export default {
"Starting OAuth authentication for MCP server '{{name}}'...":
"MCPサーバー '{{name}}' のOAuth認証を開始中...",
// Startup Tips
'Tips:': 'ヒント:',
'Use /compress when the conversation gets long to summarize history and free up context.':
'会話が長くなったら /compress で履歴を要約し、コンテキストを解放できます。',
'Start a fresh idea with /clear or /new; the previous session stays available in history.':
'/clear または /new で新しいアイデアを始められます。前のセッションは履歴に残ります。',
'Use /bug to submit issues to the maintainers when something goes off.':
'問題が発生したら /bug でメンテナーに報告できます。',
'Switch auth type quickly with /auth.':
'/auth で認証タイプをすばやく切り替えられます。',
'You can run any shell commands from Qwen Code using ! (e.g. !ls).':
'Qwen Code から ! を使って任意のシェルコマンドを実行できます(例: !ls。',
'Type / to open the command popup; Tab autocompletes slash commands and saved prompts.':
'/ を入力してコマンドポップアップを開きます。Tab でスラッシュコマンドと保存済みプロンプトを補完できます。',
'You can resume a previous conversation by running qwen --continue or qwen --resume.':
'qwen --continue または qwen --resume で前の会話を再開できます。',
'You can switch permission mode quickly with Shift+Tab or /approval-mode.':
'Shift+Tab または /approval-mode で権限モードをすばやく切り替えられます。',
'You can switch permission mode quickly with Tab or /approval-mode.':
'Tab または /approval-mode で権限モードをすばやく切り替えられます。',
'Try /insight to generate personalized insights from your chat history.':
'/insight でチャット履歴からパーソナライズされたインサイトを生成できます。',
'Tips for getting started:': '始めるためのヒント:',
'1. Ask questions, edit files, or run commands.':
'1. 質問したり、ファイルを編集したり、コマンドを実行したりできます',
@ -891,32 +929,19 @@ export default {
],
// ============================================================================
// Custom API-KEY Configuration
// Custom API Key Configuration
// ============================================================================
'For advanced users who want to configure models manually.':
'モデルを手動で設定したい上級ユーザー向け。',
'Please configure your models in settings.json:':
'settings.json でモデルを設定してください:',
'Set API key via environment variable (e.g., OPENAI_API_KEY)':
'環境変数を使用して API キーを設定してくださいOPENAI_API_KEY',
"Add model configuration to modelProviders['openai'] (or other auth types)":
"modelProviders['openai'](または他の認証タイプ)にモデル設定を追加してください",
'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig':
'各プロバイダーにはid、envKey必須、およびオプションの baseUrl、generationConfig が必要です',
'Use /model command to select your preferred model from the configured list':
'/model コマンドを使用して、設定済みリストからお好みのモデルを選択してください',
'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.':
'サポートされている認証タイプopenai、anthropic、gemini、vertex-ai など',
'You can configure your API key and models in settings.json':
'settings.json で API キーとモデルを設定できます',
'Refer to the documentation for setup instructions':
'セットアップ手順はドキュメントを参照してください',
// ============================================================================
// Coding Plan Authentication
// ============================================================================
'Please enter your API key:': 'APIキーを入力してください',
'API key cannot be empty.': 'APIキーは空にできません。',
'You can get your exclusive Coding Plan API-KEY here:':
'Coding Plan の API-KEY はこちらで取得できます:',
'New model configurations are available for Bailian Coding Plan. Update now?':
'Bailian Coding Plan の新しいモデル設定が利用可能です。今すぐ更新しますか?',
'You can get your Coding Plan API key here':
'Coding Plan APIキーはこちらで取得できます',
'Coding Plan configuration updated successfully. New models are now available.':
'Coding Plan の設定が正常に更新されました。新しいモデルが利用可能になりました。',
'Coding Plan API key not found. Please re-authenticate with Coding Plan.':
@ -927,32 +952,16 @@ export default {
// ============================================================================
// Auth Dialog - View Titles and Labels
// ============================================================================
'Coding Plan': 'Coding Plan',
'Coding Plan (Bailian, China)': 'Coding Plan (Bailian, 中国)',
'Coding Plan (Bailian, Global/Intl)':
'Coding Plan (Bailian, グローバル/国際)',
"Paste your api key of Bailian Coding Plan and you're all set!":
'Bailian Coding PlanのAPIキーを貼り付けるだけで準備完了です',
"Paste your api key of Coding Plan (Bailian, Global/Intl) and you're all set!":
'Coding Plan (Bailian, グローバル/国際) のAPIキーを貼り付けるだけで準備完了です',
Custom: 'カスタム',
'More instructions about configuring `modelProviders` manually.':
'`modelProviders`を手動で設定する方法の詳細はこちら。',
'Select API-KEY configuration mode:': 'API-KEY設定モードを選択してください',
'(Press Escape to go back)': '(Escapeキーで戻る)',
'(Press Enter to submit, Escape to cancel)':
'(Enterで送信、Escapeでキャンセル)',
'More instructions please check:': '詳細な手順はこちらをご確認ください:',
'Select Region for Coding Plan': 'Coding Planのリージョンを選択',
'Choose based on where your account is registered':
'アカウントの登録先に応じて選択してください',
'Enter Coding Plan API Key': 'Coding Plan APIキーを入力',
// ============================================================================
// Coding Plan International Updates
// ============================================================================
'New model configurations are available for {{region}}. Update now?':
'{{region}} の新しいモデル設定が利用可能です。今すぐ更新しますか?',
'New model configurations are available for Bailian Coding Plan (China). Update now?':
'Bailian Coding Plan (中国) の新しいモデル設定が利用可能です。今すぐ更新しますか?',
'New model configurations are available for Coding Plan (Bailian, Global/Intl). Update now?':
'Coding Plan (Bailian, グローバル/国際) の新しいモデル設定が利用可能です。今すぐ更新しますか?',
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
'{{region}} の設定が正常に更新されました。モデルが "{{model}}" に切り替わりました。',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':

View file

@ -173,6 +173,7 @@ export default {
'Enter to confirm, Esc to cancel': 'Enter para confirmar, Esc para cancelar',
'Enter to select, ↑↓ to navigate, Esc to go back':
'Enter para selecionar, ↑↓ para navegar, Esc para voltar',
'Enter to submit, Esc to go back': 'Enter para enviar, Esc para voltar',
'Invalid step: {{step}}': 'Etapa inválida: {{step}}',
'No subagents found.': 'Nenhum subagente encontrado.',
"Use '/agents create' to create your first subagent.":
@ -950,18 +951,22 @@ export default {
// Dialogs - Auth
// ============================================================================
'Get started': 'Começar',
'How would you like to authenticate for this project?':
'Como você gostaria de se autenticar para este projeto?',
'Select Authentication Method': 'Selecionar Método de Autenticação',
'OpenAI API key is required to use OpenAI authentication.':
'A chave da API do OpenAI é necessária para usar a autenticação do OpenAI.',
'You must select an auth method to proceed. Press Ctrl+C again to exit.':
'Você deve selecionar um método de autenticação para prosseguir. Pressione Ctrl+C novamente para sair.',
'(Use Enter to Set Auth)': '(Use Enter para Definir Autenticação)',
'Terms of Services and Privacy Notice for Qwen Code':
'Termos de Serviço e Aviso de Privacidade do Qwen Code',
'Terms of Services and Privacy Notice':
'Termos de Serviço e Aviso de Privacidade',
'Qwen OAuth': 'Qwen OAuth',
'Free \u00B7 Up to 1,000 requests/day \u00B7 Qwen latest models':
'Gratuito \u00B7 Até 1.000 solicitações/dia \u00B7 Modelos Qwen mais recentes',
'Login with QwenChat account to use daily free quota.':
'Faça login com sua conta QwenChat para usar a cota gratuita diária.',
'Paid \u00B7 Up to 6,000 requests/5 hrs \u00B7 All Alibaba Cloud Coding Plan Models':
'Pago \u00B7 Até 6.000 solicitações/5 hrs \u00B7 Todos os modelos Alibaba Cloud Coding Plan',
'Alibaba Cloud Coding Plan': 'Alibaba Cloud Coding Plan',
'Bring your own API key': 'Traga sua própria chave API',
'API-KEY': 'API-KEY',
'Use coding plan credentials or your own api-keys/providers.':
'Use credenciais do Coding Plan ou suas próprias chaves API/provedores.',
@ -989,6 +994,8 @@ export default {
'Aguardando autenticação Qwen OAuth...',
'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.':
'Nota: Sua chave de API existente no settings.json não será limpa ao usar o Qwen OAuth. Você pode voltar para a autenticação do OpenAI mais tarde, se necessário.',
'Note: Your existing API key will not be cleared when using Qwen OAuth.':
'Nota: Sua chave de API existente não será limpa ao usar o Qwen OAuth.',
'Authentication timed out. Please try again.':
'A autenticação expirou. Tente novamente.',
'Waiting for auth... (Press ESC or CTRL+C to cancel)':
@ -1037,6 +1044,17 @@ export default {
'(default)': '(padrão)',
'(set)': '(definido)',
'(not set)': '(não definido)',
Modality: 'Modalidade',
'Context Window': 'Janela de Contexto',
text: 'texto',
'text-only': 'somente texto',
image: 'imagem',
pdf: 'PDF',
audio: 'áudio',
video: 'vídeo',
'not set': 'não definido',
none: 'nenhum',
unknown: 'desconhecido',
"Failed to switch model to '{{modelId}}'.\n\n{{error}}":
"Falha ao trocar o modelo para '{{modelId}}'.\n\n{{error}}",
'Qwen 3.5 Plus — efficient hybrid model with leading coding performance':
@ -1132,6 +1150,8 @@ export default {
'Você pode retomar uma conversa anterior executando qwen --continue ou qwen --resume.',
'You can switch permission mode quickly with Shift+Tab or /approval-mode.':
'Você pode alternar o modo de permissão rapidamente com Shift+Tab ou /approval-mode.',
'Try /insight to generate personalized insights from your chat history.':
'Experimente /insight para gerar insights personalizados do seu histórico de conversas.',
// ============================================================================
// Exit Screen / Stats
@ -1394,32 +1414,21 @@ export default {
'Falha ao abrir o navegador. Confira a galeria de extensões em {{url}}',
// ============================================================================
// Custom API-KEY Configuration
// Custom API Key Configuration
// ============================================================================
'For advanced users who want to configure models manually.':
'Para usuários avançados que desejam configurar modelos manualmente.',
'Please configure your models in settings.json:':
'Por favor, configure seus modelos em settings.json:',
'Set API key via environment variable (e.g., OPENAI_API_KEY)':
'Defina a chave de API via variável de ambiente (ex: OPENAI_API_KEY)',
"Add model configuration to modelProviders['openai'] (or other auth types)":
"Adicione a configuração do modelo a modelProviders['openai'] (ou outros tipos de autenticação)",
'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig':
'Cada provedor precisa de: id, envKey (obrigatório), além de baseUrl e generationConfig opcionais',
'Use /model command to select your preferred model from the configured list':
'Use o comando /model para selecionar seu modelo preferido da lista configurada',
'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.':
'Tipos de autenticação suportados: openai, anthropic, gemini, vertex-ai, etc.',
'You can configure your API key and models in settings.json':
'Você pode configurar sua chave de API e modelos em settings.json',
'Refer to the documentation for setup instructions':
'Consulte a documentação para instruções de configuração',
// ============================================================================
// Coding Plan Authentication
// ============================================================================
'Please enter your API key:': 'Por favor, digite sua chave de API:',
'API key cannot be empty.': 'A chave de API não pode estar vazia.',
'You can get your exclusive Coding Plan API-KEY here:':
'Você pode obter sua chave de API exclusiva do Coding Plan aqui:',
'New model configurations are available for Bailian Coding Plan. Update now?':
'Novas configurações de modelo estão disponíveis para o Bailian Coding Plan. Atualizar agora?',
'You can get your Coding Plan API key here':
'Você pode obter sua chave de API do Coding Plan aqui',
'New model configurations are available for Alibaba Cloud Coding Plan. Update now?':
'Novas configurações de modelo estão disponíveis para o Alibaba Cloud Coding Plan. Atualizar agora?',
'Coding Plan configuration updated successfully. New models are now available.':
'Configuração do Coding Plan atualizada com sucesso. Novos modelos agora estão disponíveis.',
'Coding Plan API key not found. Please re-authenticate with Coding Plan.':
@ -1430,32 +1439,16 @@ export default {
// ============================================================================
// Auth Dialog - View Titles and Labels
// ============================================================================
'Coding Plan': 'Coding Plan',
'Coding Plan (Bailian, China)': 'Coding Plan (Bailian, China)',
'Coding Plan (Bailian, Global/Intl)': 'Coding Plan (Bailian, Global/Intl)',
"Paste your api key of Bailian Coding Plan and you're all set!":
'Cole sua chave de API do Bailian Coding Plan e pronto!',
"Paste your api key of Coding Plan (Bailian, Global/Intl) and you're all set!":
'Cole sua chave de API do Coding Plan (Bailian, Global/Intl) e pronto!',
Custom: 'Personalizado',
'More instructions about configuring `modelProviders` manually.':
'Mais instruções sobre como configurar `modelProviders` manualmente.',
'Select API-KEY configuration mode:':
'Selecione o modo de configuração da API-KEY:',
'(Press Escape to go back)': '(Pressione Escape para voltar)',
'(Press Enter to submit, Escape to cancel)':
'(Pressione Enter para enviar, Escape para cancelar)',
'More instructions please check:': 'Mais instruções, consulte:',
'Select Region for Coding Plan': 'Selecionar região do Coding Plan',
'Choose based on where your account is registered':
'Escolha com base em onde sua conta está registrada',
'Enter Coding Plan API Key': 'Inserir chave de API do Coding Plan',
// ============================================================================
// Coding Plan International Updates
// ============================================================================
'New model configurations are available for {{region}}. Update now?':
'Novas configurações de modelo estão disponíveis para o {{region}}. Atualizar agora?',
'New model configurations are available for Bailian Coding Plan (China). Update now?':
'Novas configurações de modelo estão disponíveis para o Bailian Coding Plan (China). Atualizar agora?',
'New model configurations are available for Coding Plan (Bailian, Global/Intl). Update now?':
'Novas configurações de modelo estão disponíveis para o Coding Plan (Bailian, Global/Intl). Atualizar agora?',
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
'Configuração do {{region}} atualizada com sucesso. Modelo alterado para "{{model}}".',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':

View file

@ -181,6 +181,7 @@ export default {
'Enter to confirm, Esc to cancel': 'Enter для подтверждения, Esc для отмены',
'Enter to select, ↑↓ to navigate, Esc to go back':
'Enter для выбора, ↑↓ для навигации, Esc для возврата',
'Enter to submit, Esc to go back': 'Enter для отправки, Esc для возврата',
'Invalid step: {{step}}': 'Неверный шаг: {{step}}',
'No subagents found.': 'Подагенты не найдены.',
"Use '/agents create' to create your first subagent.":
@ -950,18 +951,22 @@ export default {
// Диалоги - Авторизация
// ============================================================================
'Get started': 'Начать',
'How would you like to authenticate for this project?':
'Как вы хотите авторизоваться для этого проекта?',
'Select Authentication Method': 'Выберите метод авторизации',
'OpenAI API key is required to use OpenAI authentication.':
'Для использования авторизации OpenAI требуется ключ API OpenAI.',
'You must select an auth method to proceed. Press Ctrl+C again to exit.':
'Вы должны выбрать метод авторизации для продолжения. Нажмите Ctrl+C снова для выхода.',
'(Use Enter to Set Auth)': '(Enter для установки авторизации)',
'Terms of Services and Privacy Notice for Qwen Code':
'Условия обслуживания и уведомление о конфиденциальности для Qwen Code',
'Terms of Services and Privacy Notice':
'Условия обслуживания и уведомление о конфиденциальности',
'Qwen OAuth': 'Qwen OAuth',
'Free \u00B7 Up to 1,000 requests/day \u00B7 Qwen latest models':
'Бесплатно \u00B7 До 1 000 запросов/день \u00B7 Новейшие модели Qwen',
'Login with QwenChat account to use daily free quota.':
'Войдите с помощью аккаунта QwenChat, чтобы использовать ежедневную бесплатную квоту.',
'Paid \u00B7 Up to 6,000 requests/5 hrs \u00B7 All Alibaba Cloud Coding Plan Models':
'Платно \u00B7 До 6 000 запросов/5 часов \u00B7 Все модели Alibaba Cloud Coding Plan',
'Alibaba Cloud Coding Plan': 'Alibaba Cloud Coding Plan',
'Bring your own API key': 'Используйте свой API-ключ',
'API-KEY': 'API-KEY',
'Use coding plan credentials or your own api-keys/providers.':
'Используйте учетные данные Coding Plan или свои собственные API-ключи/провайдеры.',
@ -989,6 +994,8 @@ export default {
'Ожидание авторизации Qwen OAuth...',
'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.':
'Примечание: Ваш существующий ключ API в settings.json не будет удален при использовании Qwen OAuth. Вы можете переключиться обратно на авторизацию OpenAI позже при необходимости.',
'Note: Your existing API key will not be cleared when using Qwen OAuth.':
'Примечание: Ваш существующий ключ API не будет удален при использовании Qwen OAuth.',
'Authentication timed out. Please try again.':
'Время ожидания авторизации истекло. Пожалуйста, попробуйте снова.',
'Waiting for auth... (Press ESC or CTRL+C to cancel)':
@ -1036,6 +1043,17 @@ export default {
'(default)': '(по умолчанию)',
'(set)': '(установлено)',
'(not set)': '(не задано)',
Modality: 'Модальность',
'Context Window': 'Контекстное окно',
text: 'текст',
'text-only': 'только текст',
image: 'изображение',
pdf: 'PDF',
audio: 'аудио',
video: 'видео',
'not set': 'не задано',
none: 'нет',
unknown: 'неизвестно',
"Failed to switch model to '{{modelId}}'.\n\n{{error}}":
"Не удалось переключиться на модель '{{modelId}}'.\n\n{{error}}",
'Qwen 3.5 Plus — efficient hybrid model with leading coding performance':
@ -1384,38 +1402,43 @@ export default {
'Открываем страницу расширений в браузере: {{url}}',
'Failed to open browser. Check out the extensions gallery at {{url}}':
'Не удалось открыть браузер. Посетите галерею расширений по адресу {{url}}',
'Use /compress when the conversation gets long to summarize history and free up context.':
'Используйте /compress, когда разговор становится длинным, чтобы подвести итог и освободить контекст.',
'Start a fresh idea with /clear or /new; the previous session stays available in history.':
'Начните новую идею с /clear или /new; предыдущая сессия останется в истории.',
'Use /bug to submit issues to the maintainers when something goes off.':
'Используйте /bug, чтобы сообщить о проблемах разработчикам.',
'Switch auth type quickly with /auth.':
'Быстро переключите тип аутентификации с помощью /auth.',
'You can run any shell commands from Qwen Code using ! (e.g. !ls).':
'Вы можете выполнять любые shell-команды в Qwen Code с помощью ! (например, !ls).',
'Type / to open the command popup; Tab autocompletes slash commands and saved prompts.':
'Введите /, чтобы открыть меню команд; Tab автодополняет слэш-команды и сохранённые промпты.',
'You can resume a previous conversation by running qwen --continue or qwen --resume.':
'Вы можете продолжить предыдущий разговор, запустив qwen --continue или qwen --resume.',
'You can switch permission mode quickly with Shift+Tab or /approval-mode.':
'Вы можете быстро переключать режим разрешений с помощью Shift+Tab или /approval-mode.',
'You can switch permission mode quickly with Tab or /approval-mode.':
'Вы можете быстро переключать режим разрешений с помощью Tab или /approval-mode.',
'Try /insight to generate personalized insights from your chat history.':
'Попробуйте /insight, чтобы получить персонализированные выводы из истории чатов.',
// ============================================================================
// Custom API-KEY Configuration
// Custom API Key Configuration
// ============================================================================
'For advanced users who want to configure models manually.':
'Для продвинутых пользователей, которые хотят настраивать модели вручную.',
'Please configure your models in settings.json:':
'Пожалуйста, настройте ваши модели в settings.json:',
'Set API key via environment variable (e.g., OPENAI_API_KEY)':
'Установите ключ API через переменную окружения (например, OPENAI_API_KEY)',
"Add model configuration to modelProviders['openai'] (or other auth types)":
"Добавьте конфигурацию модели в modelProviders['openai'] (или другие типы аутентификации)",
'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig':
'Каждому провайдеру нужны: id, envKey (обязательно), а также опциональные baseUrl, generationConfig',
'Use /model command to select your preferred model from the configured list':
'Используйте команду /model, чтобы выбрать предпочитаемую модель из настроенного списка',
'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.':
'Поддерживаемые типы аутентификации: openai, anthropic, gemini, vertex-ai и др.',
'You can configure your API key and models in settings.json':
'Вы можете настроить API-ключ и модели в settings.json',
'Refer to the documentation for setup instructions':
'Инструкции по настройке см. в документации',
// ============================================================================
// Coding Plan Authentication
// ============================================================================
'Please enter your API key:': 'Пожалуйста, введите ваш API-ключ:',
'API key cannot be empty.': 'API-ключ не может быть пустым.',
'You can get your exclusive Coding Plan API-KEY here:':
'Получите свой эксклюзивный API-KEY Coding Plan здесь:',
'New model configurations are available for Bailian Coding Plan. Update now?':
'Доступны новые конфигурации моделей для Bailian Coding Plan. Обновить сейчас?',
'You can get your Coding Plan API key here':
'Вы можете получить API-ключ Coding Plan здесь',
'New model configurations are available for Alibaba Cloud Coding Plan. Update now?':
'Доступны новые конфигурации моделей для Alibaba Cloud Coding Plan. Обновить сейчас?',
'Coding Plan configuration updated successfully. New models are now available.':
'Конфигурация Coding Plan успешно обновлена. Новые модели теперь доступны.',
'Coding Plan API key not found. Please re-authenticate with Coding Plan.':
@ -1426,32 +1449,16 @@ export default {
// ============================================================================
// Auth Dialog - View Titles and Labels
// ============================================================================
'Coding Plan': 'Coding Plan',
'Coding Plan (Bailian, China)': 'Coding Plan (Bailian, Китай)',
'Coding Plan (Bailian, Global/Intl)':
'Coding Plan (Bailian, Глобальный/Международный)',
"Paste your api key of Bailian Coding Plan and you're all set!":
'Вставьте ваш API-ключ Bailian Coding Plan и всё готово!',
"Paste your api key of Coding Plan (Bailian, Global/Intl) and you're all set!":
'Вставьте ваш API-ключ Coding Plan (Bailian, Глобальный/Международный) и всё готово!',
Custom: 'Пользовательский',
'More instructions about configuring `modelProviders` manually.':
'Дополнительные инструкции по ручной настройке `modelProviders`.',
'Select API-KEY configuration mode:': 'Выберите режим конфигурации API-KEY:',
'(Press Escape to go back)': '(Нажмите Escape для возврата)',
'(Press Enter to submit, Escape to cancel)':
'(Нажмите Enter для отправки, Escape для отмены)',
'More instructions please check:': 'Дополнительные инструкции см.:',
'Select Region for Coding Plan': 'Выберите регион Coding Plan',
'Choose based on where your account is registered':
'Выберите в зависимости от места регистрации вашего аккаунта',
'Enter Coding Plan API Key': 'Введите API-ключ Coding Plan',
// ============================================================================
// Coding Plan International Updates
// ============================================================================
'New model configurations are available for {{region}}. Update now?':
'Доступны новые конфигурации моделей для {{region}}. Обновить сейчас?',
'New model configurations are available for Bailian Coding Plan (China). Update now?':
'Доступны новые конфигурации моделей для Bailian Coding Plan (Китай). Обновить сейчас?',
'New model configurations are available for Coding Plan (Bailian, Global/Intl). Update now?':
'Доступны новые конфигурации моделей для Coding Plan (Bailian, Глобальный/Международный). Обновить сейчас?',
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
'Конфигурация {{region}} успешно обновлена. Модель переключена на "{{model}}".',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':

View file

@ -173,6 +173,7 @@ export default {
'Enter to confirm, Esc to cancel': 'Enter 确认Esc 取消',
'Enter to select, ↑↓ to navigate, Esc to go back':
'Enter 选择,↑↓ 导航Esc 返回',
'Enter to submit, Esc to go back': 'Enter 提交Esc 返回',
'Invalid step: {{step}}': '无效步骤: {{step}}',
'No subagents found.': '未找到子智能体。',
"Use '/agents create' to create your first subagent.":
@ -882,18 +883,21 @@ export default {
// Dialogs - Auth
// ============================================================================
'Get started': '开始使用',
'How would you like to authenticate for this project?':
'您希望如何为此项目进行身份验证?',
'Select Authentication Method': '选择认证方式',
'OpenAI API key is required to use OpenAI authentication.':
'使用 OpenAI 认证需要 OpenAI API 密钥',
'You must select an auth method to proceed. Press Ctrl+C again to exit.':
'您必须选择认证方法才能继续。再次按 Ctrl+C 退出',
'(Use Enter to Set Auth)': '(使用 Enter 设置认证)',
'Terms of Services and Privacy Notice for Qwen Code':
'Qwen Code 的服务条款和隐私声明',
'Terms of Services and Privacy Notice': '服务条款和隐私声明',
'Qwen OAuth': 'Qwen OAuth (免费)',
'Free \u00B7 Up to 1,000 requests/day \u00B7 Qwen latest models':
'免费 \u00B7 每天最多 1,000 次请求 \u00B7 Qwen 最新模型',
'Login with QwenChat account to use daily free quota.':
'使用 QwenChat 账号登录,享受每日免费额度。',
'Paid \u00B7 Up to 6,000 requests/5 hrs \u00B7 All Alibaba Cloud Coding Plan Models':
'付费 \u00B7 每 5 小时最多 6,000 次请求 \u00B7 支持阿里云百炼 Coding Plan 全部模型',
'Alibaba Cloud Coding Plan': '阿里云百炼 Coding Plan',
'Bring your own API key': '使用自己的 API 密钥',
'Use coding plan credentials or your own api-keys/providers.':
'使用 Coding Plan 凭证或您自己的 API 密钥/提供商。',
OpenAI: 'OpenAI',
@ -917,6 +921,8 @@ export default {
'Waiting for Qwen OAuth authentication...': '正在等待 Qwen OAuth 认证...',
'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.':
'注意:使用 Qwen OAuth 时settings.json 中现有的 API 密钥不会被清除。如果需要,您可以稍后切换回 OpenAI 认证。',
'Note: Your existing API key will not be cleared when using Qwen OAuth.':
'注意:使用 Qwen OAuth 时,现有的 API 密钥不会被清除。',
'Authentication timed out. Please try again.': '认证超时。请重试。',
'Waiting for auth... (Press ESC or CTRL+C to cancel)':
'正在等待认证...(按 ESC 或 CTRL+C 取消)',
@ -961,6 +967,17 @@ export default {
'(default)': '(默认)',
'(set)': '(已设置)',
'(not set)': '(未设置)',
Modality: '模态',
'Context Window': '上下文窗口',
text: '文本',
'text-only': '纯文本',
image: '图像',
pdf: 'PDF',
audio: '音频',
video: '视频',
'not set': '未设置',
none: '无',
unknown: '未知',
"Failed to switch model to '{{modelId}}'.\n\n{{error}}":
"无法切换到模型 '{{modelId}}'.\n\n{{error}}",
'Qwen 3.5 Plus — efficient hybrid model with leading coding performance':
@ -1052,6 +1069,8 @@ export default {
'按 Shift+Tab 或输入 /approval-mode 可快速切换权限模式。',
'You can switch permission mode quickly with Tab or /approval-mode.':
'按 Tab 或输入 /approval-mode 可快速切换权限模式。',
'Try /insight to generate personalized insights from your chat history.':
'试试 /insight从聊天记录中生成个性化洞察。',
// ============================================================================
// Exit Screen / Stats
@ -1215,18 +1234,22 @@ export default {
'Rate limit error: {{reason}}': '触发限流:{{reason}}',
'Retrying in {{seconds}} seconds… (attempt {{attempt}}/{{maxRetries}})':
'将于 {{seconds}} 秒后重试…(第 {{attempt}}/{{maxRetries}} 次)',
'Press Ctrl+Y to retry': '按 Ctrl+Y 重试。',
'No failed request to retry.': '没有可重试的失败请求。',
'to retry last request': '重试上一次请求',
// ============================================================================
// Coding Plan Authentication
// ============================================================================
'Please enter your API key:': '请输入您的 API Key',
'API key cannot be empty.': 'API Key 不能为空。',
'You can get your exclusive Coding Plan API-KEY here:':
'您可以在这里获取专属的 Coding Plan API-KEY',
'Invalid API key. Coding Plan API keys start with "sk-sp-". Please check.':
'无效的 API KeyCoding Plan API Key 均以 "sk-sp-" 开头,请检查',
'You can get your Coding Plan API key here':
'您可以在这里获取 Coding Plan API Key',
'API key is stored in settings.env. You can migrate it to a .env file for better security.':
'API Key 已存储在 settings.env 中。您可以将其迁移到 .env 文件以获得更好的安全性。',
'New model configurations are available for Bailian Coding Plan. Update now?':
'百炼 Coding Plan 有新模型配置可用。是否立即更新?',
'New model configurations are available for Alibaba Cloud Coding Plan. Update now?':
'阿里云百炼 Coding Plan 有新模型配置可用。是否立即更新?',
'Coding Plan configuration updated successfully. New models are now available.':
'Coding Plan 配置更新成功。新模型现已可用。',
'Coding Plan API key not found. Please re-authenticate with Coding Plan.':
@ -1235,51 +1258,25 @@ export default {
'更新 Coding Plan 配置失败:{{message}}',
// ============================================================================
// Custom API-KEY Configuration
// Custom API Key Configuration
// ============================================================================
'For advanced users who want to configure models manually.':
'适合需要手动配置模型的高级用户。',
'Please configure your models in settings.json:':
'请在 settings.json 中配置您的模型:',
'Set API key via environment variable (e.g., OPENAI_API_KEY)':
'通过环境变量设置 API Key例如OPENAI_API_KEY',
"Add model configuration to modelProviders['openai'] (or other auth types)":
"将模型配置添加到 modelProviders['openai'](或其他认证类型)",
'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig':
'每个提供商需要id、envKey必需以及可选的 baseUrl、generationConfig',
'Use /model command to select your preferred model from the configured list':
'使用 /model 命令从配置列表中选择您偏好的模型',
'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.':
'支持的认证类型openai、anthropic、gemini、vertex-ai 等',
'More instructions please check:': '更多说明请查看:',
'You can configure your API key and models in settings.json':
'您可以在 settings.json 中配置 API Key 和模型',
'Refer to the documentation for setup instructions': '请参考文档了解配置说明',
// ============================================================================
// Auth Dialog - View Titles and Labels
// ============================================================================
'API-KEY': 'API-KEY',
'Coding Plan': 'Coding Plan',
'Coding Plan (Bailian, China)': 'Coding Plan (百炼, 中国)',
'Coding Plan (Bailian, Global/Intl)': 'Coding Plan (百炼, 全球/国际)',
"Paste your api key of Bailian Coding Plan and you're all set!":
'粘贴您的百炼 Coding Plan API Key即可完成设置',
"Paste your api key of Coding Plan (Bailian, Global/Intl) and you're all set!":
'粘贴您的 Coding Plan (百炼, 全球/国际) API Key即可完成设置',
Custom: '自定义',
'More instructions about configuring `modelProviders` manually.':
'关于手动配置 `modelProviders` 的更多说明。',
'Select API-KEY configuration mode:': '选择 API-KEY 配置模式:',
'(Press Escape to go back)': '(按 Escape 键返回)',
'(Press Enter to submit, Escape to cancel)': '(按 Enter 提交Escape 取消)',
'Select Region for Coding Plan': '选择 Coding Plan 区域',
'Choose based on where your account is registered':
'请根据您的账号注册地区选择',
'Enter Coding Plan API Key': '输入 Coding Plan API Key',
// ============================================================================
// Coding Plan International Updates
// ============================================================================
'New model configurations are available for {{region}}. Update now?':
'{{region}} 有新的模型配置可用。是否立即更新?',
'New model configurations are available for Bailian Coding Plan (China). Update now?':
'百炼 Coding Plan (中国) 有新的模型配置可用。是否立即更新?',
'New model configurations are available for Coding Plan (Bailian, Global/Intl). Update now?':
'Coding Plan (百炼, 全球/国际) 有新的模型配置可用。是否立即更新?',
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
'{{region}} 配置更新成功。模型已切换至 "{{model}}"。',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':

View file

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

View file

@ -629,6 +629,7 @@ export const AppContainer = (props: AppContainerProps) => {
pendingHistoryItems: pendingGeminiHistoryItems,
thought,
cancelOngoingRequest,
retryLastPrompt,
handleApprovalModeChange,
activePtyId,
loopDetectionConfirmationRequest,
@ -1532,6 +1533,7 @@ export const AppContainer = (props: AppContainerProps) => {
onSuggestionsVisibilityChange: setHasSuggestionsVisible,
refreshStatic,
handleFinalSubmit,
handleRetryLastPrompt: retryLastPrompt,
handleClearScreen,
// Welcome back dialog
handleWelcomeBackSelection,
@ -1575,6 +1577,7 @@ export const AppContainer = (props: AppContainerProps) => {
handleEscapePromptChange,
refreshStatic,
handleFinalSubmit,
retryLastPrompt,
handleClearScreen,
handleWelcomeBackSelection,
handleWelcomeBackClose,

View file

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

View file

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

View file

@ -300,7 +300,7 @@ export const useAuthCommand = (
setAuthError(null);
// Get configuration based on region
const { template, version, regionName } = getCodingPlanConfig(region);
const { template, version } = getCodingPlanConfig(region);
// Get persist scope
const persistScope = getPersistScopeForModelSelection(settings);
@ -390,7 +390,7 @@ export const useAuthCommand = (
type: MessageType.INFO,
text: t(
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).',
{ region: regionName },
{ region: t('Alibaba Cloud Coding Plan') },
),
},
Date.now(),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -126,7 +126,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
<WarningMessage text={itemForDisplay.text} />
)}
{itemForDisplay.type === 'error' && (
<ErrorMessage text={itemForDisplay.text} />
<ErrorMessage text={itemForDisplay.text} hint={itemForDisplay.hint} />
)}
{itemForDisplay.type === 'retry_countdown' && (
<RetryCountdownMessage text={itemForDisplay.text} />

View file

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

View file

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

View file

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

View file

@ -114,10 +114,9 @@ describe('<ModelDialog />', () => {
cleanup();
});
it('renders the title and help text', () => {
it('renders the title', () => {
const { getByText } = renderComponent();
expect(getByText('Select Model')).toBeDefined();
expect(getByText('(Press Esc to close)')).toBeDefined();
});
it('passes all model options to DescriptiveRadioButtonSelect', () => {
@ -289,11 +288,12 @@ describe('<ModelDialog />', () => {
expect(props.onClose).toHaveBeenCalledTimes(1);
});
it('does not pass onHighlight to DescriptiveRadioButtonSelect', () => {
it('passes onHighlight to DescriptiveRadioButtonSelect', () => {
renderComponent();
const childOnHighlight = mockedSelect.mock.calls[0][0].onHighlight;
expect(childOnHighlight).toBeUndefined();
expect(childOnHighlight).toBeDefined();
expect(typeof childOnHighlight).toBe('function');
});
it('calls onClose prop when "escape" key is pressed', () => {

View file

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

View file

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

View file

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

View file

@ -10,9 +10,17 @@ import { theme } from '../../semantic-colors.js';
interface ErrorMessageProps {
text: string;
/** Optional inline hint displayed after the error text in secondary/dimmed color */
hint?: string;
}
export const ErrorMessage: React.FC<ErrorMessageProps> = ({ text }) => {
/**
* Renders an error message with a "✕" prefix.
* When a hint is provided (e.g., retry countdown), it is displayed inline
* in parentheses with a dimmed secondary color, similar to the ESC hint
* style used in LoadingIndicator.
*/
export const ErrorMessage: React.FC<ErrorMessageProps> = ({ text, hint }) => {
const prefix = '✕ ';
const prefixWidth = prefix.length;
@ -21,10 +29,9 @@ export const ErrorMessage: React.FC<ErrorMessageProps> = ({ text }) => {
<Box width={prefixWidth}>
<Text color={theme.status.error}>{prefix}</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap" color={theme.status.error}>
{text}
</Text>
<Box flexGrow={1} flexWrap="wrap" flexDirection="row">
<Text color={theme.status.error}>{text}</Text>
{hint && <Text color={theme.text.secondary}> ({hint})</Text>}
</Box>
</Box>
);

View file

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

View file

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

View file

@ -66,6 +66,7 @@ export interface UIActions {
onSuggestionsVisibilityChange: (visible: boolean) => void;
refreshStatic: () => void;
handleFinalSubmit: (value: string) => void;
handleRetryLastPrompt: () => void;
handleClearScreen: () => void;
// Welcome back dialog
handleWelcomeBackSelection: (choice: 'continue' | 'restart') => void;

View file

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

View file

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

View file

@ -2304,40 +2304,30 @@ describe('useGeminiStream', () => {
result.current.pendingHistoryItems.find(
(item) => item.type === MessageType.ERROR,
);
const findCountdownItem = () =>
result.current.pendingHistoryItems.find(
(item) => item.type === 'retry_countdown',
);
let errorItem = findErrorItem();
let countdownItem = findCountdownItem();
for (
let attempts = 0;
attempts < 5 && (!errorItem || !countdownItem);
attempts++
) {
for (let attempts = 0; attempts < 5 && !errorItem; attempts++) {
await act(async () => {
await Promise.resolve();
});
errorItem = findErrorItem();
countdownItem = findCountdownItem();
}
// Error line should be rendered as ERROR type (wrapped by parseAndFormatApiError)
// Error item should contain the error text and a retry hint
expect(errorItem?.text).toContain('Rate limit exceeded');
// Countdown line should be rendered as retry_countdown type
expect(countdownItem?.text).toContain('Retrying in 3 seconds');
// Countdown hint should be inline on the error item (not a separate item)
expect((errorItem as { hint?: string })?.hint).toContain('3s');
expect((errorItem as { hint?: string })?.hint).toContain('attempt 1/3');
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
const countdownAfterOneSecond = result.current.pendingHistoryItems.find(
(item) => item.type === 'retry_countdown',
const errorAfterOneSecond = result.current.pendingHistoryItems.find(
(item) => item.type === MessageType.ERROR,
);
expect(countdownAfterOneSecond?.text).toContain(
'Retrying in 2 seconds',
expect((errorAfterOneSecond as { hint?: string })?.hint).toContain(
'2s',
);
resolveStream?.();
@ -2347,15 +2337,11 @@ describe('useGeminiStream', () => {
await vi.runAllTimersAsync();
});
// Both error and countdown should be cleared after retry succeeds
// Error item (with hint) should be cleared after retry succeeds
const remainingError = result.current.pendingHistoryItems.find(
(item) => item.type === MessageType.ERROR,
);
const remainingCountdown = result.current.pendingHistoryItems.find(
(item) => item.type === 'retry_countdown',
);
expect(remainingError).toBeUndefined();
expect(remainingCountdown).toBeUndefined();
} finally {
vi.useRealTimers();
}
@ -2525,14 +2511,13 @@ describe('useGeminiStream', () => {
await result.current.submitQuery('Test query');
});
// Verify error message was added
// Verify error message appears in pending history items (not via addItem,
// since errors with retry hints are now stored as pending items)
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
}),
expect.any(Number),
const errorItem = result.current.pendingHistoryItems.find(
(item) => item.type === 'error',
);
expect(errorItem).toBeDefined();
});
// Verify parseAndFormatApiError was called

View file

@ -169,12 +169,17 @@ export const useGeminiStream = (
const abortControllerRef = useRef<AbortController | null>(null);
const turnCancelledRef = useRef(false);
const isSubmittingQueryRef = useRef(false);
const lastPromptRef = useRef<PartListUnion | null>(null);
const lastPromptErroredRef = useRef(false);
const [isResponding, setIsResponding] = useState<boolean>(false);
const [thought, setThought] = useState<ThoughtSummary | null>(null);
const [pendingHistoryItem, pendingHistoryItemRef, setPendingHistoryItem] =
useStateAndRef<HistoryItemWithoutId | null>(null);
const [pendingRetryErrorItem, setPendingRetryErrorItem] =
useState<HistoryItemWithoutId | null>(null);
const [
pendingRetryErrorItem,
pendingRetryErrorItemRef,
setPendingRetryErrorItem,
] = useStateAndRef<HistoryItemWithoutId | null>(null);
const [
pendingRetryCountdownItem,
pendingRetryCountdownItemRef,
@ -254,11 +259,18 @@ export const useGeminiStream = (
}
}, []);
/**
* Clears the retry countdown timer and pending retry items.
*/
const clearRetryCountdown = useCallback(() => {
stopRetryCountdownTimer();
setPendingRetryErrorItem(null);
setPendingRetryCountdownItem(null);
}, [setPendingRetryCountdownItem, stopRetryCountdownTimer]);
}, [
setPendingRetryErrorItem,
setPendingRetryCountdownItem,
stopRetryCountdownTimer,
]);
const startRetryCountdown = useCallback(
(retryInfo: {
@ -273,18 +285,21 @@ export const useGeminiStream = (
const retryReasonText =
message ?? t('Rate limit exceeded. Please wait and try again.');
// Error line stays static (red with ✕ prefix)
setPendingRetryErrorItem({
type: MessageType.ERROR,
text: retryReasonText,
});
// Countdown line updates every second (dim/secondary color)
const updateCountdown = () => {
const elapsedMs = Date.now() - startTime;
const remainingMs = Math.max(0, delayMs - elapsedMs);
const remainingSec = Math.ceil(remainingMs / 1000);
// Update error item with hint containing countdown info (short format)
const hintText = `Retrying in ${remainingSec}s… (attempt ${attempt}/${maxRetries})`;
setPendingRetryErrorItem({
type: MessageType.ERROR,
text: retryReasonText,
hint: hintText,
});
setPendingRetryCountdownItem({
type: 'retry_countdown',
text: t(
@ -305,7 +320,11 @@ export const useGeminiStream = (
updateCountdown();
retryCountdownTimerRef.current = setInterval(updateCountdown, 1000);
},
[setPendingRetryCountdownItem, stopRetryCountdownTimer],
[
setPendingRetryErrorItem,
setPendingRetryCountdownItem,
stopRetryCountdownTimer,
],
);
useEffect(() => () => stopRetryCountdownTimer(), [stopRetryCountdownTimer]);
@ -693,6 +712,7 @@ export const useGeminiStream = (
return;
}
lastPromptErroredRef.current = false;
if (pendingHistoryItemRef.current) {
if (pendingHistoryItemRef.current.type === 'tool_group') {
const updatedTools = pendingHistoryItemRef.current.tools.map(
@ -732,27 +752,36 @@ export const useGeminiStream = (
const handleErrorEvent = useCallback(
(eventValue: GeminiErrorEventValue, userMessageTimestamp: number) => {
lastPromptErroredRef.current = true;
if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
setPendingHistoryItem(null);
}
addItem(
{
type: MessageType.ERROR,
// Only show Ctrl+Y hint if not already showing an auto-retry countdown
// (auto-retry countdown is shown when retryCountdownTimerRef is active)
const isShowingAutoRetry = retryCountdownTimerRef.current !== null;
clearRetryCountdown();
if (!isShowingAutoRetry) {
const retryHint = t('Press Ctrl+Y to retry');
// Store error with hint as a pending item (not in history).
// This allows the hint to be removed when the user retries with Ctrl+Y,
// since pending items are in the dynamic rendering area (not <Static>).
setPendingRetryErrorItem({
type: 'error' as const,
text: parseAndFormatApiError(
eventValue.error,
config.getContentGeneratorConfig()?.authType,
),
},
userMessageTimestamp,
);
clearRetryCountdown();
hint: retryHint,
});
}
setThought(null); // Reset thought when there's an error
},
[
addItem,
pendingHistoryItemRef,
setPendingHistoryItem,
setPendingRetryErrorItem,
config,
setThought,
clearRetryCountdown,
@ -816,7 +845,10 @@ export const useGeminiStream = (
userMessageTimestamp,
);
}
clearRetryCountdown();
// Only clear auto-retry countdown errors (those with active timer)
if (retryCountdownTimerRef.current) {
clearRetryCountdown();
}
},
[addItem, clearRetryCountdown],
);
@ -1032,7 +1064,7 @@ export const useGeminiStream = (
const submitQuery = useCallback(
async (
query: PartListUnion,
options?: { isContinuation: boolean },
options?: { isContinuation: boolean; skipPreparation?: boolean },
prompt_id?: string,
) => {
// Prevent concurrent executions of submitQuery, but allow continuations
@ -1056,7 +1088,11 @@ export const useGeminiStream = (
// Reset quota error flag when starting a new query (not a continuation)
if (!options?.isContinuation) {
setModelSwitchedFromQuotaError(false);
// No quota-error / fallback routing mechanism currently; keep state minimal.
// Commit any pending retry error to history (without hint) since the
// user is starting a new conversation turn
if (pendingRetryCountdownItemRef.current) {
clearRetryCountdown();
}
}
abortControllerRef.current = new AbortController();
@ -1068,12 +1104,14 @@ export const useGeminiStream = (
}
return promptIdContext.run(prompt_id, async () => {
const { queryToSend, shouldProceed } = await prepareQueryForGemini(
query,
userMessageTimestamp,
abortSignal,
prompt_id!,
);
const { queryToSend, shouldProceed } = options?.skipPreparation
? { queryToSend: query, shouldProceed: true }
: await prepareQueryForGemini(
query,
userMessageTimestamp,
abortSignal,
prompt_id!,
);
if (!shouldProceed || queryToSend === null) {
isSubmittingQueryRef.current = false;
@ -1095,6 +1133,8 @@ export const useGeminiStream = (
}
const finalQueryToSend = queryToSend;
lastPromptRef.current = finalQueryToSend;
lastPromptErroredRef.current = false;
if (!options?.isContinuation) {
// trigger new prompt event for session stats in CLI
@ -1143,6 +1183,12 @@ export const useGeminiStream = (
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
setPendingHistoryItem(null);
}
// Only clear auto-retry countdown errors (those with an active timer).
// Do NOT clear static error+hint from handleErrorEvent — those should
// remain visible until the user presses Ctrl+Y to retry.
if (retryCountdownTimerRef.current) {
clearRetryCountdown();
}
if (loopDetectedRef.current) {
loopDetectedRef.current = false;
handleLoopDetectedEvent();
@ -1151,16 +1197,17 @@ export const useGeminiStream = (
if (error instanceof UnauthorizedError) {
onAuthError('Session expired or is unauthorized.');
} else if (!isNodeError(error) || error.name !== 'AbortError') {
addItem(
{
type: MessageType.ERROR,
text: parseAndFormatApiError(
getErrorMessage(error) || 'Unknown error',
config.getContentGeneratorConfig()?.authType,
),
},
userMessageTimestamp,
);
lastPromptErroredRef.current = true;
const retryHint = t('Press Ctrl+Y to retry');
// Store error with hint as a pending item (same as handleErrorEvent)
setPendingRetryErrorItem({
type: 'error' as const,
text: parseAndFormatApiError(
getErrorMessage(error) || 'Unknown error',
config.getContentGeneratorConfig()?.authType,
),
hint: retryHint,
});
}
} finally {
setIsResponding(false);
@ -1183,9 +1230,71 @@ export const useGeminiStream = (
startNewPrompt,
getPromptCount,
handleLoopDetectedEvent,
clearRetryCountdown,
pendingRetryCountdownItemRef,
setPendingRetryErrorItem,
],
);
/**
* Retries the last failed prompt when the user presses Ctrl+Y.
*
* Activation conditions for Ctrl+Y shortcut:
* 1. The last request must have failed (lastPromptErroredRef.current === true)
* 2. Current streaming state must NOT be "Responding" (avoid interrupting ongoing stream)
* 3. Current streaming state must NOT be "WaitingForConfirmation" (avoid conflicting with tool confirmation flow)
* 4. There must be a stored lastPrompt in lastPromptRef.current
*
* When conditions are not met:
* - If streaming is active (Responding/WaitingForConfirmation): silently return without action
* - If no failed request exists: display "No failed request to retry." info message
*
* When conditions are met:
* - Clears any pending auto-retry countdown to avoid duplicate retries
* - Re-submits the last query with skipPreparation: true for faster retry
*
* This function is exposed via UIActionsContext and triggered by InputPrompt
* when the user presses Ctrl+Y (bound to Command.RETRY_LAST in keyBindings.ts).
*/
const retryLastPrompt = useCallback(async () => {
if (
streamingState === StreamingState.Responding ||
streamingState === StreamingState.WaitingForConfirmation
) {
return;
}
const lastPrompt = lastPromptRef.current;
if (!lastPrompt || !lastPromptErroredRef.current) {
addItem(
{
type: MessageType.INFO,
text: t('No failed request to retry.'),
},
Date.now(),
);
return;
}
// Commit the error to history (without hint) before clearing
const errorItem = pendingRetryErrorItemRef.current;
if (errorItem) {
addItem({ type: errorItem.type, text: errorItem.text }, Date.now());
}
clearRetryCountdown();
await submitQuery(lastPrompt, {
isContinuation: false,
skipPreparation: true,
});
}, [
streamingState,
addItem,
clearRetryCountdown,
submitQuery,
pendingRetryErrorItemRef,
]);
const handleApprovalModeChange = useCallback(
async (newApprovalMode: ApprovalMode) => {
// Auto-approve pending tool calls when switching to auto-approval modes
@ -1489,6 +1598,7 @@ export const useGeminiStream = (
pendingHistoryItems,
thought,
cancelOngoingRequest,
retryLastPrompt,
pendingToolCalls: toolCalls,
handleApprovalModeChange,
activePtyId,

View file

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

View file

@ -121,6 +121,7 @@ export type HistoryItemInfo = HistoryItemBase & {
export type HistoryItemError = HistoryItemBase & {
type: 'error';
text: string;
hint?: string; // Optional inline hint (e.g., retry countdown) displayed in secondary color
};
export type HistoryItemWarning = HistoryItemBase & {

View file

@ -380,4 +380,62 @@ describe('languageUtils', () => {
expect(fs.writeFileSync).not.toHaveBeenCalled();
});
});
describe('output-language.md path resolution priority', () => {
it('should prefer project-level path over global path', () => {
const projectPath = '/project/.qwen/output-language.md';
const globalPath = '/mock/home/.qwen/output-language.md';
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p.toString() === projectPath) return true;
if (p.toString() === globalPath) return true;
return false;
});
let resolvedPath: string | undefined;
if (fs.existsSync(projectPath)) {
resolvedPath = projectPath;
} else if (fs.existsSync(globalPath)) {
resolvedPath = globalPath;
}
expect(resolvedPath).toBe(projectPath);
});
it('should fall back to global path when project-level does not exist', () => {
const projectPath = '/project/.qwen/output-language.md';
const globalPath = '/mock/home/.qwen/output-language.md';
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p.toString() === projectPath) return false;
if (p.toString() === globalPath) return true;
return false;
});
let resolvedPath: string | undefined;
if (fs.existsSync(projectPath)) {
resolvedPath = projectPath;
} else if (fs.existsSync(globalPath)) {
resolvedPath = globalPath;
}
expect(resolvedPath).toBe(globalPath);
});
it('should return undefined when neither path exists', () => {
const projectPath = '/project/.qwen/output-language.md';
const globalPath = '/mock/home/.qwen/output-language.md';
vi.mocked(fs.existsSync).mockReturnValue(false);
let resolvedPath: string | undefined;
if (fs.existsSync(projectPath)) {
resolvedPath = projectPath;
} else if (fs.existsSync(globalPath)) {
resolvedPath = globalPath;
}
expect(resolvedPath).toBeUndefined();
});
});
});