Merge remote-tracking branch 'origin/main' into feat/remove-smart-edit-tool

# Conflicts:
#	packages/core/index.ts
#	packages/core/src/index.ts
This commit is contained in:
tanzhenxin 2026-02-05 18:08:22 +08:00
commit 6324863008
313 changed files with 18224 additions and 1327 deletions

View file

@ -12,10 +12,98 @@ import { ACP_ERROR_CODES } from '../errorCodes.js';
const createFallback = (): FileSystemService => ({
readTextFile: vi.fn(),
writeTextFile: vi.fn(),
detectFileBOM: vi.fn().mockResolvedValue(false),
findFiles: vi.fn().mockReturnValue([]),
});
describe('AcpFileSystemService', () => {
describe('detectFileBOM', () => {
it('detects BOM through ACP client when content starts with U+FEFF', async () => {
const client = {
readTextFile: vi
.fn()
.mockResolvedValue({ content: '\ufeff// BOM file' }),
} as unknown as import('../acp.js').Client;
const svc = new AcpFileSystemService(
client,
'session-1',
{ readTextFile: true, writeTextFile: true },
createFallback(),
);
const result = await svc.detectFileBOM('/test/file.txt');
expect(result).toBe(true);
expect(client.readTextFile).toHaveBeenCalledWith({
path: '/test/file.txt',
sessionId: 'session-1',
line: null,
limit: 1,
});
});
it('detects no BOM through ACP client when content does not start with U+FEFF', async () => {
const client = {
readTextFile: vi.fn().mockResolvedValue({ content: '// No BOM file' }),
} as unknown as import('../acp.js').Client;
const svc = new AcpFileSystemService(
client,
'session-2',
{ readTextFile: true, writeTextFile: true },
createFallback(),
);
const result = await svc.detectFileBOM('/test/file.txt');
expect(result).toBe(false);
});
it('falls back to local filesystem when ACP client fails', async () => {
const client = {
readTextFile: vi.fn().mockRejectedValue(new Error('Network error')),
} as unknown as import('../acp.js').Client;
const fallback = createFallback();
(fallback.detectFileBOM as ReturnType<typeof vi.fn>).mockResolvedValue(
true,
);
const svc = new AcpFileSystemService(
client,
'session-3',
{ readTextFile: true, writeTextFile: true },
fallback,
);
const result = await svc.detectFileBOM('/test/file.txt');
expect(result).toBe(true);
expect(fallback.detectFileBOM).toHaveBeenCalledWith('/test/file.txt');
});
it('falls back to local filesystem when readTextFile capability is disabled', async () => {
const client = {
readTextFile: vi.fn(),
} as unknown as import('../acp.js').Client;
const fallback = createFallback();
(fallback.detectFileBOM as ReturnType<typeof vi.fn>).mockResolvedValue(
false,
);
const svc = new AcpFileSystemService(
client,
'session-4',
{ readTextFile: false, writeTextFile: true },
fallback,
);
const result = await svc.detectFileBOM('/test/file.txt');
expect(result).toBe(false);
expect(fallback.detectFileBOM).toHaveBeenCalledWith('/test/file.txt');
expect(client.readTextFile).not.toHaveBeenCalled();
});
});
describe('readTextFile ENOENT handling', () => {
it('converts RESOURCE_NOT_FOUND error to ENOENT', async () => {
const resourceNotFoundError = {

View file

@ -54,17 +54,45 @@ export class AcpFileSystemService implements FileSystemService {
return response.content;
}
async writeTextFile(filePath: string, content: string): Promise<void> {
async writeTextFile(
filePath: string,
content: string,
options?: { bom?: boolean },
): Promise<void> {
if (!this.capabilities.writeTextFile) {
return this.fallback.writeTextFile(filePath, content);
return this.fallback.writeTextFile(filePath, content, options);
}
// Prepend BOM character if requested
const finalContent = options?.bom ? '\uFEFF' + content : content;
await this.client.writeTextFile({
path: filePath,
content,
content: finalContent,
sessionId: this.sessionId,
});
}
async detectFileBOM(filePath: string): Promise<boolean> {
// Try to detect BOM through ACP client first by reading first line
if (this.capabilities.readTextFile) {
try {
const response = await this.client.readTextFile({
path: filePath,
sessionId: this.sessionId,
line: null,
limit: 1,
});
// Check if content starts with BOM character (U+FEFF)
return response.content.charCodeAt(0) === 0xfeff;
} catch {
// Fall through to fallback if ACP read fails
}
}
// Fall back to local filesystem detection
return this.fallback.detectFileBOM(filePath);
}
findFiles(fileName: string, searchPaths: readonly string[]): string[] {
return this.fallback.findFiles(fileName, searchPaths);
}

View file

@ -35,6 +35,7 @@ describe('extensionConsentString', () => {
const config: ExtensionConfig = {
name: 'test-extension',
version: '1.0.0',
commands: [],
};
const result = extensionConsentString(config);
@ -209,6 +210,7 @@ describe('requestConsentOrFail', () => {
await requestConsentOrFail(mockRequestConsent, {
extensionConfig: { name: 'test-extension', version: '1.0.0' },
originSource: 'QwenCode',
});
expect(mockRequestConsent).toHaveBeenCalled();
@ -220,6 +222,7 @@ describe('requestConsentOrFail', () => {
await expect(
requestConsentOrFail(mockRequestConsent, {
extensionConfig: { name: 'test-extension', version: '1.0.0' },
originSource: 'QwenCode',
}),
).rejects.toThrow('Installation cancelled for "test-extension".');
});
@ -233,6 +236,7 @@ describe('requestConsentOrFail', () => {
await requestConsentOrFail(mockRequestConsent, {
extensionConfig,
previousExtensionConfig: extensionConfig,
originSource: 'QwenCode',
});
expect(mockRequestConsent).not.toHaveBeenCalled();
@ -246,6 +250,7 @@ describe('requestConsentOrFail', () => {
commands: ['command1'],
previousExtensionConfig: { name: 'test-extension', version: '1.0.0' },
previousCommands: [],
originSource: 'QwenCode',
});
expect(mockRequestConsent).toHaveBeenCalled();

View file

@ -148,8 +148,17 @@ export function extensionConsentString(
commands: string[] = [],
skills: SkillConfig[] = [],
subagents: SubagentConfig[] = [],
originSource: string = 'QwenCode',
): string {
const output: string[] = [];
if (originSource !== 'QwenCode') {
output.push(
t(
'You are installing an extension from {{originSource}}. Some features may not work perfectly with Qwen Code.',
{ originSource },
),
);
}
const mcpServerEntries = Object.entries(extensionConfig.mcpServers || {});
output.push(
t('Installing extension "{{name}}".', { name: extensionConfig.name }),
@ -221,6 +230,7 @@ export const requestConsentOrFail = async (
if (!options) return;
const {
extensionConfig,
originSource = 'QwenCode',
commands = [],
skills = [],
subagents = [],
@ -234,6 +244,7 @@ export const requestConsentOrFail = async (
commands,
skills,
subagents,
originSource,
);
if (previousExtensionConfig) {
const previousExtensionConsent = extensionConsentString(
@ -241,6 +252,7 @@ export const requestConsentOrFail = async (
previousCommands,
previousSkills,
previousSubagents,
originSource,
);
if (previousExtensionConsent === extensionConsent) {
return;

View file

@ -2045,13 +2045,13 @@ describe('loadCliConfig fileFiltering', () => {
value: boolean;
}> = [
{
property: 'disableFuzzySearch',
getter: (c) => c.getFileFilteringDisableFuzzySearch(),
property: 'enableFuzzySearch',
getter: (c) => c.getFileFilteringEnableFuzzySearch(),
value: true,
},
{
property: 'disableFuzzySearch',
getter: (c) => c.getFileFilteringDisableFuzzySearch(),
property: 'enableFuzzySearch',
getter: (c) => c.getFileFilteringEnableFuzzySearch(),
value: false,
},
{

View file

@ -10,6 +10,7 @@ import {
Config,
DEFAULT_QWEN_EMBEDDING_MODEL,
FileDiscoveryService,
FileEncoding,
getCurrentGeminiMdFilename,
loadServerHierarchicalMemory,
setGeminiMdFilename as setServerGeminiMdFilename,
@ -1028,6 +1029,8 @@ export async function loadCliConfig(
// always be true and the settings file can never disable recording.
chatRecording:
argv.chatRecording ?? settings.general?.chatRecording ?? true,
defaultFileEncoding:
settings.general?.defaultFileEncoding ?? FileEncoding.UTF8,
lsp: {
enabled: lspEnabled,
},

View file

@ -643,6 +643,105 @@ describe('Settings Loading and Merging', () => {
expect(writtenContent[SETTINGS_VERSION_KEY]).toBe(SETTINGS_VERSION);
});
it('should consolidate disableAutoUpdate and disableUpdateNag - both false means enableAutoUpdate is true', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
// V1 settings with both disable* settings as false
const legacySettingsContent = {
disableAutoUpdate: false,
disableUpdateNag: false,
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(legacySettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
// Both are false, so enableAutoUpdate should be true
expect(settings.merged.general?.enableAutoUpdate).toBe(true);
});
it('should consolidate disableAutoUpdate and disableUpdateNag - any true means enableAutoUpdate is false', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
// V1 settings with disableAutoUpdate=false but disableUpdateNag=true
const legacySettingsContent = {
disableAutoUpdate: false,
disableUpdateNag: true,
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(legacySettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
// disableUpdateNag is true, so enableAutoUpdate should be false
expect(settings.merged.general?.enableAutoUpdate).toBe(false);
});
it('should consolidate disableAutoUpdate and disableUpdateNag - disableAutoUpdate=true takes precedence', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
// V1 settings with disableAutoUpdate=true
const legacySettingsContent = {
disableAutoUpdate: true,
disableUpdateNag: false,
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(legacySettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
// disableAutoUpdate is true, so enableAutoUpdate should be false
expect(settings.merged.general?.enableAutoUpdate).toBe(false);
});
it('should bump version to 3 even when V2 settings already have V3-compatible content', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
// V2 settings that already have V3-compatible keys (no migration needed)
const v2SettingsWithV3Content = {
$version: 2,
general: {
enableAutoUpdate: true,
},
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(v2SettingsWithV3Content);
return '{}';
},
);
loadSettings(MOCK_WORKSPACE_DIR);
// Version should be bumped to 3 even though no keys needed migration
const writeCall = (fs.writeFileSync as Mock).mock.calls.find(
(call: unknown[]) => call[0] === USER_SETTINGS_PATH,
);
expect(writeCall).toBeDefined();
const writtenContent = JSON.parse(writeCall[1] as string);
expect(writtenContent.$version).toBe(SETTINGS_VERSION);
});
it('should correctly merge and migrate legacy array properties from multiple scopes', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const legacyUserSettings = {

View file

@ -56,7 +56,7 @@ export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE'];
const MIGRATE_V2_OVERWRITE = true;
// Settings version to track migration state
export const SETTINGS_VERSION = 2;
export const SETTINGS_VERSION = 3;
export const SETTINGS_VERSION_KEY = '$version';
const MIGRATION_MAP: Record<string, string> = {
@ -73,8 +73,6 @@ const MIGRATION_MAP: Record<string, string> = {
customThemes: 'ui.customThemes',
customWittyPhrases: 'ui.customWittyPhrases',
debugKeystrokeLogging: 'general.debugKeystrokeLogging',
disableAutoUpdate: 'general.disableAutoUpdate',
disableUpdateNag: 'general.disableUpdateNag',
dnsResolutionOrder: 'advanced.dnsResolutionOrder',
enforcedAuthType: 'security.auth.enforcedType',
excludeTools: 'tools.exclude',
@ -127,6 +125,43 @@ const MIGRATION_MAP: Record<string, string> = {
visionModelPreview: 'experimental.visionModelPreview',
};
// Settings that need boolean inversion during migration (V1 -> V3)
// Old negative naming -> new positive naming with inverted value
const INVERTED_BOOLEAN_MIGRATIONS: Record<string, string> = {
disableAutoUpdate: 'general.enableAutoUpdate',
disableUpdateNag: 'general.enableAutoUpdate',
disableLoadingPhrases: 'ui.accessibility.enableLoadingPhrases',
disableFuzzySearch: 'context.fileFiltering.enableFuzzySearch',
disableCacheControl: 'model.generationConfig.enableCacheControl',
};
// Consolidated settings: multiple old V1 keys that map to a single new key.
// Policy: if ANY of the old disable* settings is true, the new enable* should be false.
const CONSOLIDATED_SETTINGS: Record<string, string[]> = {
'general.enableAutoUpdate': ['disableAutoUpdate', 'disableUpdateNag'],
};
// V2 nested paths that need inversion when migrating to V3
const INVERTED_V2_PATHS: Record<string, string> = {
'general.disableAutoUpdate': 'general.enableAutoUpdate',
'general.disableUpdateNag': 'general.enableAutoUpdate',
'ui.accessibility.disableLoadingPhrases':
'ui.accessibility.enableLoadingPhrases',
'context.fileFiltering.disableFuzzySearch':
'context.fileFiltering.enableFuzzySearch',
'model.generationConfig.disableCacheControl':
'model.generationConfig.enableCacheControl',
};
// Consolidated V2 paths: multiple old paths that map to a single new path.
// Policy: if ANY of the old disable* settings is true, the new enable* should be false.
const CONSOLIDATED_V2_PATHS: Record<string, string[]> = {
'general.enableAutoUpdate': [
'general.disableAutoUpdate',
'general.disableUpdateNag',
],
};
export function getSystemSettingsPath(): string {
if (process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH']) {
return process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH'];
@ -168,7 +203,7 @@ export interface SummarizeToolOutputSettings {
}
export interface AccessibilitySettings {
disableLoadingPhrases?: boolean;
enableLoadingPhrases?: boolean;
screenReader?: boolean;
}
@ -209,6 +244,14 @@ function setNestedProperty(
current[lastKey] = value;
}
// Dynamically determine the top-level keys from the V2 settings structure.
const KNOWN_V2_CONTAINERS = new Set([
...Object.values(MIGRATION_MAP).map((path) => path.split('.')[0]),
...Object.values(INVERTED_BOOLEAN_MIGRATIONS).map(
(path) => path.split('.')[0],
),
]);
export function needsMigration(settings: Record<string, unknown>): boolean {
// Check version field first - if present and matches current version, no migration needed
if (SETTINGS_VERSION_KEY in settings) {
@ -237,10 +280,20 @@ export function needsMigration(settings: Record<string, unknown>): boolean {
return true;
});
return hasV1Keys;
// Also check for old inverted boolean keys (disable* -> enable*)
const hasInvertedBooleanKeys = Object.keys(INVERTED_BOOLEAN_MIGRATIONS).some(
(v1Key) => v1Key in settings,
);
return hasV1Keys || hasInvertedBooleanKeys;
}
function migrateSettingsToV2(
/**
* Migrates V1 (flat) settings directly to V3.
* This includes both structural migration (flat -> nested) and boolean
* inversion (disable* -> enable*), so migrateV2ToV3 will be skipped.
*/
function migrateV1ToV3(
flatSettings: Record<string, unknown>,
): Record<string, unknown> | null {
if (!needsMigration(flatSettings)) {
@ -272,6 +325,43 @@ function migrateSettingsToV2(
}
}
// Handle consolidated settings first (multiple old keys -> single new key)
// Policy: if ANY of the old disable* settings is true, the new enable* should be false
for (const [newPath, oldKeys] of Object.entries(CONSOLIDATED_SETTINGS)) {
let hasAnyDisable = false;
let hasAnyValue = false;
for (const oldKey of oldKeys) {
if (flatKeys.has(oldKey)) {
hasAnyValue = true;
const oldValue = flatSettings[oldKey];
if (typeof oldValue === 'boolean' && oldValue === true) {
hasAnyDisable = true;
}
flatKeys.delete(oldKey);
}
}
if (hasAnyValue) {
// enableAutoUpdate = !hasAnyDisable (if any disable* was true, enable should be false)
setNestedProperty(v2Settings, newPath, !hasAnyDisable);
}
}
// Handle remaining V1 settings that need boolean inversion (disable* -> enable*)
// Skip keys that were already handled by consolidated settings
const consolidatedKeys = new Set(Object.values(CONSOLIDATED_SETTINGS).flat());
for (const [oldKey, newPath] of Object.entries(INVERTED_BOOLEAN_MIGRATIONS)) {
if (consolidatedKeys.has(oldKey)) {
continue;
}
if (flatKeys.has(oldKey)) {
const oldValue = flatSettings[oldKey];
if (typeof oldValue === 'boolean') {
setNestedProperty(v2Settings, newPath, !oldValue);
}
flatKeys.delete(oldKey);
}
}
// Preserve mcpServers at the top level
if (flatSettings['mcpServers']) {
v2Settings['mcpServers'] = flatSettings['mcpServers'];
@ -310,6 +400,90 @@ function migrateSettingsToV2(
return v2Settings;
}
// Migrate V2 settings to V3 (invert disable* -> enable* booleans)
function migrateV2ToV3(
settings: Record<string, unknown>,
): Record<string, unknown> | null {
const version = settings[SETTINGS_VERSION_KEY];
if (typeof version === 'number' && version >= 3) {
return null;
}
let changed = false;
const result = structuredClone(settings);
const processedPaths = new Set<string>();
// Handle consolidated V2 paths first (multiple old paths -> single new path)
// Policy: if ANY of the old disable* settings is true, the new enable* should be false
for (const [newPath, oldPaths] of Object.entries(CONSOLIDATED_V2_PATHS)) {
let hasAnyDisable = false;
let hasAnyValue = false;
for (const oldPath of oldPaths) {
const oldValue = getNestedProperty(result, oldPath);
if (typeof oldValue === 'boolean') {
hasAnyValue = true;
if (oldValue === true) {
hasAnyDisable = true;
}
deleteNestedProperty(result, oldPath);
processedPaths.add(oldPath);
changed = true;
}
}
if (hasAnyValue) {
// enableAutoUpdate = !hasAnyDisable (if any disable* was true, enable should be false)
setNestedProperty(result, newPath, !hasAnyDisable);
}
}
// Handle remaining V2 paths that need inversion
for (const [oldPath, newPath] of Object.entries(INVERTED_V2_PATHS)) {
if (processedPaths.has(oldPath)) {
continue;
}
const oldValue = getNestedProperty(result, oldPath);
if (typeof oldValue === 'boolean') {
// Remove old property
deleteNestedProperty(result, oldPath);
// Set new property with inverted value
setNestedProperty(result, newPath, !oldValue);
changed = true;
}
}
if (changed) {
result[SETTINGS_VERSION_KEY] = SETTINGS_VERSION;
return result;
}
// Even if no changes, bump version to 3 to skip future migration checks
if (typeof version === 'number' && version < SETTINGS_VERSION) {
result[SETTINGS_VERSION_KEY] = SETTINGS_VERSION;
return result;
}
return null;
}
function deleteNestedProperty(
obj: Record<string, unknown>,
path: string,
): void {
const keys = path.split('.');
const lastKey = keys.pop();
if (!lastKey) return;
let current: Record<string, unknown> = obj;
for (const key of keys) {
const next = current[key];
if (typeof next !== 'object' || next === null) {
return;
}
current = next as Record<string, unknown>;
}
delete current[lastKey];
}
function getNestedProperty(
obj: Record<string, unknown>,
path: string,
@ -329,9 +503,26 @@ const REVERSE_MIGRATION_MAP: Record<string, string> = Object.fromEntries(
Object.entries(MIGRATION_MAP).map(([key, value]) => [value, key]),
);
// Dynamically determine the top-level keys from the V2 settings structure.
const KNOWN_V2_CONTAINERS = new Set(
Object.values(MIGRATION_MAP).map((path) => path.split('.')[0]),
// Reverse map for old V2 paths (before rename) to V1 keys.
// Used when migrating settings that still have old V2 naming (e.g., general.disableAutoUpdate).
const OLD_V2_TO_V1_MAP: Record<string, string> = {};
for (const [oldV2Path, newV3Path] of Object.entries(INVERTED_V2_PATHS)) {
// Find the V1 key that maps to this V3 path
for (const [v1Key, v3Path] of Object.entries(INVERTED_BOOLEAN_MIGRATIONS)) {
if (v3Path === newV3Path) {
OLD_V2_TO_V1_MAP[oldV2Path] = v1Key;
break;
}
}
}
// Reverse map for new V3 paths to V1 keys (with boolean inversion).
// Used when migrating settings that have new V3 naming (e.g., general.enableAutoUpdate).
const V3_TO_V1_INVERTED_MAP: Record<string, string> = Object.fromEntries(
Object.entries(INVERTED_BOOLEAN_MIGRATIONS).map(([v1Key, v3Path]) => [
v3Path,
v1Key,
]),
);
function getSettingsFileKeyWarnings(
@ -370,7 +561,7 @@ function getSettingsFileKeyWarnings(
ignoredLegacyKeys.add(oldKey);
warnings.push(
`⚠️ Legacy setting '${oldKey}' will be ignored in ${settingsFilePath}. Please use '${newPath}' instead.`,
`Warning: Legacy setting '${oldKey}' will be ignored in ${settingsFilePath}. Please use '${newPath}' instead.`,
);
}
@ -388,7 +579,7 @@ function getSettingsFileKeyWarnings(
}
warnings.push(
`⚠️ Unknown setting '${key}' will be ignored in ${settingsFilePath}.`,
`Warning: Unknown setting '${key}' will be ignored in ${settingsFilePath}.`,
);
}
@ -407,7 +598,8 @@ export function getSettingsWarnings(loadedSettings: LoadedSettings): string[] {
for (const scope of [SettingScope.User, SettingScope.Workspace]) {
const settingsFile = loadedSettings.forScope(scope);
if (settingsFile.rawJson === undefined) {
continue; // File not present / not loaded.
continue;
// File not present / not loaded.
}
const settingsObject = settingsFile.originalSettings as unknown as Record<
string,
@ -439,6 +631,26 @@ export function migrateSettingsToV1(
}
}
// Handle old V2 inverted paths (no value inversion needed)
// e.g., general.disableAutoUpdate -> disableAutoUpdate
for (const [oldV2Path, v1Key] of Object.entries(OLD_V2_TO_V1_MAP)) {
const value = getNestedProperty(v2Settings, oldV2Path);
if (value !== undefined) {
v1Settings[v1Key] = value;
v2Keys.delete(oldV2Path.split('.')[0]);
}
}
// Handle new V3 inverted paths (WITH value inversion)
// e.g., general.enableAutoUpdate -> disableAutoUpdate (inverted)
for (const [v3Path, v1Key] of Object.entries(V3_TO_V1_INVERTED_MAP)) {
const value = getNestedProperty(v2Settings, v3Path);
if (value !== undefined && typeof value === 'boolean') {
v1Settings[v1Key] = !value;
v2Keys.delete(v3Path.split('.')[0]);
}
}
// Preserve mcpServers at the top level
if (v2Settings['mcpServers']) {
v1Settings['mcpServers'] = v2Settings['mcpServers'];
@ -736,7 +948,7 @@ export function loadSettings(
let settingsObject = rawSettings as Record<string, unknown>;
if (needsMigration(settingsObject)) {
const migratedSettings = migrateSettingsToV2(settingsObject);
const migratedSettings = migrateV1ToV3(settingsObject);
if (migratedSettings) {
if (MIGRATE_V2_OVERWRITE) {
try {
@ -775,6 +987,33 @@ export function loadSettings(
}
}
}
// V2 to V3 migration (invert disable* -> enable* booleans)
const v3Migrated = migrateV2ToV3(settingsObject);
if (v3Migrated) {
if (MIGRATE_V2_OVERWRITE) {
try {
// Only backup if not already backed up by V1->V2 migration
const backupPath = `${filePath}.orig`;
if (!fs.existsSync(backupPath)) {
fs.renameSync(filePath, backupPath);
}
fs.writeFileSync(
filePath,
JSON.stringify(v3Migrated, null, 2),
'utf-8',
);
} catch (e) {
console.error(
`Error migrating settings file to V3: ${getErrorMessage(e)}`,
);
}
} else {
migratedInMemorScopes.add(scope);
}
settingsObject = v3Migrated;
}
return { settings: settingsObject as Settings, rawJson: content };
}
} catch (error: unknown) {

View file

@ -80,7 +80,7 @@ describe('SettingsSchema', () => {
).toBeDefined();
expect(
getSettingsSchema().ui?.properties?.accessibility.properties
?.disableLoadingPhrases.type,
?.enableLoadingPhrases.type,
).toBe('boolean');
});
@ -164,7 +164,7 @@ describe('SettingsSchema', () => {
true,
);
expect(
getSettingsSchema().general.properties.disableAutoUpdate.showInDialog,
getSettingsSchema().general.properties.enableAutoUpdate.showInDialog,
).toBe(true);
expect(
getSettingsSchema().ui.properties.hideWindowTitle.showInDialog,

View file

@ -143,24 +143,16 @@ const SETTINGS_SCHEMA = {
description: 'Enable Vim keybindings',
showInDialog: true,
},
disableAutoUpdate: {
enableAutoUpdate: {
type: 'boolean',
label: 'Disable Auto Update',
label: 'Enable Auto Update',
category: 'General',
requiresRestart: false,
default: false,
description: 'Disable automatic updates',
default: true,
description:
'Enable automatic update checks and installations on startup.',
showInDialog: true,
},
disableUpdateNag: {
type: 'boolean',
label: 'Disable Update Nag',
category: 'General',
requiresRestart: false,
default: false,
description: 'Disable update notification prompts.',
showInDialog: false,
},
gitCoAuthor: {
type: 'boolean',
label: 'Attribution: commit',
@ -244,6 +236,20 @@ const SETTINGS_SCHEMA = {
'Enable saving chat history to disk. Disabling this will also prevent --continue and --resume from working.',
showInDialog: false,
},
defaultFileEncoding: {
type: 'enum',
label: 'Default File Encoding',
category: 'General',
requiresRestart: false,
default: 'utf-8',
description:
'Default encoding for new files. Use "utf-8" (default) for UTF-8 without BOM, or "utf-8-bom" for UTF-8 with BOM. Only change this if your project specifically requires BOM.',
showInDialog: false,
options: [
{ value: 'utf-8', label: 'UTF-8 (without BOM)' },
{ value: 'utf-8-bom', label: 'UTF-8 with BOM' },
],
},
},
},
output: {
@ -382,14 +388,14 @@ const SETTINGS_SCHEMA = {
description: 'Accessibility settings.',
showInDialog: false,
properties: {
disableLoadingPhrases: {
enableLoadingPhrases: {
type: 'boolean',
label: 'Disable Loading Phrases',
label: 'Enable Loading Phrases',
category: 'UI',
requiresRestart: true,
default: false,
description: 'Disable loading phrases for accessibility',
showInDialog: false,
default: true,
description: 'Enable loading phrases (disable for accessibility)',
showInDialog: true,
},
screenReader: {
type: 'boolean',
@ -609,13 +615,13 @@ const SETTINGS_SCHEMA = {
parentKey: 'generationConfig',
showInDialog: false,
},
disableCacheControl: {
enableCacheControl: {
type: 'boolean',
label: 'Disable Cache Control',
label: 'Enable Cache Control',
category: 'Generation Configuration',
requiresRestart: false,
default: false,
description: 'Disable cache control for DashScope providers.',
default: true,
description: 'Enable cache control for DashScope providers.',
parentKey: 'generationConfig',
showInDialog: false,
},
@ -733,14 +739,14 @@ const SETTINGS_SCHEMA = {
description: 'Enable recursive file search functionality',
showInDialog: false,
},
disableFuzzySearch: {
enableFuzzySearch: {
type: 'boolean',
label: 'Disable Fuzzy Search',
label: 'Enable Fuzzy Search',
category: 'Context',
requiresRestart: true,
default: false,
description: 'Disable fuzzy search when searching for files.',
showInDialog: false,
default: true,
description: 'Enable fuzzy search when searching for files.',
showInDialog: true,
},
},
},

View file

@ -642,9 +642,20 @@ describe('startInteractiveUI', () => {
expect(checkForUpdates).toHaveBeenCalledTimes(1);
});
it('should not check for updates when update nag is disabled', async () => {
it('should not call checkForUpdates when enableAutoUpdate is false', async () => {
const { checkForUpdates } = await import('./ui/utils/updateCheck.js');
const settingsWithAutoUpdateDisabled = {
merged: {
general: {
enableAutoUpdate: false,
},
ui: {
hideWindowTitle: false,
},
},
} as LoadedSettings;
const mockInitializationResult = {
authError: null,
themeError: null,
@ -652,26 +663,17 @@ describe('startInteractiveUI', () => {
geminiMdFileCount: 0,
};
const settingsWithUpdateNagDisabled = {
merged: {
general: {
disableUpdateNag: true,
},
ui: {
hideWindowTitle: false,
},
},
} as LoadedSettings;
await startInteractiveUI(
mockConfig,
settingsWithUpdateNagDisabled,
settingsWithAutoUpdateDisabled,
mockStartupWarnings,
mockWorkspaceRoot,
mockInitializationResult,
);
await new Promise((resolve) => setTimeout(resolve, 0));
// checkForUpdates should NOT be called when enableAutoUpdate is false
expect(checkForUpdates).not.toHaveBeenCalled();
});
});

View file

@ -182,7 +182,9 @@ export async function startInteractiveUI(
},
);
if (!settings.merged.general?.disableUpdateNag) {
// Check for updates only if enableAutoUpdate is not explicitly disabled.
// Using !== false ensures updates are enabled by default when undefined.
if (settings.merged.general?.enableAutoUpdate !== false) {
checkForUpdates()
.then((info) => {
handleAutoUpdate(info, settings, config.getProjectRoot());
@ -339,6 +341,9 @@ export async function main() {
process.cwd(),
argv.extensions,
);
// Register cleanup for MCP clients as early as possible
// This ensures MCP server subprocesses are properly terminated on exit
registerCleanup(() => config.shutdown());
// FIXME: list extensions after the config initialize

View file

@ -23,6 +23,7 @@ export default {
'auto-accept edits': 'Änderungen automatisch akzeptieren',
'Accepting edits': 'Änderungen werden akzeptiert',
'(shift + tab to cycle)': '(Umschalt + Tab zum Wechseln)',
'(tab to cycle)': '(Tab zum Wechseln)',
'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).':
'Shell-Befehle über {{symbol}} ausführen (z.B. {{example1}}) oder natürliche Sprache verwenden (z.B. {{example2}}).',
'!': '!',
@ -424,6 +425,8 @@ export default {
'Diese Erweiterung wird folgende Unteragenten installieren:',
'Installation cancelled for "{{name}}".':
'Installation von "{{name}}" abgebrochen.',
'You are installing an extension from {{originSource}}. Some features may not work perfectly with Qwen Code.':
'Sie installieren eine Erweiterung von {{originSource}}. Einige Funktionen funktionieren möglicherweise nicht perfekt mit Qwen Code.',
'--ref and --auto-update are not applicable for marketplace extensions.':
'--ref und --auto-update sind nicht anwendbar für Marketplace-Erweiterungen.',
'Extension "{{name}}" installed successfully and enabled.':
@ -1368,4 +1371,8 @@ export default {
'Erweiterungsseite wird im Browser geöffnet: {{url}}',
'Failed to open browser. Check out the extensions gallery at {{url}}':
'Browser konnte nicht geöffnet werden. Besuchen Sie die Erweiterungsgalerie unter {{url}}',
'You can switch permission mode quickly with Shift+Tab or /approval-mode.':
'Sie können den Berechtigungsmodus schnell mit Shift+Tab oder /approval-mode wechseln.',
'You can switch permission mode quickly with Tab or /approval-mode.':
'Sie können den Berechtigungsmodus schnell mit Tab oder /approval-mode wechseln.',
};

View file

@ -23,6 +23,7 @@ export default {
'auto-accept edits': 'auto-accept edits',
'Accepting edits': 'Accepting edits',
'(shift + tab to cycle)': '(shift + tab to cycle)',
'(tab to cycle)': '(tab to cycle)',
'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).':
'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).',
'!': '!',
@ -438,6 +439,8 @@ export default {
'This extension will install the following subagents:',
'Installation cancelled for "{{name}}".':
'Installation cancelled for "{{name}}".',
'You are installing an extension from {{originSource}}. Some features may not work perfectly with Qwen Code.':
'You are installing an extension from {{originSource}}. Some features may not work perfectly with Qwen Code.',
'--ref and --auto-update are not applicable for marketplace extensions.':
'--ref and --auto-update are not applicable for marketplace extensions.',
'Extension "{{name}}" installed successfully and enabled.':
@ -1101,6 +1104,8 @@ export default {
'You can resume a previous conversation by running qwen --continue or qwen --resume.',
'You can switch permission mode quickly with Shift+Tab or /approval-mode.':
'You can switch permission mode quickly with Shift+Tab or /approval-mode.',
'You can switch permission mode quickly with Tab or /approval-mode.':
'You can switch permission mode quickly with Tab or /approval-mode.',
// ============================================================================
// Exit Screen / Stats

View file

@ -326,6 +326,8 @@ export default {
'List active extensions': '有効な拡張機能を一覧表示',
'Update extensions. Usage: update <extension-names>|--all':
'拡張機能を更新。使い方: update <拡張機能名>|--all',
'You are installing an extension from {{originSource}}. Some features may not work perfectly with Qwen Code.':
'{{originSource}} から拡張機能をインストールしています。一部の機能は Qwen Code で完全に動作しない可能性があります。',
'manage IDE integration': 'IDE連携を管理',
'check status of IDE integration': 'IDE連携の状態を確認',
'install required IDE companion for {{ideName}}':

View file

@ -454,6 +454,8 @@ export default {
'Esta extensão instalará os seguintes subagentes:',
'Installation cancelled for "{{name}}".':
'Instalação cancelada para "{{name}}".',
'You are installing an extension from {{originSource}}. Some features may not work perfectly with Qwen Code.':
'Você está instalando uma extensão de {{originSource}}. Alguns recursos podem não funcionar perfeitamente com o Qwen Code.',
'--ref and --auto-update are not applicable for marketplace extensions.':
'--ref e --auto-update não são aplicáveis para extensões de marketplace.',
'Extension "{{name}}" installed successfully and enabled.':

View file

@ -23,6 +23,7 @@ export default {
'auto-accept edits': 'Режим принятия правок',
'Accepting edits': 'Принятие правок',
'(shift + tab to cycle)': '(shift + tab для переключения)',
'(tab to cycle)': '(Tab для переключения)',
'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).':
'Выполняйте команды терминала через {{symbol}} (например, {{example1}}) или используйте естественный язык (например, {{example2}}).',
'!': '!',
@ -443,6 +444,8 @@ export default {
'This extension will install the following subagents:':
'Это расширение установит следующие подагенты:',
'Installation cancelled for "{{name}}".': 'Установка "{{name}}" отменена.',
'You are installing an extension from {{originSource}}. Some features may not work perfectly with Qwen Code.':
'Вы устанавливаете расширение от {{originSource}}. Некоторые функции могут работать не идеально с Qwen Code.',
'--ref and --auto-update are not applicable for marketplace extensions.':
'--ref и --auto-update неприменимы для расширений из маркетплейса.',
'Extension "{{name}}" installed successfully and enabled.':
@ -1372,4 +1375,8 @@ export default {
'Открываем страницу расширений в браузере: {{url}}',
'Failed to open browser. Check out the extensions gallery at {{url}}':
'Не удалось открыть браузер. Посетите галерею расширений по адресу {{url}}',
'You can switch permission mode quickly with Shift+Tab or /approval-mode.':
'Вы можете быстро переключать режим разрешений с помощью Shift+Tab или /approval-mode.',
'You can switch permission mode quickly with Tab or /approval-mode.':
'Вы можете быстро переключать режим разрешений с помощью Tab или /approval-mode.',
};

View file

@ -22,6 +22,7 @@ export default {
'auto-accept edits': '自动接受编辑',
'Accepting edits': '接受编辑',
'(shift + tab to cycle)': '(shift + tab 切换)',
'(tab to cycle)': '(按 tab 切换)',
'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).':
'通过 {{symbol}} 执行 shell 命令(例如,{{example1}})或使用自然语言(例如,{{example2}}',
'!': '!',
@ -144,15 +145,15 @@ export default {
// Commands - Agents
// ============================================================================
'Manage subagents for specialized task delegation.':
'管理用于专门任务委派的子代理',
'管理用于专门任务委派的子智能体',
'Manage existing subagents (view, edit, delete).':
'管理现有子代理(查看、编辑、删除)',
'Create a new subagent with guided setup.': '通过引导式设置创建新的子代理',
'管理现有子智能体(查看、编辑、删除)',
'Create a new subagent with guided setup.': '通过引导式设置创建新的子智能体',
// ============================================================================
// Agents - Management Dialog
// ============================================================================
Agents: '代理',
Agents: '智能体',
'Choose Action': '选择操作',
'Edit {{name}}': '编辑 {{name}}',
'Edit Tools: {{name}}': '编辑工具: {{name}}',
@ -167,21 +168,21 @@ export default {
'Enter to select, ↑↓ to navigate, Esc to go back':
'Enter 选择,↑↓ 导航Esc 返回',
'Invalid step: {{step}}': '无效步骤: {{step}}',
'No subagents found.': '未找到子代理。',
'No subagents found.': '未找到子智能体。',
"Use '/agents create' to create your first subagent.":
"使用 '/agents create' 创建您的第一个子代理。",
"使用 '/agents create' 创建您的第一个子智能体。",
'(built-in)': '(内置)',
'(overridden by project level agent)': '(已被项目级代理覆盖)',
'(overridden by project level agent)': '(已被项目级智能体覆盖)',
'Project Level ({{path}})': '项目级 ({{path}})',
'User Level ({{path}})': '用户级 ({{path}})',
'Built-in Agents': '内置代理',
'Extension Agents': '扩展代理',
'Using: {{count}} agents': '使用中: {{count}} 个代理',
'View Agent': '查看代理',
'Edit Agent': '编辑代理',
'Delete Agent': '删除代理',
'Built-in Agents': '内置智能体',
'Extension Agents': '扩展智能体',
'Using: {{count}} agents': '使用中: {{count}} 个智能体',
'View Agent': '查看智能体',
'Edit Agent': '编辑智能体',
'Delete Agent': '删除智能体',
Back: '返回',
'No agent selected': '未选择代理',
'No agent selected': '未选择智能体',
'File Path: ': '文件路径: ',
'Tools: ': '工具: ',
'Color: ': '颜色: ',
@ -192,25 +193,25 @@ export default {
'Edit color': '编辑颜色',
'❌ Error:': '❌ 错误:',
'Are you sure you want to delete agent "{{name}}"?':
'您确定要删除代理 "{{name}}" 吗?',
'您确定要删除智能体 "{{name}}" 吗?',
// ============================================================================
// Agents - Creation Wizard
// ============================================================================
'Project Level (.qwen/agents/)': '项目级 (.qwen/agents/)',
'User Level (~/.qwen/agents/)': '用户级 (~/.qwen/agents/)',
'✅ Subagent Created Successfully!': '✅ 子代理创建成功!',
'✅ Subagent Created Successfully!': '✅ 子智能体创建成功!',
'Subagent "{{name}}" has been saved to {{level}} level.':
'子代理 "{{name}}" 已保存到 {{level}} 级别。',
'子智能体 "{{name}}" 已保存到 {{level}} 级别。',
'Name: ': '名称: ',
'Location: ': '位置: ',
'❌ Error saving subagent:': '❌ 保存子代理时出错:',
'❌ Error saving subagent:': '❌ 保存子智能体时出错:',
'Warnings:': '警告:',
'Name "{{name}}" already exists at {{level}} level - will overwrite existing subagent':
'名称 "{{name}}" 在 {{level}} 级别已存在 - 将覆盖现有子代理',
'名称 "{{name}}" 在 {{level}} 级别已存在 - 将覆盖现有子智能体',
'Name "{{name}}" exists at user level - project level will take precedence':
'名称 "{{name}}" 在用户级别存在 - 项目级别将优先',
'Name "{{name}}" exists at project level - existing subagent will take precedence':
'名称 "{{name}}" 在项目级别存在 - 现有子代理将优先',
'名称 "{{name}}" 在项目级别存在 - 现有子智能体将优先',
'Description is over {{length}} characters': '描述超过 {{length}} 个字符',
'System prompt is over {{length}} characters':
'系统提示超过 {{length}} 个字符',
@ -220,13 +221,13 @@ export default {
'Generate with Qwen Code (Recommended)': '使用 Qwen Code 生成(推荐)',
'Manual Creation': '手动创建',
'Describe what this subagent should do and when it should be used. (Be comprehensive for best results)':
'描述此子代理应该做什么以及何时使用它。(为了获得最佳效果,请全面描述)',
'描述此子智能体应该做什么以及何时使用它。(为了获得最佳效果,请全面描述)',
'e.g., Expert code reviewer that reviews code based on best practices...':
'例如:专业的代码审查员,根据最佳实践审查代码...',
'Generating subagent configuration...': '正在生成子代理配置...',
'Failed to generate subagent: {{error}}': '生成子代理失败: {{error}}',
'Step {{n}}: Describe Your Subagent': '步骤 {{n}}: 描述您的子代理',
'Step {{n}}: Enter Subagent Name': '步骤 {{n}}: 输入子代理名称',
'Generating subagent configuration...': '正在生成子智能体配置...',
'Failed to generate subagent: {{error}}': '生成子智能体失败: {{error}}',
'Step {{n}}: Describe Your Subagent': '步骤 {{n}}: 描述您的子智能体',
'Step {{n}}: Enter Subagent Name': '步骤 {{n}}: 输入子智能体名称',
'Step {{n}}: Enter System Prompt': '步骤 {{n}}: 输入系统提示',
'Step {{n}}: Enter Description': '步骤 {{n}}: 输入描述',
// Agents - Tool Selection
@ -253,22 +254,22 @@ export default {
'go back': '返回',
'↑↓ to navigate, ': '↑↓ 导航,',
'Enter a clear, unique name for this subagent.':
'为此子代理输入一个清晰、唯一的名称。',
'为此子智能体输入一个清晰、唯一的名称。',
'e.g., Code Reviewer': '例如:代码审查员',
'Name cannot be empty.': '名称不能为空。',
"Write the system prompt that defines this subagent's behavior. Be comprehensive for best results.":
'编写定义此子代理行为的系统提示。为了获得最佳效果,请全面描述。',
'编写定义此子智能体行为的系统提示。为了获得最佳效果,请全面描述。',
'e.g., You are an expert code reviewer...':
'例如:您是一位专业的代码审查员...',
'System prompt cannot be empty.': '系统提示不能为空。',
'Describe when and how this subagent should be used.':
'描述何时以及如何使用此子代理。',
'描述何时以及如何使用此子智能体。',
'e.g., Reviews code for best practices and potential bugs.':
'例如:审查代码以查找最佳实践和潜在错误。',
'Description cannot be empty.': '描述不能为空。',
'Failed to launch editor: {{error}}': '启动编辑器失败: {{error}}',
'Failed to save and edit subagent: {{error}}':
'保存并编辑子代理失败: {{error}}',
'保存并编辑子智能体失败: {{error}}',
// ============================================================================
// Commands - General (continued)
@ -418,8 +419,10 @@ export default {
'此扩展将排除以下核心工具:{{tools}}',
'This extension will install the following skills:': '此扩展将安装以下技能:',
'This extension will install the following subagents:':
'此扩展将安装以下子代理',
'此扩展将安装以下子智能体',
'Installation cancelled for "{{name}}".': '已取消安装 "{{name}}"。',
'You are installing an extension from {{originSource}}. Some features may not work perfectly with Qwen Code.':
'您正在安装来自 {{originSource}} 的扩展。某些功能可能无法完美兼容 Qwen Code。',
'--ref and --auto-update are not applicable for marketplace extensions.':
'--ref 和 --auto-update 不适用于市场扩展。',
'Extension "{{name}}" installed successfully and enabled.':
@ -478,7 +481,7 @@ export default {
'Enabled (Workspace):': '已启用(工作区):',
'Context files:': '上下文文件:',
'Skills:': '技能:',
'Agents:': '代理',
'Agents:': '智能体',
'MCP servers:': 'MCP 服务器:',
'Link extension failed to install.': '链接扩展安装失败。',
'Extension "{{name}}" linked successfully and enabled.':
@ -1041,6 +1044,8 @@ export default {
'运行 qwen --continue 或 qwen --resume 可继续之前的会话。',
'You can switch permission mode quickly with Shift+Tab or /approval-mode.':
'按 Shift+Tab 或输入 /approval-mode 可快速切换权限模式。',
'You can switch permission mode quickly with Tab or /approval-mode.':
'按 Tab 或输入 /approval-mode 可快速切换权限模式。',
// ============================================================================
// Exit Screen / Stats
@ -1056,7 +1061,7 @@ export default {
'Code Changes:': '代码变更:',
Performance: '性能',
'Wall Time:': '总耗时:',
'Agent Active:': '代理活跃时间:',
'Agent Active:': '智能体活跃时间:',
'API Time:': 'API 时间:',
'Tool Time:': '工具时间:',
'Session Stats': '会话统计',

View file

@ -438,9 +438,11 @@ describe('AuthDialog', () => {
await wait();
// Should show error message instead of calling handleAuthSelect
expect(lastFrame()).toContain(
'You must select an auth method to proceed. Press Ctrl+C again to exit.',
);
await vi.waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('You must select an auth method');
expect(frame).toContain('Press Ctrl+C again to exit');
});
expect(handleAuthSelect).not.toHaveBeenCalled();
unmount();
});

View file

@ -21,21 +21,26 @@ export const AutoAcceptIndicator: React.FC<AutoAcceptIndicatorProps> = ({
let textContent = '';
let subText = '';
const cycleText =
process.platform === 'win32'
? ` ${t('(tab to cycle)')}`
: ` ${t('(shift + tab to cycle)')}`;
switch (approvalMode) {
case ApprovalMode.PLAN:
textColor = theme.status.success;
textContent = t('plan mode');
subText = ` ${t('(shift + tab to cycle)')}`;
subText = cycleText;
break;
case ApprovalMode.AUTO_EDIT:
textColor = theme.status.warning;
textContent = t('auto-accept edits');
subText = ` ${t('(shift + tab to cycle)')}`;
subText = cycleText;
break;
case ApprovalMode.YOLO:
textColor = theme.status.error;
textContent = t('YOLO mode');
subText = ` ${t('(shift + tab to cycle)')}`;
subText = cycleText;
break;
case ApprovalMode.DEFAULT:
default:

View file

@ -56,14 +56,16 @@ export const Composer = () => {
<Box flexDirection="column" marginTop={1}>
{!uiState.embeddedShellFocused && (
<LoadingIndicator
// Hide loading phrases when enableLoadingPhrases is explicitly false.
// Using === false ensures phrases show by default when undefined.
thought={
uiState.streamingState === StreamingState.WaitingForConfirmation ||
config.getAccessibility()?.disableLoadingPhrases
config.getAccessibility()?.enableLoadingPhrases === false
? undefined
: uiState.thought
}
currentLoadingPhrase={
config.getAccessibility()?.disableLoadingPhrases
config.getAccessibility()?.enableLoadingPhrases === false
? undefined
: uiState.currentLoadingPhrase
}

View file

@ -46,6 +46,18 @@ const mockCommands: readonly SlashCommand[] = [
];
describe('Help Component', () => {
it('should render platform-specific keyboard shortcuts', () => {
const { lastFrame } = render(<Help commands={mockCommands} />);
const output = lastFrame();
if (process.platform === 'win32') {
expect(output).toContain('Tab');
expect(output).not.toContain('Shift+Tab');
} else {
expect(output).toContain('Shift+Tab');
}
});
it('should not render hidden commands', () => {
const { lastFrame } = render(<Help commands={mockCommands} />);
const output = lastFrame();

View file

@ -154,7 +154,7 @@ export const Help: React.FC<Help> = ({ commands, width }) => (
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Shift+Tab
{process.platform === 'win32' ? 'Tab' : 'Shift+Tab'}
</Text>{' '}
- {t('Cycle approval modes')}
</Text>

View file

@ -28,7 +28,10 @@ const getShortcuts = (): Shortcut[] => [
{ key: '/', description: t('for commands') },
{ key: '@', description: t('for file paths') },
{ key: 'esc esc', description: t('to clear input') },
{ key: 'shift+tab', description: t('to cycle approvals') },
{
key: process.platform === 'win32' ? 'tab' : 'shift+tab',
description: t('to cycle approvals'),
},
{ key: 'ctrl+c', description: t('to quit') },
{ key: getNewlineKey(), description: t('for newline') + ' ⏎' },
{ key: 'ctrl+l', description: t('to clear screen') },

View file

@ -17,7 +17,9 @@ const startupTips = [
'You can run any shell commands from Qwen Code using ! (e.g. !ls).',
'Type / to open the command popup; Tab autocompletes slash commands and saved prompts.',
'You can resume a previous conversation by running qwen --continue or qwen --resume.',
'You can switch permission mode quickly with Shift+Tab or /approval-mode.',
process.platform === 'win32'
? 'You can switch permission mode quickly with Tab or /approval-mode.'
: 'You can switch permission mode quickly with Shift+Tab or /approval-mode.',
] as const;
export const Tips: React.FC = () => {

View file

@ -26,6 +26,7 @@ import * as path from 'node:path';
describe('handleAtCommand', () => {
let testRootDir: string;
let mockConfig: Config;
let registry: ToolRegistry;
const mockAddItem: Mock<UseHistoryManagerReturn['addItem']> = vi.fn();
const mockOnDebugMessage: Mock<(message: string) => void> = vi.fn();
@ -53,6 +54,7 @@ describe('handleAtCommand', () => {
getToolRegistry,
getTargetDir: () => testRootDir,
isSandboxed: () => false,
isTrustedFolder: () => true,
getFileService: () => new FileDiscoveryService(testRootDir),
getFileFilteringRespectGitIgnore: () => true,
getFileFilteringRespectQwenIgnore: () => true,
@ -84,7 +86,7 @@ describe('handleAtCommand', () => {
getTruncateToolOutputLines: () => 500,
} as unknown as Config;
const registry = new ToolRegistry(mockConfig);
registry = new ToolRegistry(mockConfig);
registry.registerTool(new ReadManyFilesTool(mockConfig));
registry.registerTool(new GlobTool(mockConfig));
getToolRegistry.mockReturnValue(registry);
@ -204,6 +206,288 @@ describe('handleAtCommand', () => {
);
});
it('should expand an MCP resource reference in @server: resource format', async () => {
(mockConfig as unknown as { getMcpServers: () => unknown }).getMcpServers =
() =>
({
github: {},
}) as unknown;
vi.spyOn(registry, 'readMcpResource').mockResolvedValue({
contents: [
{
uri: 'github://repos/owner/repo/issues',
mimeType: 'application/json',
text: '{"ok":true}',
},
],
} as unknown as Awaited<ReturnType<ToolRegistry['readMcpResource']>>);
const query = 'Show me the data from @github: repos/owner/repo/issues';
const result = await handleAtCommand({
query,
config: mockConfig,
addItem: mockAddItem,
onDebugMessage: mockOnDebugMessage,
messageId: 1000,
signal: abortController.signal,
});
expect(result).toEqual({
processedQuery: [
{ text: 'Show me the data from @github:repos/owner/repo/issues' },
{ text: '\n--- Content from referenced MCP resources ---' },
{ text: '\nContent from @github:repos/owner/repo/issues:\n' },
{ text: '{"ok":true}' },
{ text: '\n--- End of MCP resource content ---' },
],
shouldProceed: true,
});
expect(registry.readMcpResource).toHaveBeenCalledWith(
'github',
'github://repos/owner/repo/issues',
expect.objectContaining({ signal: abortController.signal }),
);
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'tool_group',
tools: [expect.objectContaining({ status: ToolCallStatus.Success })],
}),
1000,
);
});
it('should expand an MCP resource reference in @server:resource format', async () => {
(mockConfig as unknown as { getMcpServers: () => unknown }).getMcpServers =
() =>
({
github: {},
}) as unknown;
vi.spyOn(registry, 'readMcpResource').mockResolvedValue({
contents: [
{
uri: 'github://repos/owner/repo/issues',
mimeType: 'application/json',
text: '{"ok":true}',
},
],
} as unknown as Awaited<ReturnType<ToolRegistry['readMcpResource']>>);
const query = 'Show me the data from @github:repos/owner/repo/issues';
const result = await handleAtCommand({
query,
config: mockConfig,
addItem: mockAddItem,
onDebugMessage: mockOnDebugMessage,
messageId: 1001,
signal: abortController.signal,
});
expect(result).toEqual({
processedQuery: [
{ text: query },
{ text: '\n--- Content from referenced MCP resources ---' },
{ text: '\nContent from @github:repos/owner/repo/issues:\n' },
{ text: '{"ok":true}' },
{ text: '\n--- End of MCP resource content ---' },
],
shouldProceed: true,
});
expect(registry.readMcpResource).toHaveBeenCalledWith(
'github',
'github://repos/owner/repo/issues',
expect.objectContaining({ signal: abortController.signal }),
);
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'tool_group',
tools: [expect.objectContaining({ status: ToolCallStatus.Success })],
}),
1001,
);
});
it('should expand an MCP resource reference with a leading slash', async () => {
(mockConfig as unknown as { getMcpServers: () => unknown }).getMcpServers =
() =>
({
github: {},
}) as unknown;
vi.spyOn(registry, 'readMcpResource').mockResolvedValue({
contents: [
{
uri: 'github://repos/owner/repo/issues',
mimeType: 'application/json',
text: '{"ok":true}',
},
],
} as unknown as Awaited<ReturnType<ToolRegistry['readMcpResource']>>);
const query = 'Show me the data from @github:/repos/owner/repo/issues';
const result = await handleAtCommand({
query,
config: mockConfig,
addItem: mockAddItem,
onDebugMessage: mockOnDebugMessage,
messageId: 1002,
signal: abortController.signal,
});
expect(result).toEqual({
processedQuery: [
{ text: 'Show me the data from @github:repos/owner/repo/issues' },
{ text: '\n--- Content from referenced MCP resources ---' },
{ text: '\nContent from @github:repos/owner/repo/issues:\n' },
{ text: '{"ok":true}' },
{ text: '\n--- End of MCP resource content ---' },
],
shouldProceed: true,
});
expect(registry.readMcpResource).toHaveBeenCalledWith(
'github',
'github://repos/owner/repo/issues',
expect.objectContaining({ signal: abortController.signal }),
);
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'tool_group',
tools: [expect.objectContaining({ status: ToolCallStatus.Success })],
}),
1002,
);
});
it('should ignore @server: when no MCP resource is provided', async () => {
(mockConfig as unknown as { getMcpServers: () => unknown }).getMcpServers =
() =>
({
github: {},
}) as unknown;
const readMcpResourceSpy = vi.spyOn(registry, 'readMcpResource');
const query = 'Show me the data from @github:';
const result = await handleAtCommand({
query,
config: mockConfig,
addItem: mockAddItem,
onDebugMessage: mockOnDebugMessage,
messageId: 1003,
signal: abortController.signal,
});
expect(result).toEqual({
processedQuery: [{ text: query }],
shouldProceed: true,
});
expect(readMcpResourceSpy).not.toHaveBeenCalled();
expect(mockAddItem).not.toHaveBeenCalled();
});
it('should not expand MCP resources in untrusted folders', async () => {
(mockConfig as unknown as { getMcpServers: () => unknown }).getMcpServers =
() =>
({
github: {},
}) as unknown;
const configWithTrust = mockConfig as unknown as {
isTrustedFolder: () => boolean;
};
configWithTrust.isTrustedFolder = () => false;
const readMcpResourceSpy = vi.spyOn(registry, 'readMcpResource');
const query = 'Show me the data from @github: repos/owner/repo/issues';
const result = await handleAtCommand({
query,
config: mockConfig,
addItem: mockAddItem,
onDebugMessage: mockOnDebugMessage,
messageId: 1004,
signal: abortController.signal,
});
expect(result).toEqual({
processedQuery: null,
shouldProceed: false,
});
expect(readMcpResourceSpy).toHaveBeenCalledWith(
'github',
'github://repos/owner/repo/issues',
expect.objectContaining({ signal: abortController.signal }),
);
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'tool_group',
tools: [
expect.objectContaining({
status: ToolCallStatus.Error,
resultDisplay: expect.stringContaining('untrusted'),
}),
],
}),
1004,
);
});
it('should preserve trailing punctuation after an MCP resource reference', async () => {
(mockConfig as unknown as { getMcpServers: () => unknown }).getMcpServers =
() =>
({
github: {},
}) as unknown;
vi.spyOn(registry, 'readMcpResource').mockResolvedValue({
contents: [
{
uri: 'github://repos/owner/repo/issues',
mimeType: 'application/json',
text: '{"ok":true}',
},
],
} as unknown as Awaited<ReturnType<ToolRegistry['readMcpResource']>>);
const query = 'Show me the data from @github: repos/owner/repo/issues.';
const result = await handleAtCommand({
query,
config: mockConfig,
addItem: mockAddItem,
onDebugMessage: mockOnDebugMessage,
messageId: 1005,
signal: abortController.signal,
});
expect(result).toEqual({
processedQuery: [
{ text: 'Show me the data from @github:repos/owner/repo/issues.' },
{ text: '\n--- Content from referenced MCP resources ---' },
{ text: '\nContent from @github:repos/owner/repo/issues:\n' },
{ text: '{"ok":true}' },
{ text: '\n--- End of MCP resource content ---' },
],
shouldProceed: true,
});
expect(registry.readMcpResource).toHaveBeenCalledWith(
'github',
'github://repos/owner/repo/issues',
expect.objectContaining({ signal: abortController.signal }),
);
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'tool_group',
tools: [expect.objectContaining({ status: ToolCallStatus.Success })],
}),
1005,
);
});
it('should handle query with text before and after @command', async () => {
const fileContent = 'Markdown content.';
const filePath = await createTestFile(

View file

@ -36,6 +36,12 @@ interface AtCommandPart {
content: string;
}
interface McpResourceAtReference {
atCommand: string; // e.g. "@github:repos/owner/repo/issues"
serverName: string;
uri: string; // e.g. "github://repos/owner/repo/issues"
}
/**
* Parses a query string to find all '@<path>' commands and text segments.
* Handles \ escaped spaces within paths.
@ -110,6 +116,199 @@ function parseAllAtCommands(query: string): AtCommandPart[] {
);
}
function getConfiguredMcpServerNames(config: Config): Set<string> {
const names = new Set(Object.keys(config.getMcpServers() ?? {}));
if (config.getMcpServerCommand()) {
names.add('mcp');
}
return names;
}
function normalizeMcpResourceUri(serverName: string, resource: string): string {
if (resource.includes('://')) {
return resource;
}
const cleaned = resource.startsWith('/') ? resource.slice(1) : resource;
return `${serverName}://${cleaned}`;
}
function splitLeadingToken(
text: string,
): { token: string; rest: string } | null {
let i = 0;
while (i < text.length && /\s/.test(text[i])) {
i++;
}
if (i >= text.length) {
return null;
}
let token = '';
let inEscape = false;
while (i < text.length) {
const char = text[i];
if (inEscape) {
token += char;
inEscape = false;
i++;
continue;
}
if (char === '\\') {
inEscape = true;
i++;
continue;
}
if (/[,\s;!?()[\]{}]/.test(char)) {
break;
}
if (char === '.') {
const nextChar = i + 1 < text.length ? text[i + 1] : '';
if (nextChar === '' || /\s/.test(nextChar)) {
break;
}
}
token += char;
i++;
}
if (!token) {
return null;
}
return { token, rest: text.slice(i) };
}
function extractMcpResourceAtReferences(
parts: AtCommandPart[],
config: Config,
): { parts: AtCommandPart[]; refs: McpResourceAtReference[] } {
const configuredServers = getConfiguredMcpServerNames(config);
const refs: McpResourceAtReference[] = [];
const merged: AtCommandPart[] = [];
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (part.type !== 'atPath') {
merged.push(part);
continue;
}
const atText = part.content; // e.g. "@github:" or "@github:repos/..."
const colonIndex = atText.indexOf(':');
if (!atText.startsWith('@') || colonIndex <= 1) {
merged.push(part);
continue;
}
const serverName = atText.slice(1, colonIndex);
if (!configuredServers.has(serverName)) {
merged.push(part);
continue;
}
let resource = atText.slice(colonIndex + 1);
// Support the documented "@server: resource" format where the resource is
// separated into the following text part.
if (!resource) {
const next = parts[i + 1];
if (next?.type === 'text') {
const tokenInfo = splitLeadingToken(next.content);
if (tokenInfo) {
resource = tokenInfo.token;
const remainingText = tokenInfo.rest;
// Update the next part in place, and let the next iteration handle it.
parts[i + 1] = { type: 'text', content: remainingText };
}
}
}
if (!resource) {
// Treat "@server:" without a resource as plain text, rather than falling
// through to file resolution for a path like "server:".
merged.push({ type: 'text', content: atText });
continue;
}
const normalizedResource = resource.includes('://')
? resource
: resource.startsWith('/')
? resource.slice(1)
: resource;
const normalizedAtCommand = `@${serverName}:${normalizedResource}`;
refs.push({
atCommand: normalizedAtCommand,
serverName,
uri: normalizeMcpResourceUri(serverName, normalizedResource),
});
merged.push({ type: 'atPath', content: normalizedAtCommand });
}
return {
parts: merged.filter(
(p) => !(p.type === 'text' && p.content.trim() === ''),
),
refs,
};
}
function formatMcpResourceContents(
raw: unknown,
limits: { maxCharsPerResource: number; maxLinesPerResource: number },
): string {
if (!raw || typeof raw !== 'object') {
return '[Error: Invalid MCP resource response]';
}
const contents = (raw as { contents?: unknown }).contents;
if (!Array.isArray(contents)) {
return '[Error: Invalid MCP resource response]';
}
const parts: string[] = [];
for (const item of contents) {
if (!item || typeof item !== 'object') {
continue;
}
const text = (item as { text?: unknown }).text;
const blob = (item as { blob?: unknown }).blob;
const mimeType = (item as { mimeType?: unknown }).mimeType;
if (typeof text === 'string') {
parts.push(text);
continue;
}
if (typeof blob === 'string') {
const mimeTypeLabel =
typeof mimeType === 'string' ? mimeType : 'application/octet-stream';
parts.push(
`[Binary MCP resource omitted (mimeType: ${mimeTypeLabel}, bytes: ${blob.length})]`,
);
}
}
let combined = parts.join('\n\n');
const maxLines = limits.maxLinesPerResource;
if (Number.isFinite(maxLines)) {
const lines = combined.split('\n');
if (lines.length > maxLines) {
combined = `${lines.slice(0, maxLines).join('\n')}\n[truncated]`;
}
}
const maxChars = limits.maxCharsPerResource;
if (Number.isFinite(maxChars) && combined.length > maxChars) {
combined = `${combined.slice(0, maxChars)}\n[truncated]`;
}
return combined;
}
/**
* Processes user input potentially containing one or more '@<path>' commands.
* If found, it attempts to read the specified files/directories using the
@ -127,10 +326,17 @@ export async function handleAtCommand({
messageId: userMessageTimestamp,
signal,
}: HandleAtCommandParams): Promise<HandleAtCommandResult> {
const commandParts = parseAllAtCommands(query);
const parsedParts = parseAllAtCommands(query);
const { parts: commandParts, refs: mcpResourceRefs } =
extractMcpResourceAtReferences(parsedParts, config);
const mcpAtCommands = new Set(mcpResourceRefs.map((r) => r.atCommand));
const atPathCommandParts = commandParts.filter(
(part) => part.type === 'atPath',
);
const fileAtPathCommandParts = atPathCommandParts.filter(
(part) => !mcpAtCommands.has(part.content),
);
if (atPathCommandParts.length === 0) {
return { processedQuery: [{ text: query }], shouldProceed: true };
@ -154,15 +360,7 @@ export async function handleAtCommand({
const readManyFilesTool = toolRegistry.getTool('read_many_files');
const globTool = toolRegistry.getTool('glob');
if (!readManyFilesTool) {
addItem(
{ type: 'error', text: 'Error: read_many_files tool not found.' },
userMessageTimestamp,
);
return { processedQuery: null, shouldProceed: false };
}
for (const atPathPart of atPathCommandParts) {
for (const atPathPart of fileAtPathCommandParts) {
const originalAtPath = atPathPart.content; // e.g., "@file.txt" or "@"
if (originalAtPath === '@') {
@ -377,7 +575,7 @@ export async function handleAtCommand({
}
// Fallback for lone "@" or completely invalid @-commands resulting in empty initialQueryText
if (pathSpecsToRead.length === 0) {
if (pathSpecsToRead.length === 0 && mcpResourceRefs.length === 0) {
onDebugMessage('No valid file paths found in @ commands to read.');
if (initialQueryText === '@' && query.trim() === '@') {
// If the only thing was a lone @, pass original query (which might have spaces)
@ -395,86 +593,165 @@ export async function handleAtCommand({
const processedQueryParts: PartUnion[] = [{ text: initialQueryText }];
const toolArgs = {
paths: pathSpecsToRead,
file_filtering_options: {
respect_git_ignore: respectFileIgnore.respectGitIgnore,
respect_qwen_ignore: respectFileIgnore.respectQwenIgnore,
},
// Use configuration setting
};
let toolCallDisplay: IndividualToolCallDisplay;
const toolDisplays: IndividualToolCallDisplay[] = [];
let invocation: AnyToolInvocation | undefined = undefined;
try {
invocation = readManyFilesTool.build(toolArgs);
const result = await invocation.execute(signal);
toolCallDisplay = {
callId: `client-read-${userMessageTimestamp}`,
name: readManyFilesTool.displayName,
description: invocation.getDescription(),
status: ToolCallStatus.Success,
resultDisplay:
result.returnDisplay ||
`Successfully read: ${contentLabelsForDisplay.join(', ')}`,
confirmationDetails: undefined,
};
if (Array.isArray(result.llmContent)) {
const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/;
processedQueryParts.push({
text: '\n--- Content from referenced files ---',
});
for (const part of result.llmContent) {
if (typeof part === 'string') {
const match = fileContentRegex.exec(part);
if (match) {
const filePathSpecInContent = match[1]; // This is a resolved pathSpec
const fileActualContent = match[2].trim();
processedQueryParts.push({
text: `\nContent from @${filePathSpecInContent}:\n`,
});
processedQueryParts.push({ text: fileActualContent });
} else {
processedQueryParts.push({ text: part });
}
} else {
// part is a Part object.
processedQueryParts.push(part);
}
}
} else {
onDebugMessage(
'read_many_files tool returned no content or empty content.',
if (pathSpecsToRead.length > 0) {
if (!readManyFilesTool) {
addItem(
{ type: 'error', text: 'Error: read_many_files tool not found.' },
userMessageTimestamp,
);
return { processedQuery: null, shouldProceed: false };
}
addItem(
{ type: 'tool_group', tools: [toolCallDisplay] } as Omit<
HistoryItem,
'id'
>,
userMessageTimestamp,
);
return { processedQuery: processedQueryParts, shouldProceed: true };
} catch (error: unknown) {
toolCallDisplay = {
callId: `client-read-${userMessageTimestamp}`,
name: readManyFilesTool.displayName,
description:
invocation?.getDescription() ??
'Error attempting to execute tool to read files',
status: ToolCallStatus.Error,
resultDisplay: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`,
confirmationDetails: undefined,
const toolArgs = {
paths: pathSpecsToRead,
file_filtering_options: {
respect_git_ignore: respectFileIgnore.respectGitIgnore,
respect_qwen_ignore: respectFileIgnore.respectQwenIgnore,
},
// Use configuration setting
};
let invocation: AnyToolInvocation | undefined = undefined;
try {
invocation = readManyFilesTool.build(toolArgs);
const result = await invocation.execute(signal);
toolDisplays.push({
callId: `client-read-${userMessageTimestamp}`,
name: readManyFilesTool.displayName,
description: invocation.getDescription(),
status: ToolCallStatus.Success,
resultDisplay:
result.returnDisplay ||
`Successfully read: ${contentLabelsForDisplay.join(', ')}`,
confirmationDetails: undefined,
});
if (Array.isArray(result.llmContent)) {
const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/;
processedQueryParts.push({
text: '\n--- Content from referenced files ---',
});
for (const part of result.llmContent) {
if (typeof part === 'string') {
const match = fileContentRegex.exec(part);
if (match) {
const filePathSpecInContent = match[1]; // This is a resolved pathSpec
const fileActualContent = match[2].trim();
processedQueryParts.push({
text: `\nContent from @${filePathSpecInContent}:\n`,
});
processedQueryParts.push({ text: fileActualContent });
} else {
processedQueryParts.push({ text: part });
}
} else {
// part is a Part object.
processedQueryParts.push(part);
}
}
} else {
onDebugMessage(
'read_many_files tool returned no content or empty content.',
);
}
} catch (error: unknown) {
toolDisplays.push({
callId: `client-read-${userMessageTimestamp}`,
name: readManyFilesTool.displayName,
description:
invocation?.getDescription() ??
'Error attempting to execute tool to read files',
status: ToolCallStatus.Error,
resultDisplay: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`,
confirmationDetails: undefined,
});
addItem(
{ type: 'tool_group', tools: toolDisplays } as Omit<HistoryItem, 'id'>,
userMessageTimestamp,
);
return { processedQuery: null, shouldProceed: false };
}
}
if (mcpResourceRefs.length > 0) {
const totalCharLimit = config.getTruncateToolOutputThreshold();
const totalLineLimit = config.getTruncateToolOutputLines();
const maxCharsPerResource = Number.isFinite(totalCharLimit)
? Math.floor(totalCharLimit / Math.max(1, mcpResourceRefs.length))
: Number.POSITIVE_INFINITY;
const maxLinesPerResource = Number.isFinite(totalLineLimit)
? Math.floor(totalLineLimit / Math.max(1, mcpResourceRefs.length))
: Number.POSITIVE_INFINITY;
processedQueryParts.push({
text: '\n--- Content from referenced MCP resources ---',
});
for (let i = 0; i < mcpResourceRefs.length; i++) {
const ref = mcpResourceRefs[i];
let resourceResult: unknown;
try {
if (signal.aborted) {
const error = new Error('MCP resource read aborted');
error.name = 'AbortError';
throw error;
}
resourceResult = await toolRegistry.readMcpResource(
ref.serverName,
ref.uri,
{ signal },
);
toolDisplays.push({
callId: `client-mcp-resource-${userMessageTimestamp}-${i}`,
name: 'McpResourceRead',
description: `Read MCP resource ${ref.uri} (server: ${ref.serverName})`,
status: ToolCallStatus.Success,
resultDisplay: `Read: ${ref.uri}`,
confirmationDetails: undefined,
});
} catch (error: unknown) {
toolDisplays.push({
callId: `client-mcp-resource-${userMessageTimestamp}-${i}`,
name: 'McpResourceRead',
description: `Read MCP resource ${ref.uri} (server: ${ref.serverName})`,
status: ToolCallStatus.Error,
resultDisplay: `Error reading MCP resource (${ref.uri}): ${getErrorMessage(error)}`,
confirmationDetails: undefined,
});
addItem(
{ type: 'tool_group', tools: toolDisplays } as Omit<
HistoryItem,
'id'
>,
userMessageTimestamp,
);
return { processedQuery: null, shouldProceed: false };
}
processedQueryParts.push({
text: `\nContent from ${ref.atCommand}:\n`,
});
processedQueryParts.push({
text: formatMcpResourceContents(resourceResult, {
maxCharsPerResource,
maxLinesPerResource,
}),
});
}
processedQueryParts.push({ text: '\n--- End of MCP resource content ---' });
}
if (toolDisplays.length > 0) {
addItem(
{ type: 'tool_group', tools: [toolCallDisplay] } as Omit<
HistoryItem,
'id'
>,
{ type: 'tool_group', tools: toolDisplays } as Omit<HistoryItem, 'id'>,
userMessageTimestamp,
);
return { processedQuery: null, shouldProceed: false };
}
return { processedQuery: processedQueryParts, shouldProceed: true };
}

View file

@ -49,7 +49,7 @@ describe('useAtCompletion', () => {
respectQwenIgnore: true,
})),
getEnableRecursiveFileSearch: () => true,
getFileFilteringDisableFuzzySearch: () => false,
getFileFilteringEnableFuzzySearch: () => true,
} as unknown as Config;
vi.clearAllMocks();
});
@ -197,7 +197,7 @@ describe('useAtCompletion', () => {
cache: false,
cacheTtl: 0,
enableRecursiveFileSearch: true,
disableFuzzySearch: false,
enableFuzzySearch: true,
});
await realFileSearch.initialize();
@ -479,7 +479,7 @@ describe('useAtCompletion', () => {
respectGitIgnore: true,
respectQwenIgnore: true,
})),
getFileFilteringDisableFuzzySearch: () => false,
getFileFilteringEnableFuzzySearch: () => true,
} as unknown as Config;
const { result } = renderHook(() =>

View file

@ -166,8 +166,9 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
cacheTtl: 30, // 30 seconds
enableRecursiveFileSearch:
config?.getEnableRecursiveFileSearch() ?? true,
disableFuzzySearch:
config?.getFileFilteringDisableFuzzySearch() ?? false,
// Use enableFuzzySearch with !== false to default to true when undefined.
enableFuzzySearch:
config?.getFileFilteringEnableFuzzySearch() !== false,
});
await searcher.initialize();
fileSearch.current = searcher;

View file

@ -240,7 +240,13 @@ describe('useAutoAcceptIndicator', () => {
shift: false,
} as Key);
});
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
if (process.platform === 'win32') {
// On Windows, Tab alone toggles approval mode
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalled();
mockConfigInstance.setApprovalMode.mockClear();
} else {
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
}
act(() => {
capturedUseKeypressHandler({

View file

@ -36,7 +36,16 @@ export function useAutoAcceptIndicator({
useKeypress(
(key) => {
// Handle Shift+Tab to cycle through all modes
if (key.shift && key.name === 'tab') {
// On Windows, Shift+Tab is indistinguishable from Tab (\t) in some terminals,
// so we allow Tab to switch modes as well to support the shortcut.
const isShiftTab = key.shift && key.name === 'tab';
const isWindowsTab =
process.platform === 'win32' &&
key.name === 'tab' &&
!key.ctrl &&
!key.meta;
if (isShiftTab || isWindowsTab) {
const currentMode = config.getApprovalMode();
const currentIndex = APPROVAL_MODES.indexOf(currentMode);
const nextIndex =

View file

@ -65,7 +65,7 @@ describe('handleAutoUpdate', () => {
mockSettings = {
merged: {
general: {
disableAutoUpdate: false,
enableAutoUpdate: true,
},
},
} as LoadedSettings;
@ -94,32 +94,29 @@ describe('handleAutoUpdate', () => {
expect(mockSpawn).not.toHaveBeenCalled();
});
it('should do nothing if update nag is disabled', () => {
mockSettings.merged.general!.disableUpdateNag = true;
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
expect(mockGetInstallationInfo).not.toHaveBeenCalled();
expect(mockUpdateEventEmitter.emit).not.toHaveBeenCalled();
expect(mockSpawn).not.toHaveBeenCalled();
});
it('should emit "update-received" but not update if auto-updates are disabled', () => {
mockSettings.merged.general!.disableAutoUpdate = true;
it('should show manual update message when enableAutoUpdate is false', () => {
// When enableAutoUpdate is false, gemini.tsx won't call checkForUpdates(),
// but if handleAutoUpdate is still called, it should show a manual update message.
mockSettings.merged.general!.enableAutoUpdate = false;
mockGetInstallationInfo.mockReturnValue({
updateCommand: 'npm i -g @qwen-code/qwen-code@latest',
updateMessage: 'Please update manually.',
updateMessage:
'Please run npm i -g @qwen-code/qwen-code@latest to update',
isGlobal: true,
packageManager: PackageManager.NPM,
});
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
expect(mockUpdateEventEmitter.emit).toHaveBeenCalledTimes(1);
// Should still emit update-received with manual update message
expect(mockUpdateEventEmitter.emit).toHaveBeenCalledWith(
'update-received',
{
message: 'An update is available!\nPlease update manually.',
message:
'An update is available!\nPlease run npm i -g @qwen-code/qwen-code@latest to update',
},
);
// Should NOT spawn update when enableAutoUpdate is false
expect(mockSpawn).not.toHaveBeenCalled();
});

View file

@ -24,13 +24,14 @@ export function handleAutoUpdate(
return;
}
if (settings.merged.general?.disableUpdateNag) {
return;
}
// enableAutoUpdate is checked in gemini.tsx before calling this function,
// so if we get here, auto-update is enabled (or undefined, which defaults to enabled).
const isAutoUpdateEnabled =
settings.merged.general?.enableAutoUpdate !== false;
const installationInfo = getInstallationInfo(
projectRoot,
settings.merged.general?.disableAutoUpdate ?? false,
isAutoUpdateEnabled,
);
let combinedMessage = info.message;
@ -42,10 +43,8 @@ export function handleAutoUpdate(
message: combinedMessage,
});
if (
!installationInfo.updateCommand ||
settings.merged.general?.disableAutoUpdate
) {
// Don't automatically run the update if auto-update is disabled or no update command
if (!installationInfo.updateCommand || !isAutoUpdateEnabled) {
return;
}
const isNightly = info.update.latest.includes('nightly');

View file

@ -178,13 +178,15 @@ describe('getInstallationInfo', () => {
throw new Error('Command failed');
});
const info = getInstallationInfo(projectRoot, false);
// isAutoUpdateEnabled = true -> "Attempting to automatically update"
const info = getInstallationInfo(projectRoot, true);
expect(info.packageManager).toBe(PackageManager.PNPM);
expect(info.isGlobal).toBe(true);
expect(info.updateCommand).toBe('pnpm add -g @qwen-code/qwen-code@latest');
expect(info.updateMessage).toContain('Attempting to automatically update');
const infoDisabled = getInstallationInfo(projectRoot, true);
// isAutoUpdateEnabled = false -> "Please run..."
const infoDisabled = getInstallationInfo(projectRoot, false);
expect(infoDisabled.updateMessage).toContain('Please run pnpm add');
});
@ -196,7 +198,8 @@ describe('getInstallationInfo', () => {
throw new Error('Command failed');
});
const info = getInstallationInfo(projectRoot, false);
// isAutoUpdateEnabled = true -> "Attempting to automatically update"
const info = getInstallationInfo(projectRoot, true);
expect(info.packageManager).toBe(PackageManager.YARN);
expect(info.isGlobal).toBe(true);
expect(info.updateCommand).toBe(
@ -204,7 +207,8 @@ describe('getInstallationInfo', () => {
);
expect(info.updateMessage).toContain('Attempting to automatically update');
const infoDisabled = getInstallationInfo(projectRoot, true);
// isAutoUpdateEnabled = false -> "Please run..."
const infoDisabled = getInstallationInfo(projectRoot, false);
expect(infoDisabled.updateMessage).toContain('Please run yarn global add');
});
@ -216,13 +220,15 @@ describe('getInstallationInfo', () => {
throw new Error('Command failed');
});
const info = getInstallationInfo(projectRoot, false);
// isAutoUpdateEnabled = true -> "Attempting to automatically update"
const info = getInstallationInfo(projectRoot, true);
expect(info.packageManager).toBe(PackageManager.BUN);
expect(info.isGlobal).toBe(true);
expect(info.updateCommand).toBe('bun add -g @qwen-code/qwen-code@latest');
expect(info.updateMessage).toContain('Attempting to automatically update');
const infoDisabled = getInstallationInfo(projectRoot, true);
// isAutoUpdateEnabled = false -> "Please run..."
const infoDisabled = getInstallationInfo(projectRoot, false);
expect(infoDisabled.updateMessage).toContain('Please run bun add');
});
@ -301,7 +307,8 @@ describe('getInstallationInfo', () => {
throw new Error('Command failed');
});
const info = getInstallationInfo(projectRoot, false);
// isAutoUpdateEnabled = true -> "Attempting to automatically update"
const info = getInstallationInfo(projectRoot, true);
expect(info.packageManager).toBe(PackageManager.NPM);
expect(info.isGlobal).toBe(true);
expect(info.updateCommand).toBe(
@ -309,7 +316,8 @@ describe('getInstallationInfo', () => {
);
expect(info.updateMessage).toContain('Attempting to automatically update');
const infoDisabled = getInstallationInfo(projectRoot, true);
// isAutoUpdateEnabled = false -> "Please run..."
const infoDisabled = getInstallationInfo(projectRoot, false);
expect(infoDisabled.updateMessage).toContain('Please run npm install');
});
});

View file

@ -30,7 +30,7 @@ export interface InstallationInfo {
export function getInstallationInfo(
projectRoot: string,
isAutoUpdateDisabled: boolean,
isAutoUpdateEnabled: boolean,
): InstallationInfo {
const cliPath = process.argv[1];
if (!cliPath) {
@ -99,9 +99,9 @@ export function getInstallationInfo(
packageManager: PackageManager.PNPM,
isGlobal: true,
updateCommand,
updateMessage: isAutoUpdateDisabled
? `Please run ${updateCommand} to update`
: 'Installed with pnpm. Attempting to automatically update now...',
updateMessage: isAutoUpdateEnabled
? 'Installed with pnpm. Attempting to automatically update now...'
: `Please run ${updateCommand} to update`,
};
}
@ -112,9 +112,9 @@ export function getInstallationInfo(
packageManager: PackageManager.YARN,
isGlobal: true,
updateCommand,
updateMessage: isAutoUpdateDisabled
? `Please run ${updateCommand} to update`
: 'Installed with yarn. Attempting to automatically update now...',
updateMessage: isAutoUpdateEnabled
? 'Installed with yarn. Attempting to automatically update now...'
: `Please run ${updateCommand} to update`,
};
}
@ -132,9 +132,9 @@ export function getInstallationInfo(
packageManager: PackageManager.BUN,
isGlobal: true,
updateCommand,
updateMessage: isAutoUpdateDisabled
? `Please run ${updateCommand} to update`
: 'Installed with bun. Attempting to automatically update now...',
updateMessage: isAutoUpdateEnabled
? 'Installed with bun. Attempting to automatically update now...'
: `Please run ${updateCommand} to update`,
};
}
@ -165,9 +165,9 @@ export function getInstallationInfo(
packageManager: PackageManager.NPM,
isGlobal: true,
updateCommand,
updateMessage: isAutoUpdateDisabled
? `Please run ${updateCommand} to update`
: 'Installed with npm. Attempting to automatically update now...',
updateMessage: isAutoUpdateEnabled
? 'Installed with npm. Attempting to automatically update now...'
: `Please run ${updateCommand} to update`,
};
} catch (error) {
console.log(error);

View file

@ -121,7 +121,7 @@ describe('SettingsUtils', () => {
description: 'Accessibility settings.',
showInDialog: false,
properties: {
disableLoadingPhrases: {
enableLoadingPhrases: {
type: 'boolean',
label: 'Disable Loading Phrases',
category: 'UI',
@ -285,14 +285,14 @@ describe('SettingsUtils', () => {
it('should handle nested settings correctly', () => {
const settings = makeMockSettings({
ui: { accessibility: { disableLoadingPhrases: true } },
ui: { accessibility: { enableLoadingPhrases: true } },
});
const mergedSettings = makeMockSettings({
ui: { accessibility: { disableLoadingPhrases: false } },
ui: { accessibility: { enableLoadingPhrases: false } },
});
const value = getEffectiveValue(
'ui.accessibility.disableLoadingPhrases',
'ui.accessibility.enableLoadingPhrases',
settings,
mergedSettings,
);
@ -316,7 +316,7 @@ describe('SettingsUtils', () => {
it('should return all setting keys', () => {
const keys = getAllSettingKeys();
expect(keys).toContain('test');
expect(keys).toContain('ui.accessibility.disableLoadingPhrases');
expect(keys).toContain('ui.accessibility.enableLoadingPhrases');
});
});
@ -343,9 +343,9 @@ describe('SettingsUtils', () => {
describe('isValidSettingKey', () => {
it('should return true for valid setting keys', () => {
expect(isValidSettingKey('ui.requiresRestart')).toBe(true);
expect(
isValidSettingKey('ui.accessibility.disableLoadingPhrases'),
).toBe(true);
expect(isValidSettingKey('ui.accessibility.enableLoadingPhrases')).toBe(
true,
);
});
it('should return false for invalid setting keys', () => {
@ -358,7 +358,7 @@ describe('SettingsUtils', () => {
it('should return correct category for valid settings', () => {
expect(getSettingCategory('ui.requiresRestart')).toBe('UI');
expect(
getSettingCategory('ui.accessibility.disableLoadingPhrases'),
getSettingCategory('ui.accessibility.enableLoadingPhrases'),
).toBe('UI');
});
@ -392,7 +392,7 @@ describe('SettingsUtils', () => {
const uiSettings = categories['UI'];
const uiKeys = uiSettings.map((s) => s.key);
expect(uiKeys).toContain('ui.requiresRestart');
expect(uiKeys).toContain('ui.accessibility.disableLoadingPhrases');
expect(uiKeys).toContain('ui.accessibility.enableLoadingPhrases');
expect(uiKeys).not.toContain('ui.theme'); // This is now marked false
});
@ -422,7 +422,7 @@ describe('SettingsUtils', () => {
const keys = booleanSettings.map((s) => s.key);
expect(keys).toContain('ui.requiresRestart');
expect(keys).toContain('ui.accessibility.disableLoadingPhrases');
expect(keys).toContain('ui.accessibility.enableLoadingPhrases');
expect(keys).not.toContain('privacy.usageStatisticsEnabled');
expect(keys).not.toContain('security.auth.selectedType'); // Advanced setting
expect(keys).not.toContain('security.auth.useExternal'); // Advanced setting
@ -455,7 +455,7 @@ describe('SettingsUtils', () => {
expect(dialogKeys).toContain('ui.requiresRestart');
// Should include nested settings marked for dialog
expect(dialogKeys).toContain('ui.accessibility.disableLoadingPhrases');
expect(dialogKeys).toContain('ui.accessibility.enableLoadingPhrases');
// Should NOT include settings marked as hidden
expect(dialogKeys).not.toContain('ui.theme'); // Hidden
@ -602,14 +602,14 @@ describe('SettingsUtils', () => {
it('should return true when value differs from default', () => {
expect(isSettingModified('ui.requiresRestart', true)).toBe(true);
expect(
isSettingModified('ui.accessibility.disableLoadingPhrases', true),
isSettingModified('ui.accessibility.enableLoadingPhrases', true),
).toBe(true);
});
it('should return false when value matches default', () => {
expect(isSettingModified('ui.requiresRestart', false)).toBe(false);
expect(
isSettingModified('ui.accessibility.disableLoadingPhrases', false),
isSettingModified('ui.accessibility.enableLoadingPhrases', false),
).toBe(false);
});
});
@ -629,11 +629,11 @@ describe('SettingsUtils', () => {
it('should return true for nested settings that exist', () => {
const settings = makeMockSettings({
ui: { accessibility: { disableLoadingPhrases: true } },
ui: { accessibility: { enableLoadingPhrases: true } },
});
expect(
settingExistsInScope(
'ui.accessibility.disableLoadingPhrases',
'ui.accessibility.enableLoadingPhrases',
settings,
),
).toBe(true);
@ -643,7 +643,7 @@ describe('SettingsUtils', () => {
const settings = makeMockSettings({});
expect(
settingExistsInScope(
'ui.accessibility.disableLoadingPhrases',
'ui.accessibility.enableLoadingPhrases',
settings,
),
).toBe(false);
@ -653,7 +653,7 @@ describe('SettingsUtils', () => {
const settings = makeMockSettings({ ui: { accessibility: {} } });
expect(
settingExistsInScope(
'ui.accessibility.disableLoadingPhrases',
'ui.accessibility.enableLoadingPhrases',
settings,
),
).toBe(false);
@ -675,25 +675,25 @@ describe('SettingsUtils', () => {
it('should set nested setting value', () => {
const pendingSettings = makeMockSettings({});
const result = setPendingSettingValue(
'ui.accessibility.disableLoadingPhrases',
'ui.accessibility.enableLoadingPhrases',
true,
pendingSettings,
);
expect(result.ui?.accessibility?.disableLoadingPhrases).toBe(true);
expect(result.ui?.accessibility?.enableLoadingPhrases).toBe(true);
});
it('should preserve existing nested settings', () => {
const pendingSettings = makeMockSettings({
ui: { accessibility: { disableLoadingPhrases: false } },
ui: { accessibility: { enableLoadingPhrases: false } },
});
const result = setPendingSettingValue(
'ui.accessibility.disableLoadingPhrases',
'ui.accessibility.enableLoadingPhrases',
true,
pendingSettings,
);
expect(result.ui?.accessibility?.disableLoadingPhrases).toBe(true);
expect(result.ui?.accessibility?.enableLoadingPhrases).toBe(true);
});
it('should not mutate original settings', () => {
@ -1030,7 +1030,7 @@ describe('SettingsUtils', () => {
const settings = makeMockSettings({}); // nested setting doesn't exist
const result = isDefaultValue(
'ui.accessibility.disableLoadingPhrases',
'ui.accessibility.enableLoadingPhrases',
settings,
);
expect(result).toBe(true);
@ -1038,11 +1038,11 @@ describe('SettingsUtils', () => {
it('should return false when nested setting exists in scope', () => {
const settings = makeMockSettings({
ui: { accessibility: { disableLoadingPhrases: true } },
ui: { accessibility: { enableLoadingPhrases: true } },
}); // nested setting exists
const result = isDefaultValue(
'ui.accessibility.disableLoadingPhrases',
'ui.accessibility.enableLoadingPhrases',
settings,
);
expect(result).toBe(false);
@ -1080,14 +1080,14 @@ describe('SettingsUtils', () => {
it('should return false for nested settings that exist in scope', () => {
const settings = makeMockSettings({
ui: { accessibility: { disableLoadingPhrases: true } },
ui: { accessibility: { enableLoadingPhrases: true } },
});
const mergedSettings = makeMockSettings({
ui: { accessibility: { disableLoadingPhrases: true } },
ui: { accessibility: { enableLoadingPhrases: true } },
});
const result = isValueInherited(
'ui.accessibility.disableLoadingPhrases',
'ui.accessibility.enableLoadingPhrases',
settings,
mergedSettings,
);
@ -1097,11 +1097,11 @@ describe('SettingsUtils', () => {
it('should return true for nested settings that do not exist in scope', () => {
const settings = makeMockSettings({});
const mergedSettings = makeMockSettings({
ui: { accessibility: { disableLoadingPhrases: true } },
ui: { accessibility: { enableLoadingPhrases: true } },
});
const result = isValueInherited(
'ui.accessibility.disableLoadingPhrases',
'ui.accessibility.enableLoadingPhrases',
settings,
mergedSettings,
);