mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-05 23:42:03 +00:00
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:
commit
6324863008
313 changed files with 18224 additions and 1327 deletions
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}}':
|
||||
|
|
|
|||
|
|
@ -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.':
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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': '会话统计',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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') },
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() =>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue