mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-30 04:30:48 +00:00
Merge branch 'main' into feat/mcp-tui
This commit is contained in:
commit
1542a2bdc4
114 changed files with 6943 additions and 1324 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.5",
|
||||
"description": "Qwen Code",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
@ -34,7 +34,7 @@
|
|||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.10.1"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.10.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "1.30.0",
|
||||
|
|
@ -81,12 +81,12 @@
|
|||
"@types/diff": "^7.0.2",
|
||||
"@types/dotenv": "^6.1.1",
|
||||
"@types/node": "^20.11.24",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/yargs": "^17.0.32",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"archiver": "^7.0.1",
|
||||
"ink-testing-library": "^4.0.0",
|
||||
"jsdom": "^26.1.0",
|
||||
|
|
@ -95,6 +95,15 @@
|
|||
"typescript": "^5.3.3",
|
||||
"vitest": "^3.1.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@teddyzhu/clipboard": "^0.0.5",
|
||||
"@teddyzhu/clipboard-darwin-arm64": "0.0.5",
|
||||
"@teddyzhu/clipboard-darwin-x64": "0.0.5",
|
||||
"@teddyzhu/clipboard-linux-x64-gnu": "0.0.5",
|
||||
"@teddyzhu/clipboard-linux-arm64-gnu": "0.0.5",
|
||||
"@teddyzhu/clipboard-win32-x64-msvc": "0.0.5",
|
||||
"@teddyzhu/clipboard-win32-arm64-msvc": "0.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,7 +84,8 @@ export class AcpFileSystemService implements FileSystemService {
|
|||
limit: 1,
|
||||
});
|
||||
// Check if content starts with BOM character (U+FEFF)
|
||||
return response.content.charCodeAt(0) === 0xfeff;
|
||||
// Use codePointAt for better Unicode support and check content length first
|
||||
return response.content.length > 0 && response.content.codePointAt(0) === 0xfeff;
|
||||
} catch {
|
||||
// Fall through to fallback if ACP read fails
|
||||
}
|
||||
|
|
|
|||
|
|
@ -516,6 +516,18 @@ export class Session implements SessionContext {
|
|||
? await invocation.shouldConfirmExecute(abortSignal)
|
||||
: false;
|
||||
|
||||
// Check for plan mode enforcement - block non-read-only tools
|
||||
const isPlanMode = this.config.getApprovalMode() === ApprovalMode.PLAN;
|
||||
if (isPlanMode && !isExitPlanModeTool && confirmationDetails) {
|
||||
// In plan mode, block any tool that requires confirmation (write operations)
|
||||
return errorResponse(
|
||||
new Error(
|
||||
`Plan mode is active. The tool "${fc.name}" cannot be executed because it modifies the system. ` +
|
||||
'Please use the exit_plan_mode tool to present your plan and exit plan mode before making changes.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (confirmationDetails) {
|
||||
const content: acp.ToolCallContent[] = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -242,9 +242,14 @@ describe('parseArguments', () => {
|
|||
});
|
||||
|
||||
it('should allow -r flag as alias for --resume', async () => {
|
||||
process.argv = ['node', 'script.js', '-r', 'session-123'];
|
||||
process.argv = [
|
||||
'node',
|
||||
'script.js',
|
||||
'-r',
|
||||
'123e4567-e89b-12d3-a456-426614174000',
|
||||
];
|
||||
const argv = await parseArguments();
|
||||
expect(argv.resume).toBe('session-123');
|
||||
expect(argv.resume).toBe('123e4567-e89b-12d3-a456-426614174000');
|
||||
});
|
||||
|
||||
it('should allow -c flag as alias for --continue', async () => {
|
||||
|
|
|
|||
|
|
@ -50,6 +50,19 @@ import { loadSandboxConfig } from './sandboxConfig.js';
|
|||
import { appEvents } from '../utils/events.js';
|
||||
import { mcpCommand } from '../commands/mcp.js';
|
||||
|
||||
// UUID v4 regex pattern for validation
|
||||
const UUID_REGEX =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* Validates if a string is a valid UUID format
|
||||
* @param value - The string to validate
|
||||
* @returns True if the string is a valid UUID, false otherwise
|
||||
*/
|
||||
function isValidUUID(value: string): boolean {
|
||||
return UUID_REGEX.test(value);
|
||||
}
|
||||
|
||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||
import { buildWebSearchConfig } from './webSearch.js';
|
||||
import { writeStderrLine } from '../utils/stdioHelpers.js';
|
||||
|
|
@ -137,6 +150,8 @@ export interface CliArgs {
|
|||
continue: boolean | undefined;
|
||||
/** Resume a specific session by its ID */
|
||||
resume: string | undefined;
|
||||
/** Specify a session ID without session resumption */
|
||||
sessionId: string | undefined;
|
||||
maxSessionTurns: number | undefined;
|
||||
coreTools: string[] | undefined;
|
||||
excludeTools: string[] | undefined;
|
||||
|
|
@ -449,6 +464,10 @@ export async function parseArguments(): Promise<CliArgs> {
|
|||
description:
|
||||
'Resume a specific session by its ID. Use without an ID to show session picker.',
|
||||
})
|
||||
.option('session-id', {
|
||||
type: 'string',
|
||||
description: 'Specify a session ID for this run.',
|
||||
})
|
||||
.option('max-session-turns', {
|
||||
type: 'number',
|
||||
description: 'Maximum number of session turns',
|
||||
|
|
@ -535,6 +554,15 @@ export async function parseArguments(): Promise<CliArgs> {
|
|||
if (argv['continue'] && argv['resume']) {
|
||||
return 'Cannot use both --continue and --resume together. Use --continue to resume the latest session, or --resume <sessionId> to resume a specific session.';
|
||||
}
|
||||
if (argv['sessionId'] && (argv['continue'] || argv['resume'])) {
|
||||
return 'Cannot use --session-id with --continue or --resume. Use --session-id to start a new session with a specific ID, or use --continue/--resume to resume an existing session.';
|
||||
}
|
||||
if (argv['sessionId'] && !isValidUUID(argv['sessionId'] as string)) {
|
||||
return `Invalid --session-id: "${argv['sessionId']}". Must be a valid UUID (e.g., "123e4567-e89b-12d3-a456-426614174000").`;
|
||||
}
|
||||
if (argv['resume'] && !isValidUUID(argv['resume'] as string)) {
|
||||
return `Invalid --resume: "${argv['resume']}". Must be a valid UUID (e.g., "123e4567-e89b-12d3-a456-426614174000").`;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
)
|
||||
|
|
@ -899,6 +927,17 @@ export async function loadCliConfig(
|
|||
process.exit(1);
|
||||
}
|
||||
}
|
||||
} else if (argv['sessionId']) {
|
||||
// Use provided session ID without session resumption
|
||||
// Check if session ID is already in use
|
||||
const sessionService = new SessionService(cwd);
|
||||
const exists = await sessionService.sessionExists(argv['sessionId']);
|
||||
if (exists) {
|
||||
const message = `Error: Session Id ${argv['sessionId']} is already in use.`;
|
||||
writeStderrLine(message);
|
||||
process.exit(1);
|
||||
}
|
||||
sessionId = argv['sessionId'];
|
||||
}
|
||||
|
||||
const modelProvidersConfig = settings.modelProviders;
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ export interface KeyBinding {
|
|||
command?: boolean;
|
||||
/** Paste operation requirement: true=must be paste, false=must not be paste, undefined=ignore */
|
||||
paste?: boolean;
|
||||
meta?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -152,7 +153,16 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
|||
{ key: 'x', ctrl: true },
|
||||
{ sequence: '\x18', ctrl: true },
|
||||
],
|
||||
[Command.PASTE_CLIPBOARD_IMAGE]: [{ key: 'v', ctrl: true }],
|
||||
[Command.PASTE_CLIPBOARD_IMAGE]:
|
||||
process.platform === 'win32'
|
||||
? [
|
||||
{ key: 'v', command: true },
|
||||
{ key: 'v', meta: true },
|
||||
]
|
||||
: [
|
||||
{ key: 'v', ctrl: true },
|
||||
{ key: 'v', command: true },
|
||||
],
|
||||
|
||||
// App level bindings
|
||||
[Command.TOGGLE_TOOL_DESCRIPTIONS]: [{ key: 't', ctrl: true }],
|
||||
|
|
|
|||
|
|
@ -373,7 +373,7 @@ const SETTINGS_SCHEMA = {
|
|||
label: 'Show Line Numbers in Code',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
default: true,
|
||||
description: 'Show line numbers in the code output.',
|
||||
showInDialog: true,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,6 +7,14 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import type { ProviderModelConfig as ModelConfig } from '@qwen-code/qwen-code-core';
|
||||
|
||||
/**
|
||||
* Coding plan regions
|
||||
*/
|
||||
export enum CodingPlanRegion {
|
||||
CHINA = 'china',
|
||||
GLOBAL = 'global',
|
||||
}
|
||||
|
||||
/**
|
||||
* Coding plan template - array of model configurations
|
||||
* When user provides an api-key, these configs will be cloned with envKey pointing to the stored api-key
|
||||
|
|
@ -14,48 +22,282 @@ import type { ProviderModelConfig as ModelConfig } from '@qwen-code/qwen-code-co
|
|||
export type CodingPlanTemplate = ModelConfig[];
|
||||
|
||||
/**
|
||||
* Environment variable key for storing the coding plan API key
|
||||
* Environment variable key for storing the coding plan API key.
|
||||
* Unified key for both regions since they are mutually exclusive.
|
||||
*/
|
||||
export const CODING_PLAN_ENV_KEY = 'BAILIAN_CODING_PLAN_API_KEY';
|
||||
|
||||
/**
|
||||
* CODING_PLAN_MODELS defines the model configurations for coding-plan mode.
|
||||
*/
|
||||
export const CODING_PLAN_MODELS: CodingPlanTemplate = [
|
||||
{
|
||||
id: 'qwen3-coder-plus',
|
||||
name: 'qwen3-coder-plus',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
description: 'qwen3-coder-plus model from Bailian Coding Plan',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
},
|
||||
{
|
||||
id: 'qwen3-max-2026-01-23',
|
||||
name: 'qwen3-max-2026-01-23',
|
||||
description:
|
||||
'qwen3-max model with thinking enabled from Bailian Coding Plan',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Computes the version hash for the coding plan template.
|
||||
* Uses SHA256 of the JSON-serialized template for deterministic versioning.
|
||||
* @param template - The template to compute version for
|
||||
* @returns Hexadecimal string representing the template version
|
||||
*/
|
||||
export function computeCodingPlanVersion(): string {
|
||||
const templateString = JSON.stringify(CODING_PLAN_MODELS);
|
||||
export function computeCodingPlanVersion(template: CodingPlanTemplate): string {
|
||||
const templateString = JSON.stringify(template);
|
||||
return createHash('sha256').update(templateString).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Current version of the coding plan template.
|
||||
* Computed at runtime from the template content.
|
||||
* Generate the complete coding plan template for a specific region.
|
||||
* China region uses legacy description to maintain backward compatibility.
|
||||
* Global region uses new description with region indicator.
|
||||
* @param region - The region to generate template for
|
||||
* @returns Complete model configuration array for the region
|
||||
*/
|
||||
export const CODING_PLAN_VERSION = computeCodingPlanVersion();
|
||||
export function generateCodingPlanTemplate(
|
||||
region: CodingPlanRegion,
|
||||
): CodingPlanTemplate {
|
||||
if (region === CodingPlanRegion.CHINA) {
|
||||
// China region uses legacy fields to maintain backward compatibility
|
||||
// This ensures existing users don't get prompted for unnecessary updates
|
||||
return [
|
||||
{
|
||||
id: 'qwen3.5-plus',
|
||||
name: '[Bailian Coding Plan] qwen3.5-plus',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'qwen3-coder-plus',
|
||||
name: '[Bailian Coding Plan] qwen3-coder-plus',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
},
|
||||
{
|
||||
id: 'qwen3-coder-next',
|
||||
name: '[Bailian Coding Plan] qwen3-coder-next',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
},
|
||||
{
|
||||
id: 'qwen3-max-2026-01-23',
|
||||
name: '[Bailian Coding Plan] qwen3-max-2026-01-23',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'glm-4.7',
|
||||
name: '[Bailian Coding Plan] glm-4.7',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'glm-5',
|
||||
name: '[Bailian Coding Plan] glm-5',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'MiniMax-M2.5',
|
||||
name: '[Bailian Coding Plan] MiniMax-M2.5',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'kimi-k2.5',
|
||||
name: '[Bailian Coding Plan] kimi-k2.5',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Global region uses Bailian Coding Plan branding for Global/Intl
|
||||
return [
|
||||
{
|
||||
id: 'qwen3.5-plus',
|
||||
name: '[Bailian Coding Plan for Global/Intl] qwen3.5-plus',
|
||||
baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'qwen3-coder-plus',
|
||||
name: '[Bailian Coding Plan for Global/Intl] qwen3-coder-plus',
|
||||
baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
},
|
||||
{
|
||||
id: 'qwen3-coder-next',
|
||||
name: '[Bailian Coding Plan for Global/Intl] qwen3-coder-next',
|
||||
baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
},
|
||||
{
|
||||
id: 'qwen3-max-2026-01-23',
|
||||
name: '[Bailian Coding Plan for Global/Intl] qwen3-max-2026-01-23',
|
||||
baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'glm-4.7',
|
||||
name: '[Bailian Coding Plan for Global/Intl] glm-4.7',
|
||||
baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'glm-5',
|
||||
name: '[Bailian Coding Plan for Global/Intl] glm-5',
|
||||
baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'MiniMax-M2.5',
|
||||
name: '[Bailian Coding Plan for Global/Intl] MiniMax-M2.5',
|
||||
baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'kimi-k2.5',
|
||||
name: '[Bailian Coding Plan for Global/Intl] kimi-k2.5',
|
||||
baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the complete configuration for a specific region.
|
||||
* @param region - The region to use
|
||||
* @returns Object containing template, baseUrl, and version
|
||||
*/
|
||||
export function getCodingPlanConfig(region: CodingPlanRegion) {
|
||||
const template = generateCodingPlanTemplate(region);
|
||||
const baseUrl =
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all unique base URLs for coding plan (used for filtering/config detection).
|
||||
* @returns Array of base URLs
|
||||
*/
|
||||
export function getCodingPlanBaseUrls(): string[] {
|
||||
return [
|
||||
'https://coding.dashscope.aliyuncs.com/v1',
|
||||
'https://coding-intl.dashscope.aliyuncs.com/v1',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a config belongs to Coding Plan (any region).
|
||||
* Returns the region if matched, or false if not a Coding Plan config.
|
||||
* @param baseUrl - The baseUrl to check
|
||||
* @param envKey - The envKey to check
|
||||
* @returns The region if matched, false otherwise
|
||||
*/
|
||||
export function isCodingPlanConfig(
|
||||
baseUrl: string | undefined,
|
||||
envKey: string | undefined,
|
||||
): CodingPlanRegion | false {
|
||||
if (!baseUrl || !envKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must use the unified envKey
|
||||
if (envKey !== CODING_PLAN_ENV_KEY) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check which region's baseUrl matches
|
||||
if (baseUrl === 'https://coding.dashscope.aliyuncs.com/v1') {
|
||||
return CodingPlanRegion.CHINA;
|
||||
}
|
||||
if (baseUrl === 'https://coding-intl.dashscope.aliyuncs.com/v1') {
|
||||
return CodingPlanRegion.GLOBAL;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get region from baseUrl.
|
||||
* @param baseUrl - The baseUrl to check
|
||||
* @returns The region if matched, null otherwise
|
||||
*/
|
||||
export function getRegionFromBaseUrl(
|
||||
baseUrl: string | undefined,
|
||||
): CodingPlanRegion | null {
|
||||
if (!baseUrl) return null;
|
||||
|
||||
if (baseUrl === 'https://coding.dashscope.aliyuncs.com/v1') {
|
||||
return CodingPlanRegion.CHINA;
|
||||
}
|
||||
if (baseUrl === 'https://coding-intl.dashscope.aliyuncs.com/v1') {
|
||||
return CodingPlanRegion.GLOBAL;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -496,6 +496,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
|||
experimentalLsp: undefined,
|
||||
channel: undefined,
|
||||
chatRecording: undefined,
|
||||
sessionId: undefined,
|
||||
});
|
||||
|
||||
await main();
|
||||
|
|
|
|||
|
|
@ -11,6 +11,12 @@ export default {
|
|||
// ============================================================================
|
||||
// Help / UI Components
|
||||
// ============================================================================
|
||||
// Attachment hints
|
||||
'↑ to manage attachments': '↑ Anhänge verwalten',
|
||||
'← → select, Delete to remove, ↓ to exit':
|
||||
'← → auswählen, Entf zum Löschen, ↓ beenden',
|
||||
'Attachments: ': 'Anhänge: ',
|
||||
|
||||
'Basics:': 'Grundlagen:',
|
||||
'Add context': 'Kontext hinzufügen',
|
||||
'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.':
|
||||
|
|
@ -1032,8 +1038,8 @@ export default {
|
|||
'(not set)': '(nicht gesetzt)',
|
||||
"Failed to switch model to '{{modelId}}'.\n\n{{error}}":
|
||||
"Modell konnte nicht auf '{{modelId}}' umgestellt werden.\n\n{{error}}",
|
||||
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)':
|
||||
'Das neueste Qwen Coder Modell von Alibaba Cloud ModelStudio (Version: qwen3-coder-plus-2025-09-23)',
|
||||
'Qwen 3.5 Plus — efficient hybrid model with leading coding performance':
|
||||
'Qwen 3.5 Plus — effizientes Hybridmodell mit führender Programmierleistung',
|
||||
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)':
|
||||
'Das neueste Qwen Vision Modell von Alibaba Cloud ModelStudio (Version: qwen3-vl-plus-2025-09-23)',
|
||||
|
||||
|
|
@ -1419,8 +1425,12 @@ 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`.',
|
||||
|
|
@ -1430,4 +1440,18 @@ export default {
|
|||
'(Press Enter to submit, Escape to cancel)':
|
||||
'(Enter zum Absenden, Escape zum Abbrechen)',
|
||||
'More instructions please check:': 'Weitere Anweisungen finden Sie unter:',
|
||||
|
||||
// ============================================================================
|
||||
// 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 is stored in settings.env.':
|
||||
'Erfolgreich mit {{region}} authentifiziert. API-Schlüssel ist in settings.env gespeichert.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,6 +11,12 @@ export default {
|
|||
// ============================================================================
|
||||
// Help / UI Components
|
||||
// ============================================================================
|
||||
// Attachment hints
|
||||
'↑ to manage attachments': '↑ to manage attachments',
|
||||
'← → select, Delete to remove, ↓ to exit':
|
||||
'← → select, Delete to remove, ↓ to exit',
|
||||
'Attachments: ': 'Attachments: ',
|
||||
|
||||
'Basics:': 'Basics:',
|
||||
'Add context': 'Add context',
|
||||
'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.':
|
||||
|
|
@ -1057,8 +1063,8 @@ export default {
|
|||
'(not set)': '(not set)',
|
||||
"Failed to switch model to '{{modelId}}'.\n\n{{error}}":
|
||||
"Failed to switch model to '{{modelId}}'.\n\n{{error}}",
|
||||
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)':
|
||||
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)',
|
||||
'Qwen 3.5 Plus — efficient hybrid model with leading coding performance':
|
||||
'Qwen 3.5 Plus — efficient hybrid model with leading coding performance',
|
||||
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)':
|
||||
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)',
|
||||
|
||||
|
|
@ -1410,6 +1416,13 @@ export default {
|
|||
'Failed to open browser. Check out the extensions gallery at {{url}}':
|
||||
'Failed to open browser. Check out the extensions gallery at {{url}}',
|
||||
|
||||
// ============================================================================
|
||||
// Retry / Rate Limit
|
||||
// ============================================================================
|
||||
'Rate limit error: {{reason}}': 'Rate limit error: {{reason}}',
|
||||
'Retrying in {{seconds}} seconds… (attempt {{attempt}}/{{maxRetries}})':
|
||||
'Retrying in {{seconds}} seconds… (attempt {{attempt}}/{{maxRetries}})',
|
||||
|
||||
// ============================================================================
|
||||
// Coding Plan Authentication
|
||||
// ============================================================================
|
||||
|
|
@ -1451,8 +1464,12 @@ 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!":
|
||||
"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.',
|
||||
|
|
@ -1460,4 +1477,18 @@ export default {
|
|||
'(Press Escape to go back)': '(Press Escape to go back)',
|
||||
'(Press Enter to submit, Escape to cancel)':
|
||||
'(Press Enter to submit, Escape to cancel)',
|
||||
|
||||
// ============================================================================
|
||||
// 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 is stored in settings.env.':
|
||||
'Authenticated successfully with {{region}}. API key is stored in settings.env.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -733,8 +733,8 @@ export default {
|
|||
// Dialogs - Model
|
||||
'Select Model': 'モデルを選択',
|
||||
'(Press Esc to close)': '(Esc で閉じる)',
|
||||
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)':
|
||||
'Alibaba Cloud ModelStudioの最新Qwen Coderモデル(バージョン: qwen3-coder-plus-2025-09-23)',
|
||||
'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)':
|
||||
'Alibaba Cloud ModelStudioの最新Qwen Visionモデル(バージョン: qwen3-vl-plus-2025-09-23)',
|
||||
// Dialogs - Permissions
|
||||
|
|
@ -930,8 +930,13 @@ 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`を手動で設定する方法の詳細はこちら。',
|
||||
|
|
@ -940,4 +945,18 @@ export default {
|
|||
'(Press Enter to submit, Escape to cancel)':
|
||||
'(Enterで送信、Escapeでキャンセル)',
|
||||
'More instructions please check:': '詳細な手順はこちらをご確認ください:',
|
||||
|
||||
// ============================================================================
|
||||
// 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 is stored in settings.env.':
|
||||
'{{region}} での認証に成功しました。APIキーは settings.env に保存されています。',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1041,8 +1041,8 @@ export default {
|
|||
'(not set)': '(não definido)',
|
||||
"Failed to switch model to '{{modelId}}'.\n\n{{error}}":
|
||||
"Falha ao trocar o modelo para '{{modelId}}'.\n\n{{error}}",
|
||||
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)':
|
||||
'O modelo Qwen Coder mais recente do Alibaba Cloud ModelStudio (versão: qwen3-coder-plus-2025-09-23)',
|
||||
'Qwen 3.5 Plus — efficient hybrid model with leading coding performance':
|
||||
'Qwen 3.5 Plus — modelo híbrido eficiente com desempenho líder em programação',
|
||||
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)':
|
||||
'O modelo Qwen Vision mais recente do Alibaba Cloud ModelStudio (versão: qwen3-vl-plus-2025-09-23)',
|
||||
|
||||
|
|
@ -1433,8 +1433,12 @@ 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.',
|
||||
|
|
@ -1444,4 +1448,18 @@ export default {
|
|||
'(Press Enter to submit, Escape to cancel)':
|
||||
'(Pressione Enter para enviar, Escape para cancelar)',
|
||||
'More instructions please check:': 'Mais instruções, consulte:',
|
||||
|
||||
// ============================================================================
|
||||
// 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 is stored in settings.env.':
|
||||
'Autenticado com sucesso com {{region}}. A chave de API está armazenada em settings.env.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,6 +11,12 @@ export default {
|
|||
// ============================================================================
|
||||
// Справка / Компоненты интерфейса
|
||||
// ============================================================================
|
||||
// Attachment hints
|
||||
'↑ to manage attachments': '↑ управление вложениями',
|
||||
'← → select, Delete to remove, ↓ to exit':
|
||||
'← → выбрать, Delete удалить, ↓ выйти',
|
||||
'Attachments: ': 'Вложения: ',
|
||||
|
||||
'Basics:': 'Основы:',
|
||||
'Add context': 'Добавить контекст',
|
||||
'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.':
|
||||
|
|
@ -1034,8 +1040,8 @@ export default {
|
|||
'(not set)': '(не задано)',
|
||||
"Failed to switch model to '{{modelId}}'.\n\n{{error}}":
|
||||
"Не удалось переключиться на модель '{{modelId}}'.\n\n{{error}}",
|
||||
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)':
|
||||
'Последняя модель Qwen Coder от Alibaba Cloud ModelStudio (версия: qwen3-coder-plus-2025-09-23)',
|
||||
'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)':
|
||||
'Последняя модель Qwen Vision от Alibaba Cloud ModelStudio (версия: qwen3-vl-plus-2025-09-23)',
|
||||
|
||||
|
|
@ -1423,8 +1429,13 @@ 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`.',
|
||||
|
|
@ -1433,4 +1444,18 @@ export default {
|
|||
'(Press Enter to submit, Escape to cancel)':
|
||||
'(Нажмите Enter для отправки, Escape для отмены)',
|
||||
'More instructions please check:': 'Дополнительные инструкции см.:',
|
||||
|
||||
// ============================================================================
|
||||
// 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 is stored in settings.env.':
|
||||
'Успешная аутентификация с {{region}}. API-ключ сохранён в settings.env.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,6 +10,11 @@ export default {
|
|||
// ============================================================================
|
||||
// Help / UI Components
|
||||
// ============================================================================
|
||||
// Attachment hints
|
||||
'↑ to manage attachments': '↑ 管理附件',
|
||||
'← → select, Delete to remove, ↓ to exit': '← → 选择,Delete 删除,↓ 退出',
|
||||
'Attachments: ': '附件:',
|
||||
|
||||
'Basics:': '基础功能:',
|
||||
'Add context': '添加上下文',
|
||||
'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.':
|
||||
|
|
@ -991,8 +996,8 @@ export default {
|
|||
'(not set)': '(未设置)',
|
||||
"Failed to switch model to '{{modelId}}'.\n\n{{error}}":
|
||||
"无法切换到模型 '{{modelId}}'.\n\n{{error}}",
|
||||
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)':
|
||||
'来自阿里云 ModelStudio 的最新 Qwen Coder 模型(版本:qwen3-coder-plus-2025-09-23)',
|
||||
'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)':
|
||||
'来自阿里云 ModelStudio 的最新 Qwen Vision 模型(版本:qwen3-vl-plus-2025-09-23)',
|
||||
|
||||
|
|
@ -1237,6 +1242,13 @@ export default {
|
|||
'Failed to open browser. Check out the extensions gallery at {{url}}':
|
||||
'打开浏览器失败。请访问扩展市场:{{url}}',
|
||||
|
||||
// ============================================================================
|
||||
// Retry / Rate Limit
|
||||
// ============================================================================
|
||||
'Rate limit error: {{reason}}': '触发限流:{{reason}}',
|
||||
'Retrying in {{seconds}} seconds… (attempt {{attempt}}/{{maxRetries}})':
|
||||
'将于 {{seconds}} 秒后重试…(第 {{attempt}}/{{maxRetries}} 次)',
|
||||
|
||||
// ============================================================================
|
||||
// Coding Plan Authentication
|
||||
// ============================================================================
|
||||
|
|
@ -1279,12 +1291,30 @@ export default {
|
|||
// ============================================================================
|
||||
'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 取消)',
|
||||
|
||||
// ============================================================================
|
||||
// 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 is stored in settings.env.':
|
||||
'成功通过 {{region}} 认证。API Key 已存储在 settings.env 中。',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -80,6 +80,8 @@ export class ControlDispatcher implements IPendingRequestRegistry {
|
|||
private pendingOutgoingRequests: Map<string, PendingOutgoingRequest> =
|
||||
new Map();
|
||||
|
||||
private abortHandler: (() => void) | null = null;
|
||||
|
||||
constructor(context: IControlContext) {
|
||||
this.context = context;
|
||||
|
||||
|
|
@ -102,9 +104,10 @@ export class ControlDispatcher implements IPendingRequestRegistry {
|
|||
// this.hookController = new HookController(context, this, 'HookController');
|
||||
|
||||
// Listen for main abort signal
|
||||
this.context.abortSignal.addEventListener('abort', () => {
|
||||
this.abortHandler = () => {
|
||||
this.shutdown();
|
||||
});
|
||||
};
|
||||
this.context.abortSignal.addEventListener('abort', this.abortHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -240,6 +243,12 @@ export class ControlDispatcher implements IPendingRequestRegistry {
|
|||
shutdown(): void {
|
||||
debugLogger.debug('[ControlDispatcher] Shutting down');
|
||||
|
||||
// Remove abort listener to prevent memory leak
|
||||
if (this.abortHandler) {
|
||||
this.context.abortSignal.removeEventListener('abort', this.abortHandler);
|
||||
this.abortHandler = null;
|
||||
}
|
||||
|
||||
// Cancel all incoming requests
|
||||
for (const [
|
||||
_requestId,
|
||||
|
|
|
|||
|
|
@ -193,6 +193,7 @@ export class SystemController extends BaseController {
|
|||
|
||||
return {
|
||||
subtype: 'initialize',
|
||||
session_id: this.context.config.getSessionId(),
|
||||
capabilities,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -408,7 +408,8 @@ class Session {
|
|||
private handleInterrupt(): void {
|
||||
debugLogger.info('[Session] Interrupt requested');
|
||||
this.abortController.abort();
|
||||
this.abortController = new AbortController();
|
||||
// Do not create a new AbortController to prevent listener leaks.
|
||||
// Subsequent queries will check signal.aborted and fail immediately.
|
||||
}
|
||||
|
||||
private setupSignalHandlers(): void {
|
||||
|
|
|
|||
|
|
@ -696,7 +696,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
terminalWidth,
|
||||
terminalHeight,
|
||||
handleVisionSwitchRequired, // onVisionSwitchRequired
|
||||
embeddedShellFocused,
|
||||
);
|
||||
|
||||
// Track whether suggestions are visible for Tab key handling
|
||||
|
|
@ -904,6 +903,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
const ctrlCTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [ctrlDPressedOnce, setCtrlDPressedOnce] = useState(false);
|
||||
const ctrlDTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [escapePressedOnce, setEscapePressedOnce] = useState(false);
|
||||
const escapeTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [constrainHeight, setConstrainHeight] = useState<boolean>(true);
|
||||
const [ideContextState, setIdeContextState] = useState<
|
||||
IdeContext | undefined
|
||||
|
|
@ -1180,6 +1181,47 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
}
|
||||
handleExit(ctrlDPressedOnce, setCtrlDPressedOnce, ctrlDTimerRef);
|
||||
return;
|
||||
} else if (keyMatchers[Command.ESCAPE](key)) {
|
||||
// Escape key handling
|
||||
// Skip if shell is focused (to allow shell's own escape handling)
|
||||
if (embeddedShellFocused) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If input has content, use double-press to clear
|
||||
if (buffer.text.length > 0) {
|
||||
if (escapePressedOnce) {
|
||||
// Second press: clear input, keep the flag to allow immediate cancel
|
||||
buffer.setText('');
|
||||
return;
|
||||
}
|
||||
// First press: set flag and show prompt
|
||||
setEscapePressedOnce(true);
|
||||
escapeTimerRef.current = setTimeout(() => {
|
||||
setEscapePressedOnce(false);
|
||||
escapeTimerRef.current = null;
|
||||
}, CTRL_EXIT_PROMPT_DURATION_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
// Input is empty, cancel request immediately (no double-press needed)
|
||||
if (streamingState === StreamingState.Responding) {
|
||||
if (escapeTimerRef.current) {
|
||||
clearTimeout(escapeTimerRef.current);
|
||||
escapeTimerRef.current = null;
|
||||
}
|
||||
cancelOngoingRequest?.();
|
||||
setEscapePressedOnce(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// No action available, reset the flag
|
||||
if (escapeTimerRef.current) {
|
||||
clearTimeout(escapeTimerRef.current);
|
||||
escapeTimerRef.current = null;
|
||||
}
|
||||
setEscapePressedOnce(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let enteringConstrainHeightMode = false;
|
||||
|
|
@ -1224,10 +1266,15 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
ctrlCPressedOnce,
|
||||
setCtrlCPressedOnce,
|
||||
ctrlCTimerRef,
|
||||
buffer.text.length,
|
||||
ctrlDPressedOnce,
|
||||
setCtrlDPressedOnce,
|
||||
ctrlDTimerRef,
|
||||
escapePressedOnce,
|
||||
setEscapePressedOnce,
|
||||
escapeTimerRef,
|
||||
streamingState,
|
||||
cancelOngoingRequest,
|
||||
buffer,
|
||||
handleSlashCommand,
|
||||
activePtyId,
|
||||
embeddedShellFocused,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import { AuthType } from '@qwen-code/qwen-code-core';
|
|||
import { Box, Text } from 'ink';
|
||||
import Link from 'ink-link';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { Colors } from '../colors.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
|
||||
import { ApiKeyInput } from '../components/ApiKeyInput.js';
|
||||
|
|
@ -18,6 +17,7 @@ import { useUIState } from '../contexts/UIStateContext.js';
|
|||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { CodingPlanRegion } from '../../constants/codingPlan.js';
|
||||
|
||||
const MODEL_PROVIDERS_DOCUMENTATION_URL =
|
||||
'https://qwenlm.github.io/qwen-code-docs/en/users/configuration/settings/#modelproviders';
|
||||
|
|
@ -35,7 +35,7 @@ function parseDefaultAuthType(
|
|||
}
|
||||
|
||||
// Sub-mode types for API-KEY authentication
|
||||
type ApiKeySubMode = 'coding-plan' | 'custom';
|
||||
type ApiKeySubMode = 'coding-plan' | 'coding-plan-intl' | 'custom';
|
||||
|
||||
// View level for navigation
|
||||
type ViewLevel = 'main' | 'api-key-sub' | 'api-key-input' | 'custom-info';
|
||||
|
|
@ -53,6 +53,9 @@ export function AuthDialog(): React.JSX.Element {
|
|||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
const [viewLevel, setViewLevel] = useState<ViewLevel>('main');
|
||||
const [apiKeySubModeIndex, setApiKeySubModeIndex] = useState<number>(0);
|
||||
const [region, setRegion] = useState<CodingPlanRegion>(
|
||||
CodingPlanRegion.CHINA,
|
||||
);
|
||||
|
||||
// Main authentication entries
|
||||
const mainItems = [
|
||||
|
|
@ -72,9 +75,14 @@ export function AuthDialog(): React.JSX.Element {
|
|||
const apiKeySubItems = [
|
||||
{
|
||||
key: 'coding-plan',
|
||||
label: t('Coding Plan (Bailian)'),
|
||||
label: t('Coding Plan (Bailian, China)'),
|
||||
value: 'coding-plan' as ApiKeySubMode,
|
||||
},
|
||||
{
|
||||
key: 'coding-plan-intl',
|
||||
label: t('Coding Plan (Bailian, Global/Intl)'),
|
||||
value: 'coding-plan-intl' as ApiKeySubMode,
|
||||
},
|
||||
{
|
||||
key: 'custom',
|
||||
label: t('Custom'),
|
||||
|
|
@ -136,6 +144,10 @@ export function AuthDialog(): React.JSX.Element {
|
|||
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');
|
||||
|
|
@ -150,8 +162,8 @@ export function AuthDialog(): React.JSX.Element {
|
|||
return;
|
||||
}
|
||||
|
||||
// Submit to parent for processing
|
||||
await handleCodingPlanSubmit(apiKey);
|
||||
// Submit to parent for processing with region info
|
||||
await handleCodingPlanSubmit(apiKey, region);
|
||||
};
|
||||
|
||||
const handleGoBack = () => {
|
||||
|
|
@ -160,6 +172,8 @@ export function AuthDialog(): React.JSX.Element {
|
|||
|
||||
if (viewLevel === 'api-key-sub') {
|
||||
setViewLevel('main');
|
||||
// Reset selectedIndex to ensure UI syncs with initialAuthIndex
|
||||
setSelectedIndex(null);
|
||||
} else if (viewLevel === 'api-key-input' || viewLevel === 'custom-info') {
|
||||
setViewLevel('api-key-sub');
|
||||
}
|
||||
|
|
@ -215,7 +229,7 @@ export function AuthDialog(): React.JSX.Element {
|
|||
/>
|
||||
</Box>
|
||||
<Box marginTop={1} paddingLeft={2}>
|
||||
<Text color={Colors.Gray}>
|
||||
<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.')}
|
||||
|
|
@ -244,11 +258,13 @@ export function AuthDialog(): React.JSX.Element {
|
|||
/>
|
||||
</Box>
|
||||
<Box marginTop={1} paddingLeft={2}>
|
||||
<Text color={Colors.Gray}>
|
||||
{apiKeySubItems[apiKeySubModeIndex]?.value === 'coding-plan'
|
||||
? t("Paste your api key of Bailian Coding Plan and you're all set!")
|
||||
: t(
|
||||
<Text color={theme.text.secondary}>
|
||||
{apiKeySubItems[apiKeySubModeIndex]?.value === 'custom'
|
||||
? t(
|
||||
'More instructions about configuring `modelProviders` manually.',
|
||||
)
|
||||
: t(
|
||||
"Paste your api key of Bailian Coding Plan and you're all set!",
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
|
@ -263,7 +279,11 @@ export function AuthDialog(): React.JSX.Element {
|
|||
// Render API key input for coding-plan mode
|
||||
const renderApiKeyInputView = () => (
|
||||
<Box marginTop={1}>
|
||||
<ApiKeyInput onSubmit={handleApiKeyInputSubmit} onCancel={handleGoBack} />
|
||||
<ApiKeyInput
|
||||
onSubmit={handleApiKeyInputSubmit}
|
||||
onCancel={handleGoBack}
|
||||
region={region}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
|
|
@ -282,12 +302,12 @@ export function AuthDialog(): React.JSX.Element {
|
|||
<Text>{t('Please configure your models in settings.json:')}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} paddingLeft={2}>
|
||||
<Text color={Colors.AccentYellow}>
|
||||
<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={Colors.AccentYellow}>
|
||||
<Text color={theme.status.warning}>
|
||||
2.{' '}
|
||||
{t(
|
||||
"Add model configuration to modelProviders['openai'] (or other auth types)",
|
||||
|
|
@ -295,7 +315,7 @@ export function AuthDialog(): React.JSX.Element {
|
|||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={0} paddingLeft={2}>
|
||||
<Text color={Colors.AccentYellow}>
|
||||
<Text color={theme.status.warning}>
|
||||
3.{' '}
|
||||
{t(
|
||||
'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig',
|
||||
|
|
@ -303,7 +323,7 @@ export function AuthDialog(): React.JSX.Element {
|
|||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={0} paddingLeft={2}>
|
||||
<Text color={Colors.AccentYellow}>
|
||||
<Text color={theme.status.warning}>
|
||||
4.{' '}
|
||||
{t(
|
||||
'Use /model command to select your preferred model from the configured list',
|
||||
|
|
@ -324,7 +344,7 @@ export function AuthDialog(): React.JSX.Element {
|
|||
</Box>
|
||||
<Box marginTop={0}>
|
||||
<Link url={MODEL_PROVIDERS_DOCUMENTATION_URL} fallback={false}>
|
||||
<Text color={Colors.AccentGreen} underline>
|
||||
<Text color={theme.status.success} underline>
|
||||
{MODEL_PROVIDERS_DOCUMENTATION_URL}
|
||||
</Text>
|
||||
</Link>
|
||||
|
|
@ -369,14 +389,14 @@ export function AuthDialog(): React.JSX.Element {
|
|||
|
||||
{(authError || errorMessage) && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.AccentRed}>{authError || errorMessage}</Text>
|
||||
<Text color={theme.status.error}>{authError || errorMessage}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{viewLevel === 'main' && (
|
||||
<>
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.AccentPurple}>
|
||||
<Text color={theme.text.accent}>
|
||||
{t('(Use Enter to Set Auth)')}
|
||||
</Text>
|
||||
</Box>
|
||||
|
|
@ -395,7 +415,7 @@ export function AuthDialog(): React.JSX.Element {
|
|||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.AccentBlue}>
|
||||
<Text color={theme.text.link}>
|
||||
{
|
||||
'https://qwenlm.github.io/qwen-code-docs/en/users/support/tos-privacy/'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,9 +30,10 @@ import { AuthState, MessageType } from '../types.js';
|
|||
import type { HistoryItem } from '../types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import {
|
||||
CODING_PLAN_MODELS,
|
||||
getCodingPlanConfig,
|
||||
isCodingPlanConfig,
|
||||
CodingPlanRegion,
|
||||
CODING_PLAN_ENV_KEY,
|
||||
CODING_PLAN_VERSION,
|
||||
} from '../../constants/codingPlan.js';
|
||||
|
||||
export type { QwenAuthState } from '../hooks/useQwenAuth.js';
|
||||
|
|
@ -285,29 +286,35 @@ export const useAuthCommand = (
|
|||
|
||||
/**
|
||||
* Handle coding plan submission - generates configs from template and stores api-key
|
||||
* @param apiKey - The API key to store
|
||||
* @param region - The region to use (default: CHINA)
|
||||
*/
|
||||
const handleCodingPlanSubmit = useCallback(
|
||||
async (apiKey: string) => {
|
||||
async (
|
||||
apiKey: string,
|
||||
region: CodingPlanRegion = CodingPlanRegion.CHINA,
|
||||
) => {
|
||||
try {
|
||||
setIsAuthenticating(true);
|
||||
setAuthError(null);
|
||||
|
||||
const envKeyName = CODING_PLAN_ENV_KEY;
|
||||
// Get configuration based on region
|
||||
const { template, version, regionName } = getCodingPlanConfig(region);
|
||||
|
||||
// Get persist scope
|
||||
const persistScope = getPersistScopeForModelSelection(settings);
|
||||
|
||||
// Store api-key in settings.env
|
||||
settings.setValue(persistScope, `env.${envKeyName}`, apiKey);
|
||||
// Store api-key in settings.env (unified env key)
|
||||
settings.setValue(persistScope, `env.${CODING_PLAN_ENV_KEY}`, apiKey);
|
||||
|
||||
// Sync to process.env immediately so refreshAuth can read the apiKey
|
||||
process.env[envKeyName] = apiKey;
|
||||
process.env[CODING_PLAN_ENV_KEY] = apiKey;
|
||||
|
||||
// Generate model configs from template
|
||||
const newConfigs: ProviderModelConfig[] = CODING_PLAN_MODELS.map(
|
||||
const newConfigs: ProviderModelConfig[] = template.map(
|
||||
(templateConfig) => ({
|
||||
...templateConfig,
|
||||
envKey: envKeyName,
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -317,17 +324,9 @@ export const useAuthCommand = (
|
|||
settings.merged.modelProviders as ModelProvidersConfig | undefined
|
||||
)?.[AuthType.USE_OPENAI] || [];
|
||||
|
||||
// Identify Coding Plan configs by baseUrl + envKey
|
||||
// Remove existing Coding Plan configs to ensure template changes are applied
|
||||
const isCodingPlanConfig = (config: ProviderModelConfig) =>
|
||||
config.envKey === envKeyName &&
|
||||
CODING_PLAN_MODELS.some(
|
||||
(template) => template.baseUrl === config.baseUrl,
|
||||
);
|
||||
|
||||
// Filter out existing Coding Plan configs, keep user custom configs
|
||||
// Filter out all existing Coding Plan configs (mutually exclusive)
|
||||
const nonCodingPlanConfigs = existingConfigs.filter(
|
||||
(existing) => !isCodingPlanConfig(existing),
|
||||
(existing) => !isCodingPlanConfig(existing.baseUrl, existing.envKey),
|
||||
);
|
||||
|
||||
// Add new Coding Plan configs at the beginning
|
||||
|
|
@ -347,12 +346,11 @@ export const useAuthCommand = (
|
|||
AuthType.USE_OPENAI,
|
||||
);
|
||||
|
||||
// Persist coding plan version for future update detection
|
||||
settings.setValue(
|
||||
persistScope,
|
||||
'codingPlan.version',
|
||||
CODING_PLAN_VERSION,
|
||||
);
|
||||
// Persist coding plan region
|
||||
settings.setValue(persistScope, 'codingPlan.region', region);
|
||||
|
||||
// Persist coding plan version (single field for backward compatibility)
|
||||
settings.setValue(persistScope, 'codingPlan.version', version);
|
||||
|
||||
// If there are configs, use the first one as the model
|
||||
if (updatedConfigs.length > 0 && updatedConfigs[0]?.id) {
|
||||
|
|
@ -386,7 +384,8 @@ export const useAuthCommand = (
|
|||
{
|
||||
type: MessageType.INFO,
|
||||
text: t(
|
||||
'Authenticated successfully with Coding Plan. API key is stored in settings.env.',
|
||||
'Authenticated successfully with {{region}}. API key is stored in settings.env.',
|
||||
{ region: regionName },
|
||||
),
|
||||
},
|
||||
Date.now(),
|
||||
|
|
|
|||
|
|
@ -8,26 +8,37 @@ import type React from 'react';
|
|||
import { useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { TextInput } from './shared/TextInput.js';
|
||||
import { Colors } from '../colors.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { CodingPlanRegion } from '../../constants/codingPlan.js';
|
||||
import Link from 'ink-link';
|
||||
|
||||
interface ApiKeyInputProps {
|
||||
onSubmit: (apiKey: string) => void;
|
||||
onCancel: () => void;
|
||||
region?: CodingPlanRegion;
|
||||
}
|
||||
|
||||
const CODING_PLAN_API_KEY_URL =
|
||||
'https://bailian.console.aliyun.com/?tab=model#/efm/coding_plan';
|
||||
|
||||
const CODING_PLAN_INTL_API_KEY_URL =
|
||||
'https://modelstudio.console.alibabacloud.com/?tab=dashboard#/efm/coding_plan';
|
||||
|
||||
export function ApiKeyInput({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
region = CodingPlanRegion.CHINA,
|
||||
}: ApiKeyInputProps): React.JSX.Element {
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const apiKeyUrl =
|
||||
region === CodingPlanRegion.GLOBAL
|
||||
? CODING_PLAN_INTL_API_KEY_URL
|
||||
: CODING_PLAN_API_KEY_URL;
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
|
|
@ -52,21 +63,21 @@ export function ApiKeyInput({
|
|||
<TextInput value={apiKey} onChange={setApiKey} placeholder="sk-sp-..." />
|
||||
{error && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.AccentRed}>{error}</Text>
|
||||
<Text color={theme.status.error}>{error}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text>{t('You can get your exclusive Coding Plan API-KEY here:')}</Text>
|
||||
</Box>
|
||||
<Box marginTop={0}>
|
||||
<Link url={CODING_PLAN_API_KEY_URL} fallback={false}>
|
||||
<Text color={Colors.AccentGreen} underline>
|
||||
{CODING_PLAN_API_KEY_URL}
|
||||
<Link url={apiKeyUrl} fallback={false}>
|
||||
<Text color={theme.status.success} underline>
|
||||
{apiKeyUrl}
|
||||
</Text>
|
||||
</Link>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.Gray}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('(Press Enter to submit, Escape to cancel)')}
|
||||
</Text>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { GeminiThoughtMessageContent } from './messages/GeminiThoughtMessageCont
|
|||
import { CompressionMessage } from './messages/CompressionMessage.js';
|
||||
import { SummaryMessage } from './messages/SummaryMessage.js';
|
||||
import { WarningMessage } from './messages/WarningMessage.js';
|
||||
import { RetryCountdownMessage } from './messages/RetryCountdownMessage.js';
|
||||
import { Box } from 'ink';
|
||||
import { AboutBox } from './AboutBox.js';
|
||||
import { StatsDisplay } from './StatsDisplay.js';
|
||||
|
|
@ -126,6 +127,9 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
|||
{itemForDisplay.type === 'error' && (
|
||||
<ErrorMessage text={itemForDisplay.text} />
|
||||
)}
|
||||
{itemForDisplay.type === 'retry_countdown' && (
|
||||
<RetryCountdownMessage text={itemForDisplay.text} />
|
||||
)}
|
||||
{itemForDisplay.type === 'about' && (
|
||||
<AboutBox {...itemForDisplay.systemInfo} width={boxWidth} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -370,6 +370,8 @@ describe('InputPrompt', () => {
|
|||
});
|
||||
|
||||
describe('clipboard image paste', () => {
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
|
||||
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(null);
|
||||
|
|
@ -378,10 +380,37 @@ describe('InputPrompt', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should handle Ctrl+V when clipboard has an image', async () => {
|
||||
// Windows uses Alt+V (\x1Bv), non-Windows uses Ctrl+V (\x16)
|
||||
const describeConditional = isWindows ? it.skip : it;
|
||||
describeConditional(
|
||||
'should handle Ctrl+V when clipboard has an image',
|
||||
async () => {
|
||||
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
|
||||
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(
|
||||
'/Users/mochi/.qwen/tmp/clipboard-123.png',
|
||||
);
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Send Ctrl+V
|
||||
stdin.write('\x16'); // Ctrl+V
|
||||
await wait();
|
||||
|
||||
expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled();
|
||||
expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled();
|
||||
expect(clipboardUtils.cleanupOldClipboardImages).toHaveBeenCalled();
|
||||
// Note: The new implementation adds images as attachments rather than inserting into buffer
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
|
||||
it('should handle Cmd+V when clipboard has an image', async () => {
|
||||
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
|
||||
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(
|
||||
'/test/.qwen-clipboard/clipboard-123.png',
|
||||
'/Users/mochi/.qwen/tmp/clipboard-456.png',
|
||||
);
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
|
|
@ -389,18 +418,15 @@ describe('InputPrompt', () => {
|
|||
);
|
||||
await wait();
|
||||
|
||||
// Send Ctrl+V
|
||||
stdin.write('\x16'); // Ctrl+V
|
||||
// Send Cmd+V (meta key) / Alt+V on Windows
|
||||
// In terminals, Cmd+V or Alt+V is typically sent as ESC followed by 'v'
|
||||
stdin.write('\x1Bv');
|
||||
await wait();
|
||||
|
||||
expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled();
|
||||
expect(clipboardUtils.saveClipboardImage).toHaveBeenCalledWith(
|
||||
props.config.getTargetDir(),
|
||||
);
|
||||
expect(clipboardUtils.cleanupOldClipboardImages).toHaveBeenCalledWith(
|
||||
props.config.getTargetDir(),
|
||||
);
|
||||
expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalled();
|
||||
expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled();
|
||||
expect(clipboardUtils.cleanupOldClipboardImages).toHaveBeenCalled();
|
||||
// Note: The new implementation adds images as attachments rather than inserting into buffer
|
||||
unmount();
|
||||
});
|
||||
|
||||
|
|
@ -412,7 +438,8 @@ describe('InputPrompt', () => {
|
|||
);
|
||||
await wait();
|
||||
|
||||
stdin.write('\x16'); // Ctrl+V
|
||||
// Use platform-appropriate key combination
|
||||
stdin.write(isWindows ? '\x1Bv' : '\x16');
|
||||
await wait();
|
||||
|
||||
expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled();
|
||||
|
|
@ -430,7 +457,8 @@ describe('InputPrompt', () => {
|
|||
);
|
||||
await wait();
|
||||
|
||||
stdin.write('\x16'); // Ctrl+V
|
||||
// Use platform-appropriate key combination
|
||||
stdin.write(isWindows ? '\x1Bv' : '\x16');
|
||||
await wait();
|
||||
|
||||
expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled();
|
||||
|
|
@ -439,11 +467,7 @@ describe('InputPrompt', () => {
|
|||
});
|
||||
|
||||
it('should insert image path at cursor position with proper spacing', async () => {
|
||||
const imagePath = path.join(
|
||||
'test',
|
||||
'.qwen-clipboard',
|
||||
'clipboard-456.png',
|
||||
);
|
||||
const imagePath = '/Users/mochi/.qwen/tmp/clipboard-456.png';
|
||||
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
|
||||
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(imagePath);
|
||||
|
||||
|
|
@ -451,27 +475,20 @@ describe('InputPrompt', () => {
|
|||
mockBuffer.text = 'Hello world';
|
||||
mockBuffer.cursor = [0, 5]; // Cursor after "Hello"
|
||||
mockBuffer.lines = ['Hello world'];
|
||||
mockBuffer.replaceRangeByOffset = vi.fn();
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
await wait();
|
||||
|
||||
stdin.write('\x16'); // Ctrl+V
|
||||
// Use platform-appropriate key combination
|
||||
stdin.write(isWindows ? '\x1Bv' : '\x16');
|
||||
await wait();
|
||||
|
||||
// Should insert at cursor position with spaces
|
||||
expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalled();
|
||||
|
||||
// Get the actual call to see what path was used
|
||||
const actualCall = vi.mocked(mockBuffer.replaceRangeByOffset).mock
|
||||
.calls[0];
|
||||
expect(actualCall[0]).toBe(5); // start offset
|
||||
expect(actualCall[1]).toBe(5); // end offset
|
||||
expect(actualCall[2]).toBe(
|
||||
' @' + path.relative(path.join('test', 'project', 'src'), imagePath),
|
||||
);
|
||||
// The new implementation adds images as attachments rather than inserting into buffer
|
||||
// So we verify that saveClipboardImage was called instead
|
||||
expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled();
|
||||
expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
|
|
@ -485,7 +502,8 @@ describe('InputPrompt', () => {
|
|||
);
|
||||
await wait();
|
||||
|
||||
stdin.write('\x16'); // Ctrl+V
|
||||
// Use platform-appropriate key combination
|
||||
stdin.write(isWindows ? '\x1Bv' : '\x16');
|
||||
await wait();
|
||||
|
||||
// Should not throw and should not set buffer text on error
|
||||
|
|
|
|||
|
|
@ -22,7 +22,11 @@ import { useKeypress } from '../hooks/useKeypress.js';
|
|||
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||
import type { CommandContext, SlashCommand } from '../commands/types.js';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import { ApprovalMode, createDebugLogger } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
ApprovalMode,
|
||||
Storage,
|
||||
createDebugLogger,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
parseInputForHighlighting,
|
||||
buildSegmentsForVisualSlice,
|
||||
|
|
@ -41,6 +45,15 @@ import { useUIActions } from '../contexts/UIActionsContext.js';
|
|||
import { useKeypressContext } from '../contexts/KeypressContext.js';
|
||||
import { FEEDBACK_DIALOG_KEYS } from '../FeedbackDialog.js';
|
||||
|
||||
/**
|
||||
* Represents an attachment (e.g., pasted image) displayed above the input prompt
|
||||
*/
|
||||
export interface Attachment {
|
||||
id: string; // Unique identifier (timestamp)
|
||||
path: string; // Full file path
|
||||
filename: string; // Filename only (for display)
|
||||
}
|
||||
|
||||
const debugLogger = createDebugLogger('INPUT_PROMPT');
|
||||
export interface InputPromptProps {
|
||||
buffer: TextBuffer;
|
||||
|
|
@ -126,6 +139,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
const [recentPasteTime, setRecentPasteTime] = useState<number | null>(null);
|
||||
const pasteTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Attachment state for clipboard images
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const [isAttachmentMode, setIsAttachmentMode] = useState(false);
|
||||
const [selectedAttachmentIndex, setSelectedAttachmentIndex] = useState(-1);
|
||||
// Large paste placeholder handling
|
||||
const [pendingPastes, setPendingPastes] = useState<Map<string, string>>(
|
||||
new Map(),
|
||||
|
|
@ -281,10 +298,25 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
if (shellModeActive) {
|
||||
shellHistory.addCommandToHistory(finalValue);
|
||||
}
|
||||
|
||||
// Convert attachments to @references and prepend to the message
|
||||
if (attachments.length > 0) {
|
||||
const attachmentRefs = attachments
|
||||
.map((att) => `@${path.relative(config.getTargetDir(), att.path)}`)
|
||||
.join(' ');
|
||||
finalValue = `${attachmentRefs}\n\n${finalValue.trim()}`;
|
||||
}
|
||||
|
||||
// Clear the buffer *before* calling onSubmit to prevent potential re-submission
|
||||
// if onSubmit triggers a re-render while the buffer still holds the old value.
|
||||
buffer.setText('');
|
||||
onSubmit(finalValue);
|
||||
|
||||
// Clear attachments after submit
|
||||
setAttachments([]);
|
||||
setIsAttachmentMode(false);
|
||||
setSelectedAttachmentIndex(-1);
|
||||
|
||||
resetCompletionState();
|
||||
resetReverseSearchCompletionState();
|
||||
},
|
||||
|
|
@ -295,6 +327,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
shellModeActive,
|
||||
shellHistory,
|
||||
resetReverseSearchCompletionState,
|
||||
attachments,
|
||||
config,
|
||||
pendingPastes,
|
||||
],
|
||||
);
|
||||
|
|
@ -336,52 +370,45 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
]);
|
||||
|
||||
// Handle clipboard image pasting with Ctrl+V
|
||||
const handleClipboardImage = useCallback(async () => {
|
||||
const handleClipboardImage = useCallback(async (validated = false) => {
|
||||
try {
|
||||
if (await clipboardHasImage()) {
|
||||
const imagePath = await saveClipboardImage(config.getTargetDir());
|
||||
const hasImage = validated || (await clipboardHasImage());
|
||||
if (hasImage) {
|
||||
const imagePath = await saveClipboardImage(Storage.getGlobalTempDir());
|
||||
if (imagePath) {
|
||||
// Clean up old images
|
||||
cleanupOldClipboardImages(config.getTargetDir()).catch(() => {
|
||||
cleanupOldClipboardImages(Storage.getGlobalTempDir()).catch(() => {
|
||||
// Ignore cleanup errors
|
||||
});
|
||||
|
||||
// Get relative path from current directory
|
||||
const relativePath = path.relative(config.getTargetDir(), imagePath);
|
||||
|
||||
// Insert @path reference at cursor position
|
||||
const insertText = `@${relativePath}`;
|
||||
const currentText = buffer.text;
|
||||
const [row, col] = buffer.cursor;
|
||||
|
||||
// Calculate offset from row/col
|
||||
let offset = 0;
|
||||
for (let i = 0; i < row; i++) {
|
||||
offset += buffer.lines[i].length + 1; // +1 for newline
|
||||
}
|
||||
offset += col;
|
||||
|
||||
// Add spaces around the path if needed
|
||||
let textToInsert = insertText;
|
||||
const charBefore = offset > 0 ? currentText[offset - 1] : '';
|
||||
const charAfter =
|
||||
offset < currentText.length ? currentText[offset] : '';
|
||||
|
||||
if (charBefore && charBefore !== ' ' && charBefore !== '\n') {
|
||||
textToInsert = ' ' + textToInsert;
|
||||
}
|
||||
if (!charAfter || (charAfter !== ' ' && charAfter !== '\n')) {
|
||||
textToInsert = textToInsert + ' ';
|
||||
}
|
||||
|
||||
// Insert at cursor position
|
||||
buffer.replaceRangeByOffset(offset, offset, textToInsert);
|
||||
// Add as attachment instead of inserting @reference into text
|
||||
const filename = path.basename(imagePath);
|
||||
const newAttachment: Attachment = {
|
||||
id: String(Date.now()),
|
||||
path: imagePath,
|
||||
filename,
|
||||
};
|
||||
setAttachments((prev) => [...prev, newAttachment]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.error('Error handling clipboard image:', error);
|
||||
}
|
||||
}, [buffer, config]);
|
||||
}, []);
|
||||
|
||||
// Handle deletion of an attachment from the list
|
||||
const handleAttachmentDelete = useCallback((index: number) => {
|
||||
setAttachments((prev) => {
|
||||
const newList = prev.filter((_, i) => i !== index);
|
||||
if (newList.length === 0) {
|
||||
setIsAttachmentMode(false);
|
||||
setSelectedAttachmentIndex(-1);
|
||||
} else {
|
||||
setSelectedAttachmentIndex(Math.min(index, newList.length - 1));
|
||||
}
|
||||
return newList;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleInput = useCallback(
|
||||
(key: Key) => {
|
||||
|
|
@ -412,7 +439,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
const pasted = key.sequence.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
const charCount = [...pasted].length; // Proper Unicode char count
|
||||
const lineCount = pasted.split('\n').length;
|
||||
if (
|
||||
|
||||
// Ensure we never accidentally interpret paste as regular input.
|
||||
if (key.pasteImage) {
|
||||
handleClipboardImage(true);
|
||||
} else if (
|
||||
charCount > LARGE_PASTE_CHAR_THRESHOLD ||
|
||||
lineCount > LARGE_PASTE_LINE_THRESHOLD
|
||||
) {
|
||||
|
|
@ -666,6 +697,55 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
// Attachment mode handling - process before history navigation
|
||||
if (isAttachmentMode && attachments.length > 0) {
|
||||
if (key.name === 'left') {
|
||||
setSelectedAttachmentIndex((i) => Math.max(0, i - 1));
|
||||
return;
|
||||
}
|
||||
if (key.name === 'right') {
|
||||
setSelectedAttachmentIndex((i) =>
|
||||
Math.min(attachments.length - 1, i + 1),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
|
||||
// Exit attachment mode and return to input
|
||||
setIsAttachmentMode(false);
|
||||
setSelectedAttachmentIndex(-1);
|
||||
return;
|
||||
}
|
||||
if (key.name === 'backspace' || key.name === 'delete') {
|
||||
handleAttachmentDelete(selectedAttachmentIndex);
|
||||
return;
|
||||
}
|
||||
if (key.name === 'return' || key.name === 'escape') {
|
||||
setIsAttachmentMode(false);
|
||||
setSelectedAttachmentIndex(-1);
|
||||
return;
|
||||
}
|
||||
// For other keys, exit attachment mode and let input handle them
|
||||
setIsAttachmentMode(false);
|
||||
setSelectedAttachmentIndex(-1);
|
||||
// Continue to process the key in input
|
||||
}
|
||||
|
||||
// Enter attachment mode when pressing up at the first line with attachments
|
||||
if (
|
||||
!isAttachmentMode &&
|
||||
attachments.length > 0 &&
|
||||
!shellModeActive &&
|
||||
!reverseSearchActive &&
|
||||
!commandSearchActive &&
|
||||
buffer.visualCursor[0] === 0 &&
|
||||
buffer.visualScrollRow === 0 &&
|
||||
keyMatchers[Command.NAVIGATION_UP](key)
|
||||
) {
|
||||
setIsAttachmentMode(true);
|
||||
setSelectedAttachmentIndex(attachments.length - 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shellModeActive) {
|
||||
if (keyMatchers[Command.REVERSE_SEARCH](key)) {
|
||||
setCommandSearchActive(true);
|
||||
|
|
@ -864,6 +944,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
onToggleShortcuts,
|
||||
showShortcuts,
|
||||
uiState,
|
||||
isAttachmentMode,
|
||||
attachments,
|
||||
selectedAttachmentIndex,
|
||||
handleAttachmentDelete,
|
||||
uiActions,
|
||||
pasteWorkaround,
|
||||
nextLargePastePlaceholder,
|
||||
|
|
@ -921,6 +1005,23 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
{attachments.length > 0 && (
|
||||
<Box marginLeft={2} marginBottom={0}>
|
||||
<Text color={theme.text.secondary}>{t('Attachments: ')}</Text>
|
||||
{attachments.map((att, idx) => (
|
||||
<Text
|
||||
key={att.id}
|
||||
color={
|
||||
isAttachmentMode && idx === selectedAttachmentIndex
|
||||
? theme.status.success
|
||||
: theme.text.secondary
|
||||
}
|
||||
>
|
||||
[{att.filename}]{idx < attachments.length - 1 ? ' ' : ''}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderTop={true}
|
||||
|
|
@ -1077,6 +1178,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
/>
|
||||
</Box>
|
||||
)}
|
||||
{/* Attachment hints - show when there are attachments and no suggestions visible */}
|
||||
{attachments.length > 0 && !shouldShowSuggestions && (
|
||||
<Box marginLeft={2} marginRight={2}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{isAttachmentMode
|
||||
? t('← → select, Delete to remove, ↓ to exit')
|
||||
: t('↑ to manage attachments')}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -18,7 +18,10 @@ interface Shortcut {
|
|||
// Platform-specific key mappings
|
||||
const getNewlineKey = () =>
|
||||
process.platform === 'win32' ? 'ctrl+enter' : 'ctrl+j';
|
||||
const getPasteKey = () => (process.platform === 'darwin' ? 'cmd+v' : 'ctrl+v');
|
||||
const getPasteKey = () => {
|
||||
if (process.platform === 'win32') return 'alt+v';
|
||||
return process.platform === 'darwin' ? 'cmd+v' : 'ctrl+v';
|
||||
};
|
||||
const getExternalEditorKey = () =>
|
||||
process.platform === 'darwin' ? 'ctrl+x' : 'ctrl+x';
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
|
||||
interface RetryCountdownMessageProps {
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a retry countdown message in a dimmed/secondary style
|
||||
* to visually distinguish it from error messages.
|
||||
*/
|
||||
export const RetryCountdownMessage: React.FC<RetryCountdownMessageProps> = ({
|
||||
text,
|
||||
}) => {
|
||||
if (!text || text.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prefix = '↻ ';
|
||||
const prefixWidth = prefix.length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Box width={prefixWidth}>
|
||||
<Text color={theme.text.secondary}>{prefix}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text wrap="wrap" color={theme.text.secondary}>
|
||||
{text}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
@ -36,6 +36,7 @@ import {
|
|||
MODIFIER_ALT_BIT,
|
||||
MODIFIER_CTRL_BIT,
|
||||
} from '../utils/platformConstants.js';
|
||||
import { clipboardHasImage } from '../utils/clipboardUtils.js';
|
||||
|
||||
import { FOCUS_IN, FOCUS_OUT } from '../hooks/useFocus.js';
|
||||
|
||||
|
|
@ -54,6 +55,7 @@ export interface Key {
|
|||
paste: boolean;
|
||||
sequence: string;
|
||||
kittyProtocol?: boolean;
|
||||
pasteImage?: boolean;
|
||||
}
|
||||
|
||||
export type KeypressHandler = (key: Key) => void;
|
||||
|
|
@ -390,7 +392,7 @@ export function KeypressProvider({
|
|||
}
|
||||
};
|
||||
|
||||
const handleKeypress = (_: unknown, key: Key) => {
|
||||
const handleKeypress = async (_: unknown, key: Key) => {
|
||||
if (key.sequence === FOCUS_IN || key.sequence === FOCUS_OUT) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -400,14 +402,28 @@ export function KeypressProvider({
|
|||
}
|
||||
if (key.name === 'paste-end') {
|
||||
isPaste = false;
|
||||
broadcast({
|
||||
name: '',
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: true,
|
||||
sequence: pasteBuffer.toString(),
|
||||
});
|
||||
if (pasteBuffer.toString().length > 0) {
|
||||
broadcast({
|
||||
name: '',
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: true,
|
||||
sequence: pasteBuffer.toString(),
|
||||
});
|
||||
} else {
|
||||
const hasImage = await clipboardHasImage();
|
||||
broadcast({
|
||||
name: '',
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: true,
|
||||
pasteImage: hasImage,
|
||||
sequence: pasteBuffer.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
pasteBuffer = Buffer.alloc(0);
|
||||
return;
|
||||
}
|
||||
|
|
@ -722,6 +738,7 @@ export function KeypressProvider({
|
|||
};
|
||||
|
||||
let rl: readline.Interface;
|
||||
|
||||
if (usePassthrough) {
|
||||
rl = readline.createInterface({
|
||||
input: keypressStream,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
type ApprovalMode,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { type SettingScope } from '../../config/settings.js';
|
||||
import { type CodingPlanRegion } from '../../constants/codingPlan.js';
|
||||
import type { AuthState } from '../types.js';
|
||||
import { type VisionSwitchOutcome } from '../components/ModelSwitchDialog.js';
|
||||
// OpenAICredentials type (previously imported from OpenAIKeyPrompt)
|
||||
|
|
@ -40,7 +41,10 @@ export interface UIActions {
|
|||
authType: AuthType | undefined,
|
||||
credentials?: OpenAICredentials,
|
||||
) => Promise<void>;
|
||||
handleCodingPlanSubmit: (apiKey: string) => Promise<void>;
|
||||
handleCodingPlanSubmit: (
|
||||
apiKey: string,
|
||||
region?: CodingPlanRegion,
|
||||
) => Promise<void>;
|
||||
setAuthState: (state: AuthState) => void;
|
||||
onAuthError: (error: string | null) => void;
|
||||
cancelAuthentication: () => void;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type { Config } from '@qwen-code/qwen-code-core';
|
|||
import {
|
||||
getErrorMessage,
|
||||
isNodeError,
|
||||
Storage,
|
||||
unescapePath,
|
||||
readManyFiles,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
|
@ -181,7 +182,17 @@ export async function handleAtCommand({
|
|||
|
||||
// Check if path should be ignored based on filtering options
|
||||
const workspaceContext = config.getWorkspaceContext();
|
||||
if (!workspaceContext.isPathWithinWorkspace(pathName)) {
|
||||
|
||||
// Check if path is in project temp directory
|
||||
const projectTempDir = Storage.getGlobalTempDir();
|
||||
const absolutePathName = path.isAbsolute(pathName)
|
||||
? pathName
|
||||
: path.resolve(workspaceContext.getDirectories()[0] || '', pathName);
|
||||
|
||||
if (
|
||||
!absolutePathName.startsWith(projectTempDir) &&
|
||||
!workspaceContext.isPathWithinWorkspace(pathName)
|
||||
) {
|
||||
onDebugMessage(
|
||||
`Path ${pathName} is not in the workspace and will be skipped.`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,33 +7,16 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { useCodingPlanUpdates } from './useCodingPlanUpdates.js';
|
||||
import { CODING_PLAN_ENV_KEY } from '../../constants/codingPlan.js';
|
||||
import {
|
||||
CODING_PLAN_ENV_KEY,
|
||||
getCodingPlanConfig,
|
||||
CodingPlanRegion,
|
||||
} from '../../constants/codingPlan.js';
|
||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||
|
||||
// Mock the constants module
|
||||
vi.mock('../../constants/codingPlan.js', async () => {
|
||||
const actual = await vi.importActual('../../constants/codingPlan.js');
|
||||
return {
|
||||
...actual,
|
||||
CODING_PLAN_VERSION: 'test-version-hash',
|
||||
CODING_PLAN_MODELS: [
|
||||
{
|
||||
id: 'test-model-1',
|
||||
name: 'Test Model 1',
|
||||
baseUrl: 'https://test.example.com/v1',
|
||||
description: 'Test model 1',
|
||||
envKey: 'BAILIAN_CODING_PLAN_API_KEY',
|
||||
},
|
||||
{
|
||||
id: 'test-model-2',
|
||||
name: 'Test Model 2',
|
||||
baseUrl: 'https://test.example.com/v1',
|
||||
description: 'Test model 2',
|
||||
envKey: 'BAILIAN_CODING_PLAN_API_KEY',
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
// Get region configs for testing
|
||||
const chinaConfig = getCodingPlanConfig(CodingPlanRegion.CHINA);
|
||||
const globalConfig = getCodingPlanConfig(CodingPlanRegion.GLOBAL);
|
||||
|
||||
describe('useCodingPlanUpdates', () => {
|
||||
const mockSettings = {
|
||||
|
|
@ -50,6 +33,7 @@ describe('useCodingPlanUpdates', () => {
|
|||
const mockConfig = {
|
||||
reloadModelProvidersConfig: vi.fn(),
|
||||
refreshAuth: vi.fn(),
|
||||
getModel: vi.fn().mockReturnValue('qwen-max'),
|
||||
};
|
||||
|
||||
const mockAddItem = vi.fn();
|
||||
|
|
@ -74,8 +58,11 @@ describe('useCodingPlanUpdates', () => {
|
|||
expect(result.current.codingPlanUpdateRequest).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not show update prompt when versions match', () => {
|
||||
mockSettings.merged.codingPlan = { version: 'test-version-hash' };
|
||||
it('should not show update prompt when China region versions match', () => {
|
||||
mockSettings.merged.codingPlan = {
|
||||
region: CodingPlanRegion.CHINA,
|
||||
version: chinaConfig.version,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCodingPlanUpdates(
|
||||
|
|
@ -88,8 +75,52 @@ describe('useCodingPlanUpdates', () => {
|
|||
expect(result.current.codingPlanUpdateRequest).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should show update prompt when versions differ', async () => {
|
||||
mockSettings.merged.codingPlan = { version: 'old-version-hash' };
|
||||
it('should not show update prompt when Global region versions match', () => {
|
||||
mockSettings.merged.codingPlan = {
|
||||
region: CodingPlanRegion.GLOBAL,
|
||||
version: globalConfig.version,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCodingPlanUpdates(
|
||||
mockSettings as never,
|
||||
mockConfig as never,
|
||||
mockAddItem,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.codingPlanUpdateRequest).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should default to China region when region is not specified', async () => {
|
||||
// No region specified, should default to China
|
||||
mockSettings.merged.codingPlan = {
|
||||
version: 'old-version-hash',
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCodingPlanUpdates(
|
||||
mockSettings as never,
|
||||
mockConfig as never,
|
||||
mockAddItem,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.codingPlanUpdateRequest).toBeDefined();
|
||||
});
|
||||
|
||||
// Should prompt for China region since it defaults to China
|
||||
expect(result.current.codingPlanUpdateRequest?.prompt).toContain(
|
||||
chinaConfig.regionName,
|
||||
);
|
||||
});
|
||||
|
||||
it('should show update prompt when China region versions differ', async () => {
|
||||
mockSettings.merged.codingPlan = {
|
||||
region: CodingPlanRegion.CHINA,
|
||||
version: 'old-version-hash',
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCodingPlanUpdates(
|
||||
|
|
@ -104,20 +135,45 @@ describe('useCodingPlanUpdates', () => {
|
|||
});
|
||||
|
||||
expect(result.current.codingPlanUpdateRequest?.prompt).toContain(
|
||||
'New model configurations',
|
||||
chinaConfig.regionName,
|
||||
);
|
||||
});
|
||||
|
||||
it('should show update prompt when Global region versions differ', async () => {
|
||||
mockSettings.merged.codingPlan = {
|
||||
region: CodingPlanRegion.GLOBAL,
|
||||
version: 'old-version-hash',
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCodingPlanUpdates(
|
||||
mockSettings as never,
|
||||
mockConfig as never,
|
||||
mockAddItem,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.codingPlanUpdateRequest).toBeDefined();
|
||||
});
|
||||
|
||||
expect(result.current.codingPlanUpdateRequest?.prompt).toContain(
|
||||
globalConfig.regionName,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update execution', () => {
|
||||
it('should execute update when user confirms', async () => {
|
||||
process.env[CODING_PLAN_ENV_KEY] = 'test-api-key';
|
||||
mockSettings.merged.codingPlan = { version: 'old-version-hash' };
|
||||
it('should execute China region update when user confirms', async () => {
|
||||
mockSettings.merged.codingPlan = {
|
||||
region: CodingPlanRegion.CHINA,
|
||||
version: 'old-version-hash',
|
||||
};
|
||||
mockSettings.merged.modelProviders = {
|
||||
[AuthType.USE_OPENAI]: [
|
||||
{
|
||||
id: 'test-model-1',
|
||||
baseUrl: 'https://test.example.com/v1',
|
||||
id: 'test-model-china-1',
|
||||
baseUrl: chinaConfig.baseUrl,
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
},
|
||||
{
|
||||
|
|
@ -146,33 +202,112 @@ describe('useCodingPlanUpdates', () => {
|
|||
|
||||
// Wait for async update to complete
|
||||
await waitFor(() => {
|
||||
// Should update model providers (at least 2 calls: modelProviders + version)
|
||||
// Should update model providers (at least 2 calls: modelProviders + version + region)
|
||||
expect(mockSettings.setValue).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should update version
|
||||
// Should update version with correct hash
|
||||
expect(mockSettings.setValue).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'codingPlan.version',
|
||||
'test-version-hash',
|
||||
chinaConfig.version,
|
||||
);
|
||||
|
||||
// Should update region
|
||||
expect(mockSettings.setValue).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'codingPlan.region',
|
||||
CodingPlanRegion.CHINA,
|
||||
);
|
||||
|
||||
// Should reload and refresh auth
|
||||
expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalled();
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI);
|
||||
|
||||
// Should show success message
|
||||
// Should show success message with region info
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'info',
|
||||
text: expect.stringContaining('updated successfully'),
|
||||
text: expect.stringContaining(chinaConfig.regionName),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should execute Global region update when user confirms', async () => {
|
||||
mockSettings.merged.codingPlan = {
|
||||
region: CodingPlanRegion.GLOBAL,
|
||||
version: 'old-version-hash',
|
||||
};
|
||||
mockSettings.merged.modelProviders = {
|
||||
[AuthType.USE_OPENAI]: [
|
||||
{
|
||||
id: 'test-model-global-1',
|
||||
baseUrl: globalConfig.baseUrl,
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
},
|
||||
{
|
||||
id: 'custom-model',
|
||||
baseUrl: 'https://custom.example.com',
|
||||
envKey: 'CUSTOM_API_KEY',
|
||||
},
|
||||
],
|
||||
};
|
||||
mockConfig.refreshAuth.mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCodingPlanUpdates(
|
||||
mockSettings as never,
|
||||
mockConfig as never,
|
||||
mockAddItem,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.codingPlanUpdateRequest).toBeDefined();
|
||||
});
|
||||
|
||||
// Confirm the update
|
||||
await result.current.codingPlanUpdateRequest!.onConfirm(true);
|
||||
|
||||
// Wait for async update to complete
|
||||
await waitFor(() => {
|
||||
expect(mockSettings.setValue).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should update version with correct hash (single version field)
|
||||
expect(mockSettings.setValue).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'codingPlan.version',
|
||||
globalConfig.version,
|
||||
);
|
||||
|
||||
// Should update region
|
||||
expect(mockSettings.setValue).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'codingPlan.region',
|
||||
CodingPlanRegion.GLOBAL,
|
||||
);
|
||||
|
||||
// Should reload and refresh auth
|
||||
expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalled();
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI);
|
||||
|
||||
// Should show success message with Global region info
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'info',
|
||||
text: expect.stringContaining(globalConfig.regionName),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not execute update when user declines', async () => {
|
||||
mockSettings.merged.codingPlan = { version: 'old-version-hash' };
|
||||
mockSettings.merged.codingPlan = {
|
||||
region: CodingPlanRegion.CHINA,
|
||||
version: 'old-version-hash',
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCodingPlanUpdates(
|
||||
|
|
@ -194,9 +329,103 @@ describe('useCodingPlanUpdates', () => {
|
|||
expect(mockConfig.reloadModelProvidersConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should replace all Coding Plan configs during update (mutually exclusive)', async () => {
|
||||
// Since regions are mutually exclusive, when updating one region,
|
||||
// all Coding Plan configs should be replaced (not preserving other region configs)
|
||||
mockSettings.merged.codingPlan = {
|
||||
region: CodingPlanRegion.CHINA,
|
||||
version: 'old-version-hash',
|
||||
};
|
||||
const chinaModelConfig = {
|
||||
id: 'test-model-china-1',
|
||||
baseUrl: chinaConfig.baseUrl,
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
};
|
||||
const globalModelConfig = {
|
||||
id: 'test-model-global-1',
|
||||
baseUrl: globalConfig.baseUrl,
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
};
|
||||
const customConfig = {
|
||||
id: 'custom-model',
|
||||
baseUrl: 'https://custom.example.com',
|
||||
envKey: 'CUSTOM_API_KEY',
|
||||
};
|
||||
mockSettings.merged.modelProviders = {
|
||||
[AuthType.USE_OPENAI]: [
|
||||
chinaModelConfig,
|
||||
globalModelConfig,
|
||||
customConfig,
|
||||
],
|
||||
};
|
||||
mockConfig.refreshAuth.mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCodingPlanUpdates(
|
||||
mockSettings as never,
|
||||
mockConfig as never,
|
||||
mockAddItem,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.codingPlanUpdateRequest).toBeDefined();
|
||||
});
|
||||
|
||||
await result.current.codingPlanUpdateRequest!.onConfirm(true);
|
||||
|
||||
// Wait for async update to complete
|
||||
await waitFor(() => {
|
||||
expect(mockSettings.setValue).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Get the updated configs passed to setValue
|
||||
const setValueCalls = mockSettings.setValue.mock.calls;
|
||||
const modelProvidersCall = setValueCalls.find((call: unknown[]) =>
|
||||
(call[1] as string).includes('modelProviders'),
|
||||
);
|
||||
|
||||
expect(modelProvidersCall).toBeDefined();
|
||||
const updatedConfigs = modelProvidersCall![2] as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
|
||||
// Should have new China configs + custom config only (global config removed since regions are mutually exclusive)
|
||||
// The China template has 8 models, so we expect 8 (from template) + 1 (custom) = 9
|
||||
// Note: description field has been removed, only name field contains the branding
|
||||
expect(updatedConfigs.length).toBe(9);
|
||||
|
||||
// Should NOT contain the Global config (mutually exclusive)
|
||||
expect(
|
||||
updatedConfigs.some(
|
||||
(c: Record<string, unknown>) => c['baseUrl'] === globalConfig.baseUrl,
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
// Should contain the custom config
|
||||
expect(
|
||||
updatedConfigs.some(
|
||||
(c: Record<string, unknown>) => c['id'] === 'custom-model',
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
// All configs should use the unified env key
|
||||
updatedConfigs.forEach((config) => {
|
||||
if (config['envKey'] === CODING_PLAN_ENV_KEY) {
|
||||
expect(config['baseUrl']).toBe(chinaConfig.baseUrl);
|
||||
}
|
||||
});
|
||||
|
||||
// Should reload and refresh auth
|
||||
expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalled();
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI);
|
||||
});
|
||||
|
||||
it('should preserve non-Coding Plan configs during update', async () => {
|
||||
process.env[CODING_PLAN_ENV_KEY] = 'test-api-key';
|
||||
mockSettings.merged.codingPlan = { version: 'old-version-hash' };
|
||||
mockSettings.merged.codingPlan = {
|
||||
region: CodingPlanRegion.CHINA,
|
||||
version: 'old-version-hash',
|
||||
};
|
||||
const customConfig = {
|
||||
id: 'custom-model',
|
||||
baseUrl: 'https://custom.example.com',
|
||||
|
|
@ -205,8 +434,8 @@ describe('useCodingPlanUpdates', () => {
|
|||
mockSettings.merged.modelProviders = {
|
||||
[AuthType.USE_OPENAI]: [
|
||||
{
|
||||
id: 'test-model-1',
|
||||
baseUrl: 'https://test.example.com/v1',
|
||||
id: 'test-model-china-1',
|
||||
baseUrl: chinaConfig.baseUrl,
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
},
|
||||
customConfig,
|
||||
|
|
@ -233,10 +462,41 @@ describe('useCodingPlanUpdates', () => {
|
|||
// Should preserve custom config - verify setValue was called
|
||||
expect(mockSettings.setValue).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Get the updated configs passed to setValue
|
||||
const setValueCalls = mockSettings.setValue.mock.calls;
|
||||
const modelProvidersCall = setValueCalls.find((call: unknown[]) =>
|
||||
(call[1] as string).includes('modelProviders'),
|
||||
);
|
||||
|
||||
// Should preserve custom config
|
||||
expect(modelProvidersCall).toBeDefined();
|
||||
const updatedConfigs = modelProvidersCall![2] as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
expect(
|
||||
updatedConfigs.some(
|
||||
(c: Record<string, unknown>) => c['id'] === 'custom-model',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle missing API key error', async () => {
|
||||
mockSettings.merged.codingPlan = { version: 'old-version-hash' };
|
||||
it('should handle update errors gracefully', async () => {
|
||||
mockSettings.merged.codingPlan = {
|
||||
region: CodingPlanRegion.CHINA,
|
||||
version: 'old-version-hash',
|
||||
};
|
||||
mockSettings.merged.modelProviders = {
|
||||
[AuthType.USE_OPENAI]: [
|
||||
{
|
||||
id: 'test-model-china-1',
|
||||
baseUrl: chinaConfig.baseUrl,
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
},
|
||||
],
|
||||
};
|
||||
// Simulate an error during refreshAuth
|
||||
mockConfig.refreshAuth.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCodingPlanUpdates(
|
||||
|
|
@ -253,18 +513,23 @@ describe('useCodingPlanUpdates', () => {
|
|||
await result.current.codingPlanUpdateRequest!.onConfirm(true);
|
||||
|
||||
// Should show error message
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'error',
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'error',
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('dismissUpdate', () => {
|
||||
it('should clear update request when dismissed', async () => {
|
||||
mockSettings.merged.codingPlan = { version: 'old-version-hash' };
|
||||
mockSettings.merged.codingPlan = {
|
||||
region: CodingPlanRegion.CHINA,
|
||||
version: 'old-version-hash',
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCodingPlanUpdates(
|
||||
|
|
|
|||
|
|
@ -10,9 +10,10 @@ import { AuthType } from '@qwen-code/qwen-code-core';
|
|||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
|
||||
import {
|
||||
CODING_PLAN_MODELS,
|
||||
isCodingPlanConfig,
|
||||
getCodingPlanConfig,
|
||||
CodingPlanRegion,
|
||||
CODING_PLAN_ENV_KEY,
|
||||
CODING_PLAN_VERSION,
|
||||
} from '../../constants/codingPlan.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
|
|
@ -21,20 +22,6 @@ export interface CodingPlanUpdateRequest {
|
|||
onConfirm: (confirmed: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a config is a Coding Plan configuration by matching baseUrl and envKey.
|
||||
* This ensures only configs from the Coding Plan provider are identified.
|
||||
*/
|
||||
function isCodingPlanConfig(config: {
|
||||
baseUrl?: string;
|
||||
envKey?: string;
|
||||
}): boolean {
|
||||
return (
|
||||
config.envKey === CODING_PLAN_ENV_KEY &&
|
||||
CODING_PLAN_MODELS.some((template) => template.baseUrl === config.baseUrl)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for detecting and handling Coding Plan template updates.
|
||||
* Compares the persisted version with the current template version
|
||||
|
|
@ -55,134 +42,148 @@ export function useCodingPlanUpdates(
|
|||
/**
|
||||
* Execute the Coding Plan configuration update.
|
||||
* Removes old Coding Plan configs and replaces them with new ones from the template.
|
||||
* Uses the region from settings.codingPlan.region (defaults to CHINA).
|
||||
*/
|
||||
const executeUpdate = useCallback(async () => {
|
||||
try {
|
||||
const persistScope = getPersistScopeForModelSelection(settings);
|
||||
const executeUpdate = useCallback(
|
||||
async (region: CodingPlanRegion = CodingPlanRegion.CHINA) => {
|
||||
try {
|
||||
const persistScope = getPersistScopeForModelSelection(settings);
|
||||
|
||||
// Get current configs
|
||||
const currentConfigs =
|
||||
(
|
||||
settings.merged.modelProviders as
|
||||
| Record<string, Array<Record<string, unknown>>>
|
||||
| undefined
|
||||
)?.[AuthType.USE_OPENAI] || [];
|
||||
// Get current configs
|
||||
const currentConfigs =
|
||||
(
|
||||
settings.merged.modelProviders as
|
||||
| Record<string, Array<Record<string, unknown>>>
|
||||
| undefined
|
||||
)?.[AuthType.USE_OPENAI] || [];
|
||||
|
||||
// Filter out Coding Plan configs (keep user custom configs)
|
||||
const nonCodingPlanConfigs = currentConfigs.filter(
|
||||
(cfg) =>
|
||||
!isCodingPlanConfig({
|
||||
baseUrl: cfg['baseUrl'] as string | undefined,
|
||||
envKey: cfg['envKey'] as string | undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
// Generate new configs from template with the stored API key
|
||||
const apiKey = process.env[CODING_PLAN_ENV_KEY];
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
t(
|
||||
'Coding Plan API key not found. Please re-authenticate with Coding Plan.',
|
||||
),
|
||||
// Filter out all Coding Plan configs (since they are mutually exclusive)
|
||||
// Keep only non-Coding-Plan user custom configs
|
||||
const nonCodingPlanConfigs = currentConfigs.filter(
|
||||
(cfg) =>
|
||||
!isCodingPlanConfig(
|
||||
cfg['baseUrl'] as string | undefined,
|
||||
cfg['envKey'] as string | undefined,
|
||||
),
|
||||
);
|
||||
|
||||
// Get the configuration for the current region
|
||||
const { template, version, regionName } = getCodingPlanConfig(region);
|
||||
|
||||
// Generate new configs from template
|
||||
const newConfigs = template.map((templateConfig) => ({
|
||||
...templateConfig,
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
}));
|
||||
|
||||
// Combine: new Coding Plan configs at the front, user configs preserved
|
||||
const updatedConfigs = [
|
||||
...newConfigs,
|
||||
...(nonCodingPlanConfigs as Array<Record<string, unknown>>),
|
||||
] as Array<Record<string, unknown>>;
|
||||
|
||||
// Hot-reload model providers configuration first (in-memory only)
|
||||
const updatedModelProviders = {
|
||||
...(settings.merged.modelProviders as
|
||||
| Record<string, unknown>
|
||||
| undefined),
|
||||
[AuthType.USE_OPENAI]: updatedConfigs,
|
||||
};
|
||||
config.reloadModelProvidersConfig(
|
||||
updatedModelProviders as unknown as ModelProvidersConfig,
|
||||
);
|
||||
|
||||
// Refresh auth with the new configuration
|
||||
// This validates the configuration before persisting
|
||||
await config.refreshAuth(AuthType.USE_OPENAI);
|
||||
|
||||
// Persist to settings only after successful auth refresh
|
||||
settings.setValue(
|
||||
persistScope,
|
||||
`modelProviders.${AuthType.USE_OPENAI}`,
|
||||
updatedConfigs,
|
||||
);
|
||||
|
||||
// Update the version (single version field for backward compatibility)
|
||||
settings.setValue(persistScope, 'codingPlan.version', version);
|
||||
|
||||
// Update the region
|
||||
settings.setValue(persistScope, 'codingPlan.region', region);
|
||||
|
||||
const activeModel = config.getModel();
|
||||
|
||||
addItem(
|
||||
{
|
||||
type: 'info',
|
||||
text: t(
|
||||
'{{region}} configuration updated successfully. Model switched to "{{model}}".',
|
||||
{ region: regionName, model: activeModel },
|
||||
),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
addItem(
|
||||
{
|
||||
type: 'error',
|
||||
text: t('Failed to update Coding Plan configuration: {{message}}', {
|
||||
message: errorMessage,
|
||||
}),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const newConfigs = CODING_PLAN_MODELS.map((templateConfig) => ({
|
||||
...templateConfig,
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
}));
|
||||
|
||||
// Combine: new Coding Plan configs at the front, user configs preserved
|
||||
const updatedConfigs = [
|
||||
...newConfigs,
|
||||
...(nonCodingPlanConfigs as Array<Record<string, unknown>>),
|
||||
] as Array<Record<string, unknown>>;
|
||||
|
||||
// Persist updated model providers
|
||||
settings.setValue(
|
||||
persistScope,
|
||||
`modelProviders.${AuthType.USE_OPENAI}`,
|
||||
updatedConfigs,
|
||||
);
|
||||
|
||||
// Update the version
|
||||
settings.setValue(
|
||||
persistScope,
|
||||
'codingPlan.version',
|
||||
CODING_PLAN_VERSION,
|
||||
);
|
||||
|
||||
// Hot-reload model providers configuration
|
||||
const updatedModelProviders = {
|
||||
...(settings.merged.modelProviders as
|
||||
| Record<string, unknown>
|
||||
| undefined),
|
||||
[AuthType.USE_OPENAI]: updatedConfigs,
|
||||
};
|
||||
config.reloadModelProvidersConfig(
|
||||
updatedModelProviders as unknown as ModelProvidersConfig,
|
||||
);
|
||||
|
||||
// Refresh auth with the new configuration
|
||||
await config.refreshAuth(AuthType.USE_OPENAI);
|
||||
|
||||
addItem(
|
||||
{
|
||||
type: 'info',
|
||||
text: t(
|
||||
'Coding Plan configuration updated successfully. New models are now available.',
|
||||
),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
addItem(
|
||||
{
|
||||
type: 'error',
|
||||
text: t('Failed to update Coding Plan configuration: {{message}}', {
|
||||
message: errorMessage,
|
||||
}),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}, [settings, config, addItem]);
|
||||
},
|
||||
[settings, config, addItem],
|
||||
);
|
||||
|
||||
/**
|
||||
* Check for version mismatch and prompt user for update if needed.
|
||||
* Uses the region from settings.codingPlan.region (defaults to CHINA if not set).
|
||||
*/
|
||||
const checkForUpdates = useCallback(() => {
|
||||
const savedVersion = (
|
||||
settings.merged as { codingPlan?: { version?: string } }
|
||||
).codingPlan?.version;
|
||||
const mergedSettings = settings.merged as {
|
||||
codingPlan?: {
|
||||
version?: string;
|
||||
region?: CodingPlanRegion;
|
||||
};
|
||||
};
|
||||
|
||||
// Get the region (default to CHINA if not set)
|
||||
const region = mergedSettings.codingPlan?.region ?? CodingPlanRegion.CHINA;
|
||||
|
||||
// Get the saved version for the current region
|
||||
const savedVersion = mergedSettings.codingPlan?.version;
|
||||
|
||||
// If no version is stored, user hasn't used Coding Plan yet - skip check
|
||||
if (!savedVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If versions match, no update needed
|
||||
if (savedVersion === CODING_PLAN_VERSION) {
|
||||
return;
|
||||
}
|
||||
// Get current version for the region
|
||||
const currentVersion = getCodingPlanConfig(region).version;
|
||||
|
||||
// Version mismatch - prompt user for update
|
||||
setUpdateRequest({
|
||||
prompt: t(
|
||||
'New model configurations are available for Bailian Coding Plan. Update now?',
|
||||
),
|
||||
onConfirm: async (confirmed: boolean) => {
|
||||
setUpdateRequest(undefined);
|
||||
if (confirmed) {
|
||||
await executeUpdate();
|
||||
}
|
||||
},
|
||||
});
|
||||
// Check if version matches
|
||||
if (savedVersion !== currentVersion) {
|
||||
const { regionName } = getCodingPlanConfig(region);
|
||||
setUpdateRequest({
|
||||
prompt: t(
|
||||
'New model configurations are available for {{region}}. Update now?',
|
||||
{ region: regionName },
|
||||
),
|
||||
onConfirm: async (confirmed: boolean) => {
|
||||
setUpdateRequest(undefined);
|
||||
if (confirmed) {
|
||||
await executeUpdate(region);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [settings, executeUpdate]);
|
||||
|
||||
// Check for updates on mount
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import type { Mock, MockInstance } from 'vitest';
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useGeminiStream } from './useGeminiStream.js';
|
||||
import { useKeypress } from './useKeypress.js';
|
||||
import * as atCommandProcessor from './atCommandProcessor.js';
|
||||
import type {
|
||||
TrackedToolCall,
|
||||
|
|
@ -67,7 +66,12 @@ const MockedUserPromptEvent = vi.hoisted(() =>
|
|||
const MockedApiCancelEvent = vi.hoisted(() =>
|
||||
vi.fn().mockImplementation(() => {}),
|
||||
);
|
||||
const mockParseAndFormatApiError = vi.hoisted(() => vi.fn());
|
||||
const mockParseAndFormatApiError = vi.hoisted(() =>
|
||||
vi.fn(
|
||||
(msg: unknown) =>
|
||||
`[API Error: ${typeof msg === 'string' ? msg : 'An unknown error occurred.'}]`,
|
||||
),
|
||||
);
|
||||
const mockLogApiCancel = vi.hoisted(() => vi.fn());
|
||||
|
||||
// Vision auto-switch mocks (hoisted)
|
||||
|
|
@ -107,10 +111,6 @@ vi.mock('./useVisionAutoSwitch.js', () => ({
|
|||
})),
|
||||
}));
|
||||
|
||||
vi.mock('./useKeypress.js', () => ({
|
||||
useKeypress: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./shellCommandProcessor.js', () => ({
|
||||
useShellCommandProcessor: vi.fn().mockReturnValue({
|
||||
handleShellCommand: vi.fn(),
|
||||
|
|
@ -123,22 +123,6 @@ vi.mock('../utils/markdownUtilities.js', () => ({
|
|||
findLastSafeSplitPoint: vi.fn((s: string) => s.length),
|
||||
}));
|
||||
|
||||
vi.mock('./useStateAndRef.js', () => ({
|
||||
useStateAndRef: vi.fn((initial) => {
|
||||
let val = initial;
|
||||
const ref = { current: val };
|
||||
const setVal = vi.fn((updater) => {
|
||||
if (typeof updater === 'function') {
|
||||
val = updater(val);
|
||||
} else {
|
||||
val = updater;
|
||||
}
|
||||
ref.current = val;
|
||||
});
|
||||
return [val, ref, setVal];
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('./useLogger.js', () => ({
|
||||
useLogger: vi.fn().mockReturnValue({
|
||||
logMessage: vi.fn().mockResolvedValue(undefined),
|
||||
|
|
@ -850,28 +834,8 @@ describe('useGeminiStream', () => {
|
|||
expect(result.current.streamingState).toBe(StreamingState.Responding);
|
||||
});
|
||||
|
||||
describe('User Cancellation', () => {
|
||||
let keypressCallback: (key: any) => void;
|
||||
const mockUseKeypress = useKeypress as Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
// Capture the callback passed to useKeypress
|
||||
mockUseKeypress.mockImplementation((callback, options) => {
|
||||
if (options.isActive) {
|
||||
keypressCallback = callback;
|
||||
} else {
|
||||
keypressCallback = () => {};
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const simulateEscapeKeyPress = () => {
|
||||
act(() => {
|
||||
keypressCallback({ name: 'escape' });
|
||||
});
|
||||
};
|
||||
|
||||
it('should cancel an in-progress stream when escape is pressed', async () => {
|
||||
describe('Cancellation', () => {
|
||||
it('should cancel an in-progress stream when cancelOngoingRequest is called', async () => {
|
||||
const mockStream = (async function* () {
|
||||
yield { type: 'content', value: 'Part 1' };
|
||||
// Keep the stream open
|
||||
|
|
@ -891,8 +855,10 @@ describe('useGeminiStream', () => {
|
|||
expect(result.current.streamingState).toBe(StreamingState.Responding);
|
||||
});
|
||||
|
||||
// Simulate escape key press
|
||||
simulateEscapeKeyPress();
|
||||
// Call cancelOngoingRequest directly
|
||||
act(() => {
|
||||
result.current.cancelOngoingRequest();
|
||||
});
|
||||
|
||||
// Verify cancellation message is added
|
||||
await waitFor(() => {
|
||||
|
|
@ -909,7 +875,7 @@ describe('useGeminiStream', () => {
|
|||
expect(result.current.streamingState).toBe(StreamingState.Idle);
|
||||
});
|
||||
|
||||
it('should call onCancelSubmit handler when escape is pressed', async () => {
|
||||
it('should call onCancelSubmit handler when cancelOngoingRequest is called', async () => {
|
||||
const cancelSubmitSpy = vi.fn();
|
||||
const mockStream = (async function* () {
|
||||
yield { type: 'content', value: 'Part 1' };
|
||||
|
|
@ -947,12 +913,14 @@ describe('useGeminiStream', () => {
|
|||
result.current.submitQuery('test query');
|
||||
});
|
||||
|
||||
simulateEscapeKeyPress();
|
||||
act(() => {
|
||||
result.current.cancelOngoingRequest();
|
||||
});
|
||||
|
||||
expect(cancelSubmitSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call setShellInputFocused(false) when escape is pressed', async () => {
|
||||
it('should call setShellInputFocused(false) when cancelOngoingRequest is called', async () => {
|
||||
const setShellInputFocusedSpy = vi.fn();
|
||||
const mockStream = (async function* () {
|
||||
yield { type: 'content', value: 'Part 1' };
|
||||
|
|
@ -989,18 +957,22 @@ describe('useGeminiStream', () => {
|
|||
result.current.submitQuery('test query');
|
||||
});
|
||||
|
||||
simulateEscapeKeyPress();
|
||||
act(() => {
|
||||
result.current.cancelOngoingRequest();
|
||||
});
|
||||
|
||||
expect(setShellInputFocusedSpy).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should not do anything if escape is pressed when not responding', () => {
|
||||
it('should not do anything if cancelOngoingRequest is called when not responding', () => {
|
||||
const { result } = renderTestHook();
|
||||
|
||||
expect(result.current.streamingState).toBe(StreamingState.Idle);
|
||||
|
||||
// Simulate escape key press
|
||||
simulateEscapeKeyPress();
|
||||
// Call cancelOngoingRequest
|
||||
act(() => {
|
||||
result.current.cancelOngoingRequest();
|
||||
});
|
||||
|
||||
// No change should happen, no cancellation message
|
||||
expect(mockAddItem).not.toHaveBeenCalledWith(
|
||||
|
|
@ -1035,7 +1007,9 @@ describe('useGeminiStream', () => {
|
|||
});
|
||||
|
||||
// Cancel the request
|
||||
simulateEscapeKeyPress();
|
||||
act(() => {
|
||||
result.current.cancelOngoingRequest();
|
||||
});
|
||||
|
||||
// Allow the stream to continue
|
||||
act(() => {
|
||||
|
|
@ -1083,7 +1057,9 @@ describe('useGeminiStream', () => {
|
|||
expect(result.current.streamingState).toBe(StreamingState.Responding);
|
||||
|
||||
// Try to cancel
|
||||
simulateEscapeKeyPress();
|
||||
act(() => {
|
||||
result.current.cancelOngoingRequest();
|
||||
});
|
||||
|
||||
// Nothing should happen because the state is not `Responding`
|
||||
expect(abortSpy).not.toHaveBeenCalled();
|
||||
|
|
@ -2296,6 +2272,127 @@ describe('useGeminiStream', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should show a retry countdown and update pending history over time', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
let resolveStream: (() => void) | undefined;
|
||||
mockSendMessageStream.mockReturnValue(
|
||||
(async function* () {
|
||||
yield {
|
||||
type: ServerGeminiEventType.Retry,
|
||||
retryInfo: {
|
||||
message: '[API Error: Rate limit exceeded]',
|
||||
attempt: 1,
|
||||
maxRetries: 3,
|
||||
delayMs: 3000,
|
||||
},
|
||||
};
|
||||
yield {
|
||||
type: ServerGeminiEventType.Retry,
|
||||
};
|
||||
await new Promise<void>((resolve) => {
|
||||
resolveStream = resolve;
|
||||
});
|
||||
yield {
|
||||
type: ServerGeminiEventType.Finished,
|
||||
value: { reason: 'STOP', usageMetadata: undefined },
|
||||
};
|
||||
})(),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useGeminiStream(
|
||||
new MockedGeminiClientClass(mockConfig),
|
||||
[],
|
||||
mockAddItem,
|
||||
mockConfig,
|
||||
mockLoadedSettings,
|
||||
mockOnDebugMessage,
|
||||
mockHandleSlashCommand,
|
||||
false,
|
||||
() => 'vscode' as EditorType,
|
||||
() => {},
|
||||
() => Promise.resolve(),
|
||||
false,
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
false, // visionModelPreviewEnabled
|
||||
() => {},
|
||||
80,
|
||||
24,
|
||||
),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
void result.current.submitQuery('Trigger retry');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const findErrorItem = () =>
|
||||
result.current.pendingHistoryItems.find(
|
||||
(item) => item.type === MessageType.ERROR,
|
||||
);
|
||||
const findCountdownItem = () =>
|
||||
result.current.pendingHistoryItems.find(
|
||||
(item) => item.type === 'retry_countdown',
|
||||
);
|
||||
|
||||
let errorItem = findErrorItem();
|
||||
let countdownItem = findCountdownItem();
|
||||
for (
|
||||
let attempts = 0;
|
||||
attempts < 5 && (!errorItem || !countdownItem);
|
||||
attempts++
|
||||
) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
errorItem = findErrorItem();
|
||||
countdownItem = findCountdownItem();
|
||||
}
|
||||
|
||||
// Error line should be rendered as ERROR type (wrapped by parseAndFormatApiError)
|
||||
expect(errorItem?.text).toContain('Rate limit exceeded');
|
||||
|
||||
// Countdown line should be rendered as retry_countdown type
|
||||
expect(countdownItem?.text).toContain('Retrying in 3 seconds');
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
});
|
||||
|
||||
const countdownAfterOneSecond = result.current.pendingHistoryItems.find(
|
||||
(item) => item.type === 'retry_countdown',
|
||||
);
|
||||
expect(countdownAfterOneSecond?.text).toContain(
|
||||
'Retrying in 2 seconds',
|
||||
);
|
||||
|
||||
resolveStream?.();
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
// Both error and countdown should be cleared after retry succeeds
|
||||
const remainingError = result.current.pendingHistoryItems.find(
|
||||
(item) => item.type === MessageType.ERROR,
|
||||
);
|
||||
const remainingCountdown = result.current.pendingHistoryItems.find(
|
||||
(item) => item.type === 'retry_countdown',
|
||||
);
|
||||
expect(remainingError).toBeUndefined();
|
||||
expect(remainingCountdown).toBeUndefined();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('should memoize pendingHistoryItems', () => {
|
||||
mockUseReactToolScheduler.mockReturnValue([
|
||||
[],
|
||||
|
|
|
|||
|
|
@ -63,8 +63,8 @@ import {
|
|||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { useSessionStats } from '../contexts/SessionContext.js';
|
||||
import { useKeypress } from './useKeypress.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
const debugLogger = createDebugLogger('GEMINI_STREAM');
|
||||
|
||||
|
|
@ -115,7 +115,6 @@ export const useGeminiStream = (
|
|||
persistSessionModel?: string;
|
||||
showGuidance?: boolean;
|
||||
}>,
|
||||
isShellFocused?: boolean,
|
||||
) => {
|
||||
const [initError, setInitError] = useState<string | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
|
@ -125,6 +124,16 @@ export const useGeminiStream = (
|
|||
const [thought, setThought] = useState<ThoughtSummary | null>(null);
|
||||
const [pendingHistoryItem, pendingHistoryItemRef, setPendingHistoryItem] =
|
||||
useStateAndRef<HistoryItemWithoutId | null>(null);
|
||||
const [pendingRetryErrorItem, setPendingRetryErrorItem] =
|
||||
useState<HistoryItemWithoutId | null>(null);
|
||||
const [
|
||||
pendingRetryCountdownItem,
|
||||
pendingRetryCountdownItemRef,
|
||||
setPendingRetryCountdownItem,
|
||||
] = useStateAndRef<HistoryItemWithoutId | null>(null);
|
||||
const retryCountdownTimerRef = useRef<ReturnType<typeof setInterval> | null>(
|
||||
null,
|
||||
);
|
||||
const processedMemoryToolsRef = useRef<Set<string>>(new Set());
|
||||
const {
|
||||
startNewPrompt,
|
||||
|
|
@ -189,6 +198,69 @@ export const useGeminiStream = (
|
|||
onComplete: (result: { userSelection: 'disable' | 'keep' }) => void;
|
||||
} | null>(null);
|
||||
|
||||
const stopRetryCountdownTimer = useCallback(() => {
|
||||
if (retryCountdownTimerRef.current) {
|
||||
clearInterval(retryCountdownTimerRef.current);
|
||||
retryCountdownTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearRetryCountdown = useCallback(() => {
|
||||
stopRetryCountdownTimer();
|
||||
setPendingRetryErrorItem(null);
|
||||
setPendingRetryCountdownItem(null);
|
||||
}, [setPendingRetryCountdownItem, stopRetryCountdownTimer]);
|
||||
|
||||
const startRetryCountdown = useCallback(
|
||||
(retryInfo: {
|
||||
message?: string;
|
||||
attempt: number;
|
||||
maxRetries: number;
|
||||
delayMs: number;
|
||||
}) => {
|
||||
stopRetryCountdownTimer();
|
||||
const startTime = Date.now();
|
||||
const { message, attempt, maxRetries, delayMs } = retryInfo;
|
||||
const retryReasonText =
|
||||
message ?? t('Rate limit exceeded. Please wait and try again.');
|
||||
|
||||
// Error line stays static (red with ✕ prefix)
|
||||
setPendingRetryErrorItem({
|
||||
type: MessageType.ERROR,
|
||||
text: retryReasonText,
|
||||
});
|
||||
|
||||
// Countdown line updates every second (dim/secondary color)
|
||||
const updateCountdown = () => {
|
||||
const elapsedMs = Date.now() - startTime;
|
||||
const remainingMs = Math.max(0, delayMs - elapsedMs);
|
||||
const remainingSec = Math.ceil(remainingMs / 1000);
|
||||
|
||||
setPendingRetryCountdownItem({
|
||||
type: 'retry_countdown',
|
||||
text: t(
|
||||
'Retrying in {{seconds}} seconds… (attempt {{attempt}}/{{maxRetries}})',
|
||||
{
|
||||
seconds: String(remainingSec),
|
||||
attempt: String(attempt),
|
||||
maxRetries: String(maxRetries),
|
||||
},
|
||||
),
|
||||
} as HistoryItemWithoutId);
|
||||
|
||||
if (remainingMs <= 0) {
|
||||
stopRetryCountdownTimer();
|
||||
}
|
||||
};
|
||||
|
||||
updateCountdown();
|
||||
retryCountdownTimerRef.current = setInterval(updateCountdown, 1000);
|
||||
},
|
||||
[setPendingRetryCountdownItem, stopRetryCountdownTimer],
|
||||
);
|
||||
|
||||
useEffect(() => () => stopRetryCountdownTimer(), [stopRetryCountdownTimer]);
|
||||
|
||||
const onExec = useCallback(async (done: Promise<void>) => {
|
||||
setIsResponding(true);
|
||||
await done;
|
||||
|
|
@ -295,6 +367,7 @@ export const useGeminiStream = (
|
|||
Date.now(),
|
||||
);
|
||||
setPendingHistoryItem(null);
|
||||
clearRetryCountdown();
|
||||
onCancelSubmit();
|
||||
setIsResponding(false);
|
||||
setShellInputFocused(false);
|
||||
|
|
@ -305,19 +378,11 @@ export const useGeminiStream = (
|
|||
onCancelSubmit,
|
||||
pendingHistoryItemRef,
|
||||
setShellInputFocused,
|
||||
clearRetryCountdown,
|
||||
config,
|
||||
getPromptCount,
|
||||
]);
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'escape' && !isShellFocused) {
|
||||
cancelOngoingRequest();
|
||||
}
|
||||
},
|
||||
{ isActive: streamingState === StreamingState.Responding },
|
||||
);
|
||||
|
||||
const prepareQueryForGemini = useCallback(
|
||||
async (
|
||||
query: PartListUnion,
|
||||
|
|
@ -609,10 +674,17 @@ export const useGeminiStream = (
|
|||
{ type: MessageType.INFO, text: 'User cancelled the request.' },
|
||||
userMessageTimestamp,
|
||||
);
|
||||
clearRetryCountdown();
|
||||
setIsResponding(false);
|
||||
setThought(null); // Reset thought when user cancels
|
||||
},
|
||||
[addItem, pendingHistoryItemRef, setPendingHistoryItem, setThought],
|
||||
[
|
||||
addItem,
|
||||
pendingHistoryItemRef,
|
||||
setPendingHistoryItem,
|
||||
setThought,
|
||||
clearRetryCountdown,
|
||||
],
|
||||
);
|
||||
|
||||
const handleErrorEvent = useCallback(
|
||||
|
|
@ -631,9 +703,17 @@ export const useGeminiStream = (
|
|||
},
|
||||
userMessageTimestamp,
|
||||
);
|
||||
clearRetryCountdown();
|
||||
setThought(null); // Reset thought when there's an error
|
||||
},
|
||||
[addItem, pendingHistoryItemRef, setPendingHistoryItem, config, setThought],
|
||||
[
|
||||
addItem,
|
||||
pendingHistoryItemRef,
|
||||
setPendingHistoryItem,
|
||||
config,
|
||||
setThought,
|
||||
clearRetryCountdown,
|
||||
],
|
||||
);
|
||||
|
||||
const handleCitationEvent = useCallback(
|
||||
|
|
@ -693,8 +773,9 @@ export const useGeminiStream = (
|
|||
userMessageTimestamp,
|
||||
);
|
||||
}
|
||||
clearRetryCountdown();
|
||||
},
|
||||
[addItem],
|
||||
[addItem, clearRetryCountdown],
|
||||
);
|
||||
|
||||
const handleChatCompressionEvent = useCallback(
|
||||
|
|
@ -853,7 +934,16 @@ export const useGeminiStream = (
|
|||
loopDetectedRef.current = true;
|
||||
break;
|
||||
case ServerGeminiEventType.Retry:
|
||||
// Will add the missing logic later
|
||||
// Clear any pending partial content from the failed attempt
|
||||
if (pendingHistoryItemRef.current) {
|
||||
setPendingHistoryItem(null);
|
||||
}
|
||||
// Show retry info if available (rate-limit / throttling errors)
|
||||
if (event.retryInfo) {
|
||||
startRetryCountdown(event.retryInfo);
|
||||
} else if (!pendingRetryCountdownItemRef.current) {
|
||||
clearRetryCountdown();
|
||||
}
|
||||
break;
|
||||
default: {
|
||||
// enforces exhaustive switch-case
|
||||
|
|
@ -878,7 +968,12 @@ export const useGeminiStream = (
|
|||
handleMaxSessionTurnsEvent,
|
||||
handleSessionTokenLimitExceededEvent,
|
||||
handleCitationEvent,
|
||||
startRetryCountdown,
|
||||
clearRetryCountdown,
|
||||
setThought,
|
||||
pendingHistoryItemRef,
|
||||
setPendingHistoryItem,
|
||||
pendingRetryCountdownItemRef,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -1216,10 +1311,18 @@ export const useGeminiStream = (
|
|||
|
||||
const pendingHistoryItems = useMemo(
|
||||
() =>
|
||||
[pendingHistoryItem, pendingToolCallGroupDisplay].filter(
|
||||
(i) => i !== undefined && i !== null,
|
||||
),
|
||||
[pendingHistoryItem, pendingToolCallGroupDisplay],
|
||||
[
|
||||
pendingHistoryItem,
|
||||
pendingRetryErrorItem,
|
||||
pendingRetryCountdownItem,
|
||||
pendingToolCallGroupDisplay,
|
||||
].filter((i) => i !== undefined && i !== null),
|
||||
[
|
||||
pendingHistoryItem,
|
||||
pendingRetryErrorItem,
|
||||
pendingRetryCountdownItem,
|
||||
pendingToolCallGroupDisplay,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -269,8 +269,11 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
|||
return false; // Let InputPrompt handle completion
|
||||
}
|
||||
|
||||
// Let InputPrompt handle Ctrl+V for clipboard image pasting
|
||||
if (normalizedKey.ctrl && normalizedKey.name === 'v') {
|
||||
// Let InputPrompt handle Ctrl+V or Cmd+V for clipboard image pasting
|
||||
if (
|
||||
(normalizedKey.ctrl || normalizedKey.meta) &&
|
||||
normalizedKey.name === 'v'
|
||||
) {
|
||||
return false; // Let InputPrompt handle clipboard functionality
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { defaultKeyBindings } from '../config/keyBindings.js';
|
|||
import type { Key } from './hooks/useKeypress.js';
|
||||
|
||||
describe('keyMatchers', () => {
|
||||
const isWindows = process.platform === 'win32';
|
||||
const createKey = (name: string, mods: Partial<Key> = {}): Key => ({
|
||||
name,
|
||||
ctrl: false,
|
||||
|
|
@ -49,7 +50,8 @@ describe('keyMatchers', () => {
|
|||
key.name === 'return' && (key.ctrl || key.meta || key.paste),
|
||||
[Command.OPEN_EXTERNAL_EDITOR]: (key: Key) =>
|
||||
key.ctrl && (key.name === 'x' || key.sequence === '\x18'),
|
||||
[Command.PASTE_CLIPBOARD_IMAGE]: (key: Key) => key.ctrl && key.name === 'v',
|
||||
[Command.PASTE_CLIPBOARD_IMAGE]: (key: Key) =>
|
||||
(isWindows ? key.meta : key.ctrl || key.meta) && key.name === 'v',
|
||||
[Command.TOGGLE_TOOL_DESCRIPTIONS]: (key: Key) =>
|
||||
key.ctrl && key.name === 't',
|
||||
[Command.TOGGLE_IDE_CONTEXT_DETAIL]: (key: Key) =>
|
||||
|
|
@ -216,8 +218,12 @@ describe('keyMatchers', () => {
|
|||
},
|
||||
{
|
||||
command: Command.PASTE_CLIPBOARD_IMAGE,
|
||||
positive: [createKey('v', { ctrl: true })],
|
||||
negative: [createKey('v'), createKey('c', { ctrl: true })],
|
||||
positive: isWindows
|
||||
? [createKey('v', { meta: true })]
|
||||
: [createKey('v', { ctrl: true }), createKey('v', { meta: true })],
|
||||
negative: isWindows
|
||||
? [createKey('v', { ctrl: true }), createKey('v')]
|
||||
: [createKey('v'), createKey('c', { ctrl: true })],
|
||||
},
|
||||
|
||||
// App level bindings
|
||||
|
|
|
|||
|
|
@ -50,6 +50,10 @@ function matchKeyBinding(keyBinding: KeyBinding, key: Key): boolean {
|
|||
return false;
|
||||
}
|
||||
|
||||
if (keyBinding.meta !== undefined && key.meta !== keyBinding.meta) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export const AVAILABLE_MODELS_QWEN: AvailableModel[] = [
|
|||
label: MAINLINE_CODER,
|
||||
get description() {
|
||||
return t(
|
||||
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)',
|
||||
'Qwen 3.5 Plus — efficient hybrid model with leading coding performance',
|
||||
);
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -128,6 +128,11 @@ export type HistoryItemWarning = HistoryItemBase & {
|
|||
text: string;
|
||||
};
|
||||
|
||||
export type HistoryItemRetryCountdown = HistoryItemBase & {
|
||||
type: 'retry_countdown';
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type HistoryItemAbout = HistoryItemBase & {
|
||||
type: 'about';
|
||||
systemInfo: {
|
||||
|
|
@ -265,6 +270,7 @@ export type HistoryItemWithoutId =
|
|||
| HistoryItemInfo
|
||||
| HistoryItemError
|
||||
| HistoryItemWarning
|
||||
| HistoryItemRetryCountdown
|
||||
| HistoryItemAbout
|
||||
| HistoryItemHelp
|
||||
| HistoryItemToolGroup
|
||||
|
|
|
|||
|
|
@ -4,66 +4,120 @@
|
|||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import {
|
||||
clipboardHasImage,
|
||||
saveClipboardImage,
|
||||
cleanupOldClipboardImages,
|
||||
} from './clipboardUtils.js';
|
||||
|
||||
// Mock ClipboardManager
|
||||
const mockHasFormat = vi.fn();
|
||||
const mockGetImageData = vi.fn();
|
||||
|
||||
vi.mock('@teddyzhu/clipboard', () => ({
|
||||
default: {
|
||||
ClipboardManager: vi.fn().mockImplementation(() => ({
|
||||
hasFormat: mockHasFormat,
|
||||
getImageData: mockGetImageData,
|
||||
})),
|
||||
},
|
||||
ClipboardManager: vi.fn().mockImplementation(() => ({
|
||||
hasFormat: mockHasFormat,
|
||||
getImageData: mockGetImageData,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('clipboardUtils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('clipboardHasImage', () => {
|
||||
it('should return false on non-macOS platforms', async () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
const result = await clipboardHasImage();
|
||||
expect(result).toBe(false);
|
||||
} else {
|
||||
// Skip on macOS as it would require actual clipboard state
|
||||
expect(true).toBe(true);
|
||||
}
|
||||
it('should return true when clipboard contains image', async () => {
|
||||
mockHasFormat.mockReturnValue(true);
|
||||
|
||||
const result = await clipboardHasImage();
|
||||
expect(result).toBe(true);
|
||||
expect(mockHasFormat).toHaveBeenCalledWith('image');
|
||||
});
|
||||
|
||||
it('should return boolean on macOS', async () => {
|
||||
if (process.platform === 'darwin') {
|
||||
const result = await clipboardHasImage();
|
||||
expect(typeof result).toBe('boolean');
|
||||
} else {
|
||||
// Skip on non-macOS
|
||||
expect(true).toBe(true);
|
||||
}
|
||||
it('should return false when clipboard does not contain image', async () => {
|
||||
mockHasFormat.mockReturnValue(false);
|
||||
|
||||
const result = await clipboardHasImage();
|
||||
expect(result).toBe(false);
|
||||
expect(mockHasFormat).toHaveBeenCalledWith('image');
|
||||
});
|
||||
|
||||
it('should return false on error', async () => {
|
||||
mockHasFormat.mockImplementation(() => {
|
||||
throw new Error('Clipboard error');
|
||||
});
|
||||
|
||||
const result = await clipboardHasImage();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false and not throw when error occurs in DEBUG mode', async () => {
|
||||
const originalEnv = process.env;
|
||||
vi.stubGlobal('process', {
|
||||
...process,
|
||||
env: { ...originalEnv, DEBUG: '1' },
|
||||
});
|
||||
|
||||
mockHasFormat.mockImplementation(() => {
|
||||
throw new Error('Test error');
|
||||
});
|
||||
|
||||
const result = await clipboardHasImage();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveClipboardImage', () => {
|
||||
it('should return null on non-macOS platforms', async () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
const result = await saveClipboardImage();
|
||||
expect(result).toBe(null);
|
||||
} else {
|
||||
// Skip on macOS
|
||||
expect(true).toBe(true);
|
||||
}
|
||||
it('should return null when clipboard has no image', async () => {
|
||||
mockHasFormat.mockReturnValue(false);
|
||||
|
||||
const result = await saveClipboardImage('/tmp/test');
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
// Test with invalid directory (should not throw)
|
||||
const result = await saveClipboardImage(
|
||||
'/invalid/path/that/does/not/exist',
|
||||
);
|
||||
it('should return null when image data buffer is null', async () => {
|
||||
mockHasFormat.mockReturnValue(true);
|
||||
mockGetImageData.mockReturnValue({ data: null });
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
// On macOS, might return null due to various errors
|
||||
expect(result === null || typeof result === 'string').toBe(true);
|
||||
} else {
|
||||
// On other platforms, should always return null
|
||||
expect(result).toBe(null);
|
||||
}
|
||||
const result = await saveClipboardImage('/tmp/test');
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
||||
it('should handle errors gracefully and return null', async () => {
|
||||
mockHasFormat.mockImplementation(() => {
|
||||
throw new Error('Clipboard error');
|
||||
});
|
||||
|
||||
const result = await saveClipboardImage('/tmp/test');
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
||||
it('should return null and not throw when error occurs in DEBUG mode', async () => {
|
||||
const originalEnv = process.env;
|
||||
vi.stubGlobal('process', {
|
||||
...process,
|
||||
env: { ...originalEnv, DEBUG: '1' },
|
||||
});
|
||||
|
||||
mockHasFormat.mockImplementation(() => {
|
||||
throw new Error('Test error');
|
||||
});
|
||||
|
||||
const result = await saveClipboardImage('/tmp/test');
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupOldClipboardImages', () => {
|
||||
it('should not throw errors', async () => {
|
||||
// Should handle missing directories gracefully
|
||||
it('should not throw errors when directory does not exist', async () => {
|
||||
await expect(
|
||||
cleanupOldClipboardImages('/path/that/does/not/exist'),
|
||||
).resolves.not.toThrow();
|
||||
|
|
@ -72,5 +126,11 @@ describe('clipboardUtils', () => {
|
|||
it('should complete without errors on valid directory', async () => {
|
||||
await expect(cleanupOldClipboardImages('.')).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should use clipboard directory consistently with saveClipboardImage', () => {
|
||||
// This test verifies that both functions use the same directory structure
|
||||
// The implementation uses 'clipboard' subdirectory for both functions
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,116 +6,86 @@
|
|||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { createDebugLogger, execCommand } from '@qwen-code/qwen-code-core';
|
||||
|
||||
const MACOS_CLIPBOARD_TIMEOUT_MS = 1500;
|
||||
import { createDebugLogger } from '@qwen-code/qwen-code-core';
|
||||
|
||||
const debugLogger = createDebugLogger('CLIPBOARD_UTILS');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type ClipboardModule = any;
|
||||
|
||||
let cachedClipboardModule: ClipboardModule | null = null;
|
||||
let clipboardLoadAttempted = false;
|
||||
|
||||
async function getClipboardModule(): Promise<ClipboardModule | null> {
|
||||
if (clipboardLoadAttempted) return cachedClipboardModule;
|
||||
clipboardLoadAttempted = true;
|
||||
|
||||
try {
|
||||
const modName = '@teddyzhu/clipboard';
|
||||
cachedClipboardModule = await import(modName);
|
||||
return cachedClipboardModule;
|
||||
} catch (_e) {
|
||||
debugLogger.error(
|
||||
'Failed to load @teddyzhu/clipboard native module. Clipboard image features will be unavailable.',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the system clipboard contains an image (macOS only for now)
|
||||
* Checks if the system clipboard contains an image
|
||||
* @returns true if clipboard contains an image
|
||||
*/
|
||||
export async function clipboardHasImage(): Promise<boolean> {
|
||||
if (process.platform !== 'darwin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Use osascript to check clipboard type
|
||||
const { stdout } = await execCommand(
|
||||
'osascript',
|
||||
['-e', 'clipboard info'],
|
||||
{
|
||||
timeout: MACOS_CLIPBOARD_TIMEOUT_MS,
|
||||
},
|
||||
);
|
||||
const imageRegex =
|
||||
/«class PNGf»|TIFF picture|JPEG picture|GIF picture|«class JPEG»|«class TIFF»/;
|
||||
return imageRegex.test(stdout);
|
||||
} catch {
|
||||
const mod = await getClipboardModule();
|
||||
if (!mod) return false;
|
||||
const clipboard = new mod.ClipboardManager();
|
||||
return clipboard.hasFormat('image');
|
||||
} catch (error) {
|
||||
debugLogger.error('Error checking clipboard for image:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the image from clipboard to a temporary file (macOS only for now)
|
||||
* Saves the image from clipboard to a temporary file
|
||||
* @param targetDir The target directory to create temp files within
|
||||
* @returns The path to the saved image file, or null if no image or error
|
||||
*/
|
||||
export async function saveClipboardImage(
|
||||
targetDir?: string,
|
||||
): Promise<string | null> {
|
||||
if (process.platform !== 'darwin') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const mod = await getClipboardModule();
|
||||
if (!mod) return null;
|
||||
const clipboard = new mod.ClipboardManager();
|
||||
|
||||
if (!clipboard.hasFormat('image')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create a temporary directory for clipboard images within the target directory
|
||||
// This avoids security restrictions on paths outside the target directory
|
||||
const baseDir = targetDir || process.cwd();
|
||||
const tempDir = path.join(baseDir, '.qwen-clipboard');
|
||||
const tempDir = path.join(baseDir, 'clipboard');
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
|
||||
// Generate a unique filename with timestamp
|
||||
const timestamp = new Date().getTime();
|
||||
const tempFilePath = path.join(tempDir, `clipboard-${timestamp}.png`);
|
||||
|
||||
// Try different image formats in order of preference
|
||||
const formats = [
|
||||
{ class: 'PNGf', extension: 'png' },
|
||||
{ class: 'JPEG', extension: 'jpg' },
|
||||
{ class: 'TIFF', extension: 'tiff' },
|
||||
{ class: 'GIFf', extension: 'gif' },
|
||||
];
|
||||
const imageData = clipboard.getImageData();
|
||||
// Use data buffer from the API
|
||||
const buffer = imageData.data;
|
||||
|
||||
for (const format of formats) {
|
||||
const tempFilePath = path.join(
|
||||
tempDir,
|
||||
`clipboard-${timestamp}.${format.extension}`,
|
||||
);
|
||||
|
||||
// Try to save clipboard as this format
|
||||
const script = `
|
||||
try
|
||||
set imageData to the clipboard as «class ${format.class}»
|
||||
set fileRef to open for access POSIX file "${tempFilePath}" with write permission
|
||||
write imageData to fileRef
|
||||
close access fileRef
|
||||
return "success"
|
||||
on error errMsg
|
||||
try
|
||||
close access POSIX file "${tempFilePath}"
|
||||
end try
|
||||
return "error"
|
||||
end try
|
||||
`;
|
||||
|
||||
const { stdout } = await execCommand('osascript', ['-e', script], {
|
||||
timeout: MACOS_CLIPBOARD_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
if (stdout.trim() === 'success') {
|
||||
// Verify the file was created and has content
|
||||
try {
|
||||
const stats = await fs.stat(tempFilePath);
|
||||
if (stats.size > 0) {
|
||||
return tempFilePath;
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist, continue to next format
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up failed attempt
|
||||
try {
|
||||
await fs.unlink(tempFilePath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
if (!buffer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// No format worked
|
||||
return null;
|
||||
await fs.writeFile(tempFilePath, buffer);
|
||||
|
||||
return tempFilePath;
|
||||
} catch (error) {
|
||||
debugLogger.error('Error saving clipboard image:', error);
|
||||
return null;
|
||||
|
|
@ -123,8 +93,8 @@ export async function saveClipboardImage(
|
|||
}
|
||||
|
||||
/**
|
||||
* Cleans up old temporary clipboard image files
|
||||
* Removes files older than 1 hour
|
||||
* Cleans up old temporary clipboard image files using LRU strategy
|
||||
* Keeps maximum 100 images, when exceeding removes 50 oldest files to reduce cleanup frequency
|
||||
* @param targetDir The target directory where temp files are stored
|
||||
*/
|
||||
export async function cleanupOldClipboardImages(
|
||||
|
|
@ -132,23 +102,49 @@ export async function cleanupOldClipboardImages(
|
|||
): Promise<void> {
|
||||
try {
|
||||
const baseDir = targetDir || process.cwd();
|
||||
const tempDir = path.join(baseDir, '.qwen-clipboard');
|
||||
const tempDir = path.join(baseDir, 'clipboard');
|
||||
const files = await fs.readdir(tempDir);
|
||||
const oneHourAgo = Date.now() - 60 * 60 * 1000;
|
||||
const MAX_IMAGES = 100;
|
||||
const CLEANUP_COUNT = 50;
|
||||
|
||||
// Filter clipboard image files and get their stats
|
||||
const imageFiles: Array<{ name: string; path: string; atime: number }> = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (
|
||||
file.startsWith('clipboard-') &&
|
||||
(file.endsWith('.png') ||
|
||||
file.endsWith('.jpg') ||
|
||||
file.endsWith('.webp') ||
|
||||
file.endsWith('.heic') ||
|
||||
file.endsWith('.heif') ||
|
||||
file.endsWith('.tiff') ||
|
||||
file.endsWith('.gif'))
|
||||
file.endsWith('.gif') ||
|
||||
file.endsWith('.bmp'))
|
||||
) {
|
||||
const filePath = path.join(tempDir, file);
|
||||
const stats = await fs.stat(filePath);
|
||||
if (stats.mtimeMs < oneHourAgo) {
|
||||
await fs.unlink(filePath);
|
||||
}
|
||||
imageFiles.push({
|
||||
name: file,
|
||||
path: filePath,
|
||||
atime: stats.atimeMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If exceeds limit, remove CLEANUP_COUNT oldest files to reduce cleanup frequency
|
||||
if (imageFiles.length > MAX_IMAGES) {
|
||||
// Sort by access time (oldest first)
|
||||
imageFiles.sort((a, b) => a.atime - b.atime);
|
||||
|
||||
// Remove CLEANUP_COUNT oldest files (or all excess files if less than CLEANUP_COUNT)
|
||||
const removeCount = Math.min(
|
||||
CLEANUP_COUNT,
|
||||
imageFiles.length - MAX_IMAGES + CLEANUP_COUNT,
|
||||
);
|
||||
const filesToRemove = imageFiles.slice(0, removeCount);
|
||||
for (const file of filesToRemove) {
|
||||
await fs.unlink(file.path);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -337,7 +337,7 @@ export async function start_sandbox(
|
|||
|
||||
writeStderrLine(`hopping into sandbox (command: ${config.command}) ...`);
|
||||
|
||||
// determine full path for gemini-cli to distinguish linked vs installed setting
|
||||
// determine full path for qwen-code to distinguish linked vs installed setting
|
||||
const gcPath = fs.realpathSync(process.argv[1]);
|
||||
|
||||
const projectSandboxDockerfile = path.join(
|
||||
|
|
@ -350,9 +350,9 @@ export async function start_sandbox(
|
|||
const workdir = path.resolve(process.cwd());
|
||||
const containerWorkdir = getContainerPath(workdir);
|
||||
|
||||
// if BUILD_SANDBOX is set, then call scripts/build_sandbox.js under gemini-cli repo
|
||||
// if BUILD_SANDBOX is set, then call scripts/build_sandbox.js under qwen-code repo
|
||||
//
|
||||
// note this can only be done with binary linked from gemini-cli repo
|
||||
// note this can only be done with binary linked from qwen-code repo
|
||||
if (process.env['BUILD_SANDBOX']) {
|
||||
if (!gcPath.includes('qwen-code/packages/')) {
|
||||
throw new FatalSandboxError(
|
||||
|
|
@ -389,8 +389,8 @@ export async function start_sandbox(
|
|||
if (!(await ensureSandboxImageIsPresent(config.command, image))) {
|
||||
const remedy =
|
||||
image === LOCAL_DEV_SANDBOX_IMAGE_NAME
|
||||
? 'Try running `npm run build:all` or `npm run build:sandbox` under the gemini-cli repo to build it locally, or check the image name and your network connection.'
|
||||
: 'Please check the image name, your network connection, or notify gemini-cli-dev@google.com if the issue persists.';
|
||||
? 'Try running `npm run build:all` or `npm run build:sandbox` under the qwen-code repo to build it locally, or check the image name and your network connection.'
|
||||
: 'Please check the image name, your network connection, or notify qwen-code-dev@service.alibaba.com if the issue persists.';
|
||||
throw new FatalSandboxError(
|
||||
`Sandbox image '${image}' is missing or could not be pulled. ${remedy}`,
|
||||
);
|
||||
|
|
@ -544,7 +544,7 @@ export async function start_sandbox(
|
|||
process.env['GEMINI_CLI_INTEGRATION_TEST'] === 'true';
|
||||
let containerName;
|
||||
if (isIntegrationTest) {
|
||||
containerName = `gemini-cli-integration-test-${randomBytes(4).toString(
|
||||
containerName = `qwen-code-integration-test-${randomBytes(4).toString(
|
||||
'hex',
|
||||
)}`;
|
||||
writeStderrLine(`ContainerName: ${containerName}`);
|
||||
|
|
@ -716,10 +716,16 @@ export async function start_sandbox(
|
|||
let userFlag = '';
|
||||
const finalEntrypoint = entrypoint(workdir, cliArgs);
|
||||
|
||||
if (process.env['GEMINI_CLI_INTEGRATION_TEST'] === 'true') {
|
||||
// Check if we should use current user's UID/GID in sandbox
|
||||
// In integration test mode, we still respect SANDBOX_SET_UID_GID to allow
|
||||
// tests that need to access host's ~/.qwen (e.g., --resume functionality)
|
||||
const useCurrentUser = await shouldUseCurrentUserInSandbox();
|
||||
|
||||
if (!useCurrentUser) {
|
||||
// Use root user (default for integration tests or when explicitly disabled)
|
||||
args.push('--user', 'root');
|
||||
userFlag = '--user root';
|
||||
} else if (await shouldUseCurrentUserInSandbox()) {
|
||||
} else {
|
||||
// For the user-creation logic to work, the container must start as root.
|
||||
// The entrypoint script then handles dropping privileges to the correct user.
|
||||
args.push('--user', 'root');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue