mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-05 23:42:03 +00:00
Merge origin/main into refactor/read-many-files-util
Resolved conflicts by: - index.ts: Adopted main's organized structure, added readManyFiles.js export - atCommandProcessor.ts: Kept refactored readManyFiles utility approach - atCommandProcessor.test.ts: Kept tests for refactored approach
This commit is contained in:
commit
42da41381a
350 changed files with 20541 additions and 5735 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,10 +21,12 @@ import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
|
|||
* have appeared during the original session.
|
||||
*/
|
||||
export class HistoryReplayer {
|
||||
private readonly ctx: SessionContext;
|
||||
private readonly messageEmitter: MessageEmitter;
|
||||
private readonly toolCallEmitter: ToolCallEmitter;
|
||||
|
||||
constructor(ctx: SessionContext) {
|
||||
this.ctx = ctx;
|
||||
this.messageEmitter = new MessageEmitter(ctx);
|
||||
this.toolCallEmitter = new ToolCallEmitter(ctx);
|
||||
}
|
||||
|
|
@ -44,6 +46,7 @@ export class HistoryReplayer {
|
|||
* Replays a single chat record.
|
||||
*/
|
||||
private async replayRecord(record: ChatRecord): Promise<void> {
|
||||
this.setActiveRecordId(record.uuid, record.timestamp);
|
||||
switch (record.type) {
|
||||
case 'user':
|
||||
if (record.message) {
|
||||
|
|
@ -68,6 +71,7 @@ export class HistoryReplayer {
|
|||
// Skip system records (compression, telemetry, slash commands)
|
||||
break;
|
||||
}
|
||||
this.setActiveRecordId(null);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -199,4 +203,13 @@ export class HistoryReplayer {
|
|||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private setActiveRecordId(recordId: string | null, timestamp?: string): void {
|
||||
const context = this.ctx as unknown as {
|
||||
setActiveRecordId?: (id: string | null, timestamp?: string) => void;
|
||||
};
|
||||
if (typeof context.setActiveRecordId === 'function') {
|
||||
context.setActiveRecordId(recordId, timestamp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -131,7 +132,6 @@ export interface CliArgs {
|
|||
webSearchDefault: string | undefined;
|
||||
screenReader: boolean | undefined;
|
||||
vlmSwitchMode: string | undefined;
|
||||
useSmartEdit: boolean | undefined;
|
||||
inputFormat?: string | undefined;
|
||||
outputFormat: string | undefined;
|
||||
includePartialMessages?: boolean;
|
||||
|
|
@ -1007,7 +1007,6 @@ export async function loadCliConfig(
|
|||
truncateToolOutputLines: settings.tools?.truncateToolOutputLines,
|
||||
enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation,
|
||||
eventEmitter: appEvents,
|
||||
useSmartEdit: argv.useSmartEdit ?? settings.useSmartEdit,
|
||||
gitCoAuthor: settings.general?.gitCoAuthor,
|
||||
output: {
|
||||
format: outputSettingsFormat,
|
||||
|
|
@ -1018,6 +1017,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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -968,15 +974,6 @@ const SETTINGS_SCHEMA = {
|
|||
},
|
||||
},
|
||||
},
|
||||
useSmartEdit: {
|
||||
type: 'boolean',
|
||||
label: 'Use Smart Edit',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Enable the smart-edit tool instead of the replace tool.',
|
||||
showInDialog: false,
|
||||
},
|
||||
security: {
|
||||
type: 'object',
|
||||
label: 'Security',
|
||||
|
|
|
|||
|
|
@ -477,7 +477,6 @@ describe('gemini.tsx main function kitty protocol', () => {
|
|||
webSearchDefault: undefined,
|
||||
screenReader: undefined,
|
||||
vlmSwitchMode: undefined,
|
||||
useSmartEdit: undefined,
|
||||
inputFormat: undefined,
|
||||
outputFormat: undefined,
|
||||
includePartialMessages: undefined,
|
||||
|
|
@ -642,9 +641,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 +662,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': '会话统计',
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { copyCommand } from '../ui/commands/copyCommand.js';
|
|||
import { docsCommand } from '../ui/commands/docsCommand.js';
|
||||
import { directoryCommand } from '../ui/commands/directoryCommand.js';
|
||||
import { editorCommand } from '../ui/commands/editorCommand.js';
|
||||
import { exportCommand } from '../ui/commands/exportCommand.js';
|
||||
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
|
||||
import { helpCommand } from '../ui/commands/helpCommand.js';
|
||||
import { ideCommand } from '../ui/commands/ideCommand.js';
|
||||
|
|
@ -67,6 +68,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
|||
docsCommand,
|
||||
directoryCommand,
|
||||
editorCommand,
|
||||
exportCommand,
|
||||
extensionsCommand,
|
||||
helpCommand,
|
||||
await ideCommand(),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
383
packages/cli/src/ui/commands/exportCommand.test.ts
Normal file
383
packages/cli/src/ui/commands/exportCommand.test.ts
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { exportCommand } from './exportCommand.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import type { ChatRecord } from '@qwen-code/qwen-code-core';
|
||||
import type { Part, Content } from '@google/genai';
|
||||
import {
|
||||
collectSessionData,
|
||||
normalizeSessionData,
|
||||
toMarkdown,
|
||||
toHtml,
|
||||
generateExportFilename,
|
||||
} from '../utils/export/index.js';
|
||||
|
||||
const mockSessionServiceMocks = vi.hoisted(() => ({
|
||||
loadLastSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', () => {
|
||||
class SessionService {
|
||||
constructor(_cwd: string) {}
|
||||
async loadLastSession() {
|
||||
return mockSessionServiceMocks.loadLastSession();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
SessionService,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../utils/export/index.js', () => ({
|
||||
collectSessionData: vi.fn(),
|
||||
normalizeSessionData: vi.fn(),
|
||||
toMarkdown: vi.fn(),
|
||||
toHtml: vi.fn(),
|
||||
generateExportFilename: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
writeFile: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('exportCommand', () => {
|
||||
const mockSessionData = {
|
||||
conversation: {
|
||||
sessionId: 'test-session-id',
|
||||
startTime: '2025-01-01T00:00:00Z',
|
||||
messages: [
|
||||
{
|
||||
type: 'user',
|
||||
message: {
|
||||
parts: [{ text: 'Hello' }] as Part[],
|
||||
} as Content,
|
||||
},
|
||||
] as ChatRecord[],
|
||||
},
|
||||
};
|
||||
|
||||
let mockContext: ReturnType<typeof createMockCommandContext>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockSessionServiceMocks.loadLastSession.mockResolvedValue(mockSessionData);
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getWorkingDir: vi.fn().mockReturnValue('/test/dir'),
|
||||
getProjectRoot: vi.fn().mockReturnValue('/test/project'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(collectSessionData).mockResolvedValue({
|
||||
sessionId: 'test-session-id',
|
||||
startTime: '2025-01-01T00:00:00Z',
|
||||
messages: [],
|
||||
});
|
||||
vi.mocked(normalizeSessionData).mockImplementation((data) => data);
|
||||
vi.mocked(toMarkdown).mockReturnValue('# Test Markdown');
|
||||
vi.mocked(toHtml).mockReturnValue(
|
||||
'<html><script id="chat-data" type="application/json">{"data": "test"}</script></html>',
|
||||
);
|
||||
vi.mocked(generateExportFilename).mockImplementation(
|
||||
(ext: string) => `export-2025-01-01T00-00-00-000Z.${ext}`,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('command structure', () => {
|
||||
it('should have correct name and description', () => {
|
||||
expect(exportCommand.name).toBe('export');
|
||||
expect(exportCommand.description).toBe(
|
||||
'Export current session message history to a file',
|
||||
);
|
||||
});
|
||||
|
||||
it('should have html, md, json, and jsonl subcommands', () => {
|
||||
expect(exportCommand.subCommands).toHaveLength(4);
|
||||
expect(exportCommand.subCommands?.map((c) => c.name)).toEqual([
|
||||
'html',
|
||||
'md',
|
||||
'json',
|
||||
'jsonl',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exportMarkdownAction', () => {
|
||||
it('should export session to markdown file', async () => {
|
||||
const mdCommand = exportCommand.subCommands?.find((c) => c.name === 'md');
|
||||
if (!mdCommand?.action) {
|
||||
throw new Error('md command not found');
|
||||
}
|
||||
|
||||
const result = await mdCommand.action(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('export-2025-01-01T00-00-00-000Z.md'),
|
||||
});
|
||||
|
||||
expect(mockSessionServiceMocks.loadLastSession).toHaveBeenCalled();
|
||||
expect(collectSessionData).toHaveBeenCalledWith(
|
||||
mockSessionData.conversation,
|
||||
expect.anything(),
|
||||
);
|
||||
expect(normalizeSessionData).toHaveBeenCalled();
|
||||
expect(toMarkdown).toHaveBeenCalled();
|
||||
expect(generateExportFilename).toHaveBeenCalledWith('md');
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('export-2025-01-01T00-00-00-000Z.md'),
|
||||
'# Test Markdown',
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error when config is not available', async () => {
|
||||
const contextWithoutConfig = createMockCommandContext({
|
||||
services: {
|
||||
config: null,
|
||||
},
|
||||
});
|
||||
|
||||
const mdCommand = exportCommand.subCommands?.find((c) => c.name === 'md');
|
||||
if (!mdCommand?.action) {
|
||||
throw new Error('md command not found');
|
||||
}
|
||||
const result = await mdCommand.action(contextWithoutConfig, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Configuration not available.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error when working directory cannot be determined', async () => {
|
||||
const contextWithoutCwd = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getWorkingDir: vi.fn().mockReturnValue(null),
|
||||
getProjectRoot: vi.fn().mockReturnValue(null),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mdCommand = exportCommand.subCommands?.find((c) => c.name === 'md');
|
||||
if (!mdCommand || !mdCommand.action) {
|
||||
throw new Error('md command not found');
|
||||
}
|
||||
const result = await mdCommand.action(contextWithoutCwd, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Could not determine current working directory.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error when no session is found', async () => {
|
||||
mockSessionServiceMocks.loadLastSession.mockResolvedValue(undefined);
|
||||
|
||||
const mdCommand = exportCommand.subCommands?.find((c) => c.name === 'md');
|
||||
if (!mdCommand?.action) {
|
||||
throw new Error('md command not found');
|
||||
}
|
||||
const result = await mdCommand.action(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'No active session found to export.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors during export', async () => {
|
||||
const error = new Error('File write failed');
|
||||
vi.mocked(fs.writeFile).mockRejectedValue(error);
|
||||
|
||||
const mdCommand = exportCommand.subCommands?.find((c) => c.name === 'md');
|
||||
if (!mdCommand?.action) {
|
||||
throw new Error('md command not found');
|
||||
}
|
||||
const result = await mdCommand.action(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Failed to export session: File write failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('should use project root when working dir is not available', async () => {
|
||||
const contextWithProjectRoot = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getWorkingDir: vi.fn().mockReturnValue(null),
|
||||
getProjectRoot: vi.fn().mockReturnValue('/test/project'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mdCommand = exportCommand.subCommands?.find((c) => c.name === 'md');
|
||||
if (!mdCommand?.action) {
|
||||
throw new Error('md command not found');
|
||||
}
|
||||
await mdCommand.action(contextWithProjectRoot, '');
|
||||
});
|
||||
});
|
||||
|
||||
describe('exportHtmlAction', () => {
|
||||
it('should export session to HTML file', async () => {
|
||||
const htmlCommand = exportCommand.subCommands?.find(
|
||||
(c) => c.name === 'html',
|
||||
);
|
||||
if (!htmlCommand?.action) {
|
||||
throw new Error('html command not found');
|
||||
}
|
||||
|
||||
const result = await htmlCommand.action(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining(
|
||||
'export-2025-01-01T00-00-00-000Z.html',
|
||||
),
|
||||
});
|
||||
|
||||
expect(mockSessionServiceMocks.loadLastSession).toHaveBeenCalled();
|
||||
expect(collectSessionData).toHaveBeenCalledWith(
|
||||
mockSessionData.conversation,
|
||||
expect.anything(),
|
||||
);
|
||||
expect(normalizeSessionData).toHaveBeenCalled();
|
||||
expect(toHtml).toHaveBeenCalled();
|
||||
expect(generateExportFilename).toHaveBeenCalledWith('html');
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('export-2025-01-01T00-00-00-000Z.html'),
|
||||
expect.stringContaining('{"data": "test"}'),
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error when config is not available', async () => {
|
||||
const contextWithoutConfig = createMockCommandContext({
|
||||
services: {
|
||||
config: null,
|
||||
},
|
||||
});
|
||||
|
||||
const htmlCommand = exportCommand.subCommands?.find(
|
||||
(c) => c.name === 'html',
|
||||
);
|
||||
if (!htmlCommand?.action) {
|
||||
throw new Error('html command not found');
|
||||
}
|
||||
const result = await htmlCommand.action(contextWithoutConfig, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Configuration not available.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error when working directory cannot be determined', async () => {
|
||||
const contextWithoutCwd = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getWorkingDir: vi.fn().mockReturnValue(null),
|
||||
getProjectRoot: vi.fn().mockReturnValue(null),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const htmlCommand = exportCommand.subCommands?.find(
|
||||
(c) => c.name === 'html',
|
||||
);
|
||||
if (!htmlCommand || !htmlCommand.action) {
|
||||
throw new Error('html command not found');
|
||||
}
|
||||
const result = await htmlCommand.action(contextWithoutCwd, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Could not determine current working directory.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error when no session is found', async () => {
|
||||
mockSessionServiceMocks.loadLastSession.mockResolvedValue(undefined);
|
||||
|
||||
const htmlCommand = exportCommand.subCommands?.find(
|
||||
(c) => c.name === 'html',
|
||||
);
|
||||
if (!htmlCommand?.action) {
|
||||
throw new Error('html command not found');
|
||||
}
|
||||
const result = await htmlCommand.action(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'No active session found to export.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors during HTML generation', async () => {
|
||||
const error = new Error('Failed to generate HTML');
|
||||
vi.mocked(toHtml).mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
const htmlCommand = exportCommand.subCommands?.find(
|
||||
(c) => c.name === 'html',
|
||||
);
|
||||
if (!htmlCommand?.action) {
|
||||
throw new Error('html command not found');
|
||||
}
|
||||
const result = await htmlCommand.action(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Failed to export session: Failed to generate HTML',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors during file write', async () => {
|
||||
const error = new Error('File write failed');
|
||||
vi.mocked(fs.writeFile).mockRejectedValue(error);
|
||||
|
||||
const htmlCommand = exportCommand.subCommands?.find(
|
||||
(c) => c.name === 'html',
|
||||
);
|
||||
if (!htmlCommand?.action) {
|
||||
throw new Error('html command not found');
|
||||
}
|
||||
const result = await htmlCommand.action(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Failed to export session: File write failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
347
packages/cli/src/ui/commands/exportCommand.ts
Normal file
347
packages/cli/src/ui/commands/exportCommand.ts
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
type CommandContext,
|
||||
type SlashCommand,
|
||||
type MessageActionReturn,
|
||||
CommandKind,
|
||||
} from './types.js';
|
||||
import { SessionService } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
collectSessionData,
|
||||
normalizeSessionData,
|
||||
toMarkdown,
|
||||
toHtml,
|
||||
toJson,
|
||||
toJsonl,
|
||||
generateExportFilename,
|
||||
} from '../utils/export/index.js';
|
||||
|
||||
/**
|
||||
* Action for the 'md' subcommand - exports session to markdown.
|
||||
*/
|
||||
async function exportMarkdownAction(
|
||||
context: CommandContext,
|
||||
): Promise<MessageActionReturn> {
|
||||
const { services } = context;
|
||||
const { config } = services;
|
||||
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Configuration not available.',
|
||||
};
|
||||
}
|
||||
|
||||
const cwd = config.getWorkingDir() || config.getProjectRoot();
|
||||
if (!cwd) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Could not determine current working directory.',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Load the current session
|
||||
const sessionService = new SessionService(cwd);
|
||||
const sessionData = await sessionService.loadLastSession();
|
||||
|
||||
if (!sessionData) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'No active session found to export.',
|
||||
};
|
||||
}
|
||||
|
||||
const { conversation } = sessionData;
|
||||
|
||||
// Collect and normalize export data (SSOT)
|
||||
const exportData = await collectSessionData(conversation, config);
|
||||
const normalizedData = normalizeSessionData(
|
||||
exportData,
|
||||
conversation.messages,
|
||||
config,
|
||||
);
|
||||
|
||||
// Generate markdown from SSOT
|
||||
const markdown = toMarkdown(normalizedData);
|
||||
|
||||
const filename = generateExportFilename('md');
|
||||
const filepath = path.join(cwd, filename);
|
||||
|
||||
// Write to file
|
||||
await fs.writeFile(filepath, markdown, 'utf-8');
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Session exported to markdown: ${filename}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Failed to export session: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Action for the 'html' subcommand - exports session to HTML.
|
||||
*/
|
||||
async function exportHtmlAction(
|
||||
context: CommandContext,
|
||||
): Promise<MessageActionReturn> {
|
||||
const { services } = context;
|
||||
const { config } = services;
|
||||
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Configuration not available.',
|
||||
};
|
||||
}
|
||||
|
||||
const cwd = config.getWorkingDir() || config.getProjectRoot();
|
||||
if (!cwd) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Could not determine current working directory.',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Load the current session
|
||||
const sessionService = new SessionService(cwd);
|
||||
const sessionData = await sessionService.loadLastSession();
|
||||
|
||||
if (!sessionData) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'No active session found to export.',
|
||||
};
|
||||
}
|
||||
|
||||
const { conversation } = sessionData;
|
||||
|
||||
// Collect and normalize export data (SSOT)
|
||||
const exportData = await collectSessionData(conversation, config);
|
||||
const normalizedData = normalizeSessionData(
|
||||
exportData,
|
||||
conversation.messages,
|
||||
config,
|
||||
);
|
||||
|
||||
// Generate HTML from SSOT
|
||||
const html = toHtml(normalizedData);
|
||||
|
||||
const filename = generateExportFilename('html');
|
||||
const filepath = path.join(cwd, filename);
|
||||
|
||||
// Write to file
|
||||
await fs.writeFile(filepath, html, 'utf-8');
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Session exported to HTML: ${filename}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Failed to export session: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Action for the 'json' subcommand - exports session to JSON.
|
||||
*/
|
||||
async function exportJsonAction(
|
||||
context: CommandContext,
|
||||
): Promise<MessageActionReturn> {
|
||||
const { services } = context;
|
||||
const { config } = services;
|
||||
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Configuration not available.',
|
||||
};
|
||||
}
|
||||
|
||||
const cwd = config.getWorkingDir() || config.getProjectRoot();
|
||||
if (!cwd) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Could not determine current working directory.',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Load the current session
|
||||
const sessionService = new SessionService(cwd);
|
||||
const sessionData = await sessionService.loadLastSession();
|
||||
|
||||
if (!sessionData) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'No active session found to export.',
|
||||
};
|
||||
}
|
||||
|
||||
const { conversation } = sessionData;
|
||||
|
||||
// Collect and normalize export data (SSOT)
|
||||
const exportData = await collectSessionData(conversation, config);
|
||||
const normalizedData = normalizeSessionData(
|
||||
exportData,
|
||||
conversation.messages,
|
||||
config,
|
||||
);
|
||||
|
||||
// Generate JSON from SSOT
|
||||
const json = toJson(normalizedData);
|
||||
|
||||
const filename = generateExportFilename('json');
|
||||
const filepath = path.join(cwd, filename);
|
||||
|
||||
// Write to file
|
||||
await fs.writeFile(filepath, json, 'utf-8');
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Session exported to JSON: ${filename}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Failed to export session: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Action for the 'jsonl' subcommand - exports session to JSONL.
|
||||
*/
|
||||
async function exportJsonlAction(
|
||||
context: CommandContext,
|
||||
): Promise<MessageActionReturn> {
|
||||
const { services } = context;
|
||||
const { config } = services;
|
||||
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Configuration not available.',
|
||||
};
|
||||
}
|
||||
|
||||
const cwd = config.getWorkingDir() || config.getProjectRoot();
|
||||
if (!cwd) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Could not determine current working directory.',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Load the current session
|
||||
const sessionService = new SessionService(cwd);
|
||||
const sessionData = await sessionService.loadLastSession();
|
||||
|
||||
if (!sessionData) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'No active session found to export.',
|
||||
};
|
||||
}
|
||||
|
||||
const { conversation } = sessionData;
|
||||
|
||||
// Collect and normalize export data (SSOT)
|
||||
const exportData = await collectSessionData(conversation, config);
|
||||
const normalizedData = normalizeSessionData(
|
||||
exportData,
|
||||
conversation.messages,
|
||||
config,
|
||||
);
|
||||
|
||||
// Generate JSONL from SSOT
|
||||
const jsonl = toJsonl(normalizedData);
|
||||
|
||||
const filename = generateExportFilename('jsonl');
|
||||
const filepath = path.join(cwd, filename);
|
||||
|
||||
// Write to file
|
||||
await fs.writeFile(filepath, jsonl, 'utf-8');
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Session exported to JSONL: ${filename}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Failed to export session: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main export command with subcommands.
|
||||
*/
|
||||
export const exportCommand: SlashCommand = {
|
||||
name: 'export',
|
||||
description: 'Export current session message history to a file',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
subCommands: [
|
||||
{
|
||||
name: 'html',
|
||||
description: 'Export session to HTML format',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: exportHtmlAction,
|
||||
},
|
||||
{
|
||||
name: 'md',
|
||||
description: 'Export session to markdown format',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: exportMarkdownAction,
|
||||
},
|
||||
{
|
||||
name: 'json',
|
||||
description: 'Export session to JSON format',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: exportJsonAction,
|
||||
},
|
||||
{
|
||||
name: 'jsonl',
|
||||
description: 'Export session to JSONL format (one message per line)',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: exportJsonlAction,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -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') },
|
||||
|
|
|
|||
|
|
@ -55,7 +55,6 @@ const renderComponent = (
|
|||
switchModel: vi.fn().mockResolvedValue(undefined),
|
||||
getAuthType: vi.fn(() => 'qwen-oauth'),
|
||||
|
||||
// --- Functions used by ClearcutLogger ---
|
||||
getUsageStatisticsEnabled: vi.fn(() => true),
|
||||
getSessionId: vi.fn(() => 'mock-session-id'),
|
||||
getDebugMode: vi.fn(() => false),
|
||||
|
|
@ -63,7 +62,6 @@ const renderComponent = (
|
|||
authType: AuthType.QWEN_OAUTH,
|
||||
model: MAINLINE_CODER,
|
||||
})),
|
||||
getUseSmartEdit: vi.fn(() => false),
|
||||
getUseModelRouter: vi.fn(() => false),
|
||||
getProxy: vi.fn(() => undefined),
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -233,7 +233,6 @@ describe('useGeminiStream', () => {
|
|||
.fn()
|
||||
.mockReturnValue(contentGeneratorConfig),
|
||||
getMaxSessionTurns: vi.fn(() => 50),
|
||||
getUseSmartEdit: () => false,
|
||||
} as unknown as Config;
|
||||
mockOnDebugMessage = vi.fn();
|
||||
mockHandleSlashCommand = vi.fn().mockResolvedValue(false);
|
||||
|
|
|
|||
|
|
@ -64,7 +64,6 @@ const mockConfig = {
|
|||
model: 'test-model',
|
||||
authType: 'gemini',
|
||||
}),
|
||||
getUseSmartEdit: () => false,
|
||||
getUseModelRouter: () => false,
|
||||
getGeminiClient: () => null, // No client needed for these tests
|
||||
getShellExecutionConfig: () => ({ terminalWidth: 80, terminalHeight: 24 }),
|
||||
|
|
|
|||
266
packages/cli/src/ui/utils/export/collect.ts
Normal file
266
packages/cli/src/ui/utils/export/collect.ts
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Config, ChatRecord } from '@qwen-code/qwen-code-core';
|
||||
import type { SessionContext } from '../../../acp-integration/session/types.js';
|
||||
import type * as acp from '../../../acp-integration/acp.js';
|
||||
import { HistoryReplayer } from '../../../acp-integration/session/HistoryReplayer.js';
|
||||
import type { ExportMessage, ExportSessionData } from './types.js';
|
||||
|
||||
/**
|
||||
* Export session context that captures session updates into export messages.
|
||||
* Implements SessionContext to work with HistoryReplayer.
|
||||
*/
|
||||
class ExportSessionContext implements SessionContext {
|
||||
readonly sessionId: string;
|
||||
readonly config: Config;
|
||||
private messages: ExportMessage[] = [];
|
||||
private currentMessage: {
|
||||
type: 'user' | 'assistant';
|
||||
role: 'user' | 'assistant' | 'thinking';
|
||||
parts: Array<{ text: string }>;
|
||||
timestamp: number;
|
||||
} | null = null;
|
||||
private activeRecordId: string | null = null;
|
||||
private activeRecordTimestamp: string | null = null;
|
||||
private toolCallMap: Map<string, ExportMessage['toolCall']> = new Map();
|
||||
|
||||
constructor(sessionId: string, config: Config) {
|
||||
this.sessionId = sessionId;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
async sendUpdate(update: acp.SessionUpdate): Promise<void> {
|
||||
switch (update.sessionUpdate) {
|
||||
case 'user_message_chunk':
|
||||
this.handleMessageChunk('user', update.content);
|
||||
break;
|
||||
case 'agent_message_chunk':
|
||||
this.handleMessageChunk('assistant', update.content);
|
||||
break;
|
||||
case 'agent_thought_chunk':
|
||||
this.handleMessageChunk('assistant', update.content, 'thinking');
|
||||
break;
|
||||
case 'tool_call':
|
||||
this.flushCurrentMessage();
|
||||
this.handleToolCallStart(update);
|
||||
break;
|
||||
case 'tool_call_update':
|
||||
this.handleToolCallUpdate(update);
|
||||
break;
|
||||
case 'plan':
|
||||
this.flushCurrentMessage();
|
||||
this.handlePlanUpdate(update);
|
||||
break;
|
||||
default:
|
||||
// Ignore other update types
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setActiveRecordId(recordId: string | null, timestamp?: string): void {
|
||||
this.activeRecordId = recordId;
|
||||
this.activeRecordTimestamp = timestamp ?? null;
|
||||
}
|
||||
|
||||
private getMessageTimestamp(): string {
|
||||
return this.activeRecordTimestamp ?? new Date().toISOString();
|
||||
}
|
||||
|
||||
private getMessageUuid(): string {
|
||||
return this.activeRecordId ?? randomUUID();
|
||||
}
|
||||
|
||||
private handleMessageChunk(
|
||||
role: 'user' | 'assistant',
|
||||
content: { type: string; text?: string },
|
||||
messageRole: 'user' | 'assistant' | 'thinking' = role,
|
||||
): void {
|
||||
if (content.type !== 'text' || !content.text) return;
|
||||
|
||||
// If we're starting a new message type, flush the previous one
|
||||
if (
|
||||
this.currentMessage &&
|
||||
(this.currentMessage.type !== role ||
|
||||
this.currentMessage.role !== messageRole)
|
||||
) {
|
||||
this.flushCurrentMessage();
|
||||
}
|
||||
|
||||
// Add to current message or create new one
|
||||
if (
|
||||
this.currentMessage &&
|
||||
this.currentMessage.type === role &&
|
||||
this.currentMessage.role === messageRole
|
||||
) {
|
||||
this.currentMessage.parts.push({ text: content.text });
|
||||
} else {
|
||||
this.currentMessage = {
|
||||
type: role,
|
||||
role: messageRole,
|
||||
parts: [{ text: content.text }],
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private handleToolCallStart(update: acp.ToolCall): void {
|
||||
const toolCall: ExportMessage['toolCall'] = {
|
||||
toolCallId: update.toolCallId,
|
||||
kind: update.kind || 'other',
|
||||
title:
|
||||
typeof update.title === 'string' ? update.title : update.title || '',
|
||||
status: update.status || 'pending',
|
||||
rawInput: update.rawInput as string | object | undefined,
|
||||
locations: update.locations,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.toolCallMap.set(update.toolCallId, toolCall);
|
||||
|
||||
// Immediately add tool call to messages to preserve order
|
||||
const uuid = this.getMessageUuid();
|
||||
this.messages.push({
|
||||
uuid,
|
||||
sessionId: this.sessionId,
|
||||
timestamp: this.getMessageTimestamp(),
|
||||
type: 'tool_call',
|
||||
toolCall,
|
||||
});
|
||||
}
|
||||
|
||||
private handleToolCallUpdate(update: {
|
||||
toolCallId: string;
|
||||
status?: 'pending' | 'in_progress' | 'completed' | 'failed' | null;
|
||||
title?: string | null;
|
||||
content?: Array<{ type: string; [key: string]: unknown }> | null;
|
||||
kind?: string | null;
|
||||
}): void {
|
||||
const toolCall = this.toolCallMap.get(update.toolCallId);
|
||||
if (toolCall) {
|
||||
// Update the tool call in place
|
||||
if (update.status) toolCall.status = update.status;
|
||||
if (update.content) toolCall.content = update.content;
|
||||
if (update.title)
|
||||
toolCall.title = typeof update.title === 'string' ? update.title : '';
|
||||
}
|
||||
}
|
||||
|
||||
private handlePlanUpdate(update: {
|
||||
entries: Array<{
|
||||
content: string;
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
priority?: string;
|
||||
}>;
|
||||
}): void {
|
||||
// Create a tool_call message for plan updates (TodoWriteTool)
|
||||
// This ensures todos appear at the correct position in the chat
|
||||
const uuid = this.getMessageUuid();
|
||||
const timestamp = this.getMessageTimestamp();
|
||||
|
||||
// Format entries as markdown checklist text for UpdatedPlanToolCall.parsePlanEntries
|
||||
const todoText = update.entries
|
||||
.map((entry) => {
|
||||
const checkbox =
|
||||
entry.status === 'completed'
|
||||
? '[x]'
|
||||
: entry.status === 'in_progress'
|
||||
? '[-]'
|
||||
: '[ ]';
|
||||
return `- ${checkbox} ${entry.content}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const todoContent = [
|
||||
{
|
||||
type: 'content' as const,
|
||||
content: {
|
||||
type: 'text',
|
||||
text: todoText,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
this.messages.push({
|
||||
uuid,
|
||||
sessionId: this.sessionId,
|
||||
timestamp,
|
||||
type: 'tool_call',
|
||||
toolCall: {
|
||||
toolCallId: uuid, // Use the same uuid as toolCallId for plan updates
|
||||
kind: 'todowrite',
|
||||
title: 'TodoWrite',
|
||||
status: 'completed',
|
||||
content: todoContent,
|
||||
timestamp: Date.parse(timestamp),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private flushCurrentMessage(): void {
|
||||
if (!this.currentMessage) return;
|
||||
|
||||
const uuid = this.getMessageUuid();
|
||||
this.messages.push({
|
||||
uuid,
|
||||
sessionId: this.sessionId,
|
||||
timestamp: this.getMessageTimestamp(),
|
||||
type: this.currentMessage.type,
|
||||
message: {
|
||||
role: this.currentMessage.role,
|
||||
parts: this.currentMessage.parts,
|
||||
},
|
||||
});
|
||||
|
||||
this.currentMessage = null;
|
||||
}
|
||||
|
||||
flushMessages(): void {
|
||||
this.flushCurrentMessage();
|
||||
}
|
||||
|
||||
getMessages(): ExportMessage[] {
|
||||
return this.messages;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects session data from ChatRecord[] using HistoryReplayer.
|
||||
* Returns the raw ExportSessionData (SSOT) without normalization.
|
||||
*/
|
||||
export async function collectSessionData(
|
||||
conversation: {
|
||||
sessionId: string;
|
||||
startTime: string;
|
||||
messages: ChatRecord[];
|
||||
},
|
||||
config: Config,
|
||||
): Promise<ExportSessionData> {
|
||||
// Create export session context
|
||||
const exportContext = new ExportSessionContext(
|
||||
conversation.sessionId,
|
||||
config,
|
||||
);
|
||||
|
||||
// Create history replayer with export context
|
||||
const replayer = new HistoryReplayer(exportContext);
|
||||
|
||||
// Replay chat records to build export messages
|
||||
await replayer.replay(conversation.messages);
|
||||
|
||||
// Flush any buffered messages
|
||||
exportContext.flushMessages();
|
||||
|
||||
// Get the export messages
|
||||
const messages = exportContext.getMessages();
|
||||
|
||||
return {
|
||||
sessionId: conversation.sessionId,
|
||||
startTime: conversation.startTime,
|
||||
messages,
|
||||
};
|
||||
}
|
||||
83
packages/cli/src/ui/utils/export/formatters/html.ts
Normal file
83
packages/cli/src/ui/utils/export/formatters/html.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ExportSessionData } from '../types.js';
|
||||
import { HTML_TEMPLATE } from './htmlTemplate.js';
|
||||
|
||||
/**
|
||||
* Escapes JSON for safe embedding in HTML.
|
||||
*/
|
||||
function escapeJsonForHtml(json: string): string {
|
||||
return json
|
||||
.replace(/<\/script/gi, '<\\/script')
|
||||
.replace(/&/g, '\\u0026')
|
||||
.replace(/</g, '\\u003c')
|
||||
.replace(/>/g, '\\u003e')
|
||||
.replace(/\u2028/g, '\\u2028')
|
||||
.replace(/\u2029/g, '\\u2029');
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the HTML template built from assets.
|
||||
*/
|
||||
export function loadHtmlTemplate(): string {
|
||||
return HTML_TEMPLATE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects JSON data into the HTML template.
|
||||
*/
|
||||
export function injectDataIntoHtmlTemplate(
|
||||
template: string,
|
||||
data: {
|
||||
sessionId: string;
|
||||
startTime: string;
|
||||
messages: unknown[];
|
||||
},
|
||||
): string {
|
||||
const jsonData = JSON.stringify(data, null, 2);
|
||||
const escapedJsonData = escapeJsonForHtml(jsonData);
|
||||
const idAttribute = 'id="chat-data"';
|
||||
const idIndex = template.indexOf(idAttribute);
|
||||
if (idIndex === -1) {
|
||||
return template;
|
||||
}
|
||||
|
||||
const openTagStart = template.lastIndexOf('<script', idIndex);
|
||||
if (openTagStart === -1) {
|
||||
return template;
|
||||
}
|
||||
|
||||
const openTagEnd = template.indexOf('>', idIndex);
|
||||
if (openTagEnd === -1) {
|
||||
return template;
|
||||
}
|
||||
|
||||
const closeTagStart = template.indexOf('</script>', openTagEnd);
|
||||
if (closeTagStart === -1) {
|
||||
return template;
|
||||
}
|
||||
|
||||
const lineStart = template.lastIndexOf('\n', openTagStart);
|
||||
const lineIndent =
|
||||
lineStart === -1 ? '' : template.slice(lineStart + 1, openTagStart);
|
||||
const indentedJson = escapedJsonData
|
||||
.split('\n')
|
||||
.map((line) => `${lineIndent}${line}`)
|
||||
.join('\n');
|
||||
|
||||
const before = template.slice(0, openTagEnd + 1);
|
||||
const after = template.slice(closeTagStart);
|
||||
return `${before}\n${indentedJson}\n${after}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts ExportSessionData to HTML format.
|
||||
*/
|
||||
export function toHtml(sessionData: ExportSessionData): string {
|
||||
const template = loadHtmlTemplate();
|
||||
return injectDataIntoHtmlTemplate(template, sessionData);
|
||||
}
|
||||
10
packages/cli/src/ui/utils/export/formatters/htmlTemplate.ts
Normal file
10
packages/cli/src/ui/utils/export/formatters/htmlTemplate.ts
Normal file
File diff suppressed because one or more lines are too long
15
packages/cli/src/ui/utils/export/formatters/json.ts
Normal file
15
packages/cli/src/ui/utils/export/formatters/json.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ExportSessionData } from '../types.js';
|
||||
|
||||
/**
|
||||
* Converts ExportSessionData to JSON format.
|
||||
* Outputs a single JSON object containing the entire session.
|
||||
*/
|
||||
export function toJson(sessionData: ExportSessionData): string {
|
||||
return JSON.stringify(sessionData, null, 2);
|
||||
}
|
||||
31
packages/cli/src/ui/utils/export/formatters/jsonl.ts
Normal file
31
packages/cli/src/ui/utils/export/formatters/jsonl.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ExportSessionData } from '../types.js';
|
||||
|
||||
/**
|
||||
* Converts ExportSessionData to JSONL (JSON Lines) format.
|
||||
* Each message is output as a separate JSON object on its own line.
|
||||
*/
|
||||
export function toJsonl(sessionData: ExportSessionData): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
// Add session metadata as the first line
|
||||
lines.push(
|
||||
JSON.stringify({
|
||||
type: 'session_metadata',
|
||||
sessionId: sessionData.sessionId,
|
||||
startTime: sessionData.startTime,
|
||||
}),
|
||||
);
|
||||
|
||||
// Add each message as a separate line
|
||||
for (const message of sessionData.messages) {
|
||||
lines.push(JSON.stringify(message));
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
225
packages/cli/src/ui/utils/export/formatters/markdown.ts
Normal file
225
packages/cli/src/ui/utils/export/formatters/markdown.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ExportSessionData, ExportMessage } from '../types.js';
|
||||
|
||||
/**
|
||||
* Converts ExportSessionData to markdown format.
|
||||
*/
|
||||
export function toMarkdown(sessionData: ExportSessionData): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
// Add header with metadata
|
||||
lines.push('# Chat Session Export\n');
|
||||
lines.push(`- **Session ID**: \`${sanitizeText(sessionData.sessionId)}\``);
|
||||
lines.push(`- **Start Time**: ${sanitizeText(sessionData.startTime)}`);
|
||||
lines.push(`- **Exported**: ${new Date().toISOString()}`);
|
||||
lines.push('\n---\n');
|
||||
|
||||
// Process each message
|
||||
for (const message of sessionData.messages) {
|
||||
if (message.type === 'user') {
|
||||
lines.push('## User\n');
|
||||
lines.push(formatMessageContent(message));
|
||||
} else if (message.type === 'assistant') {
|
||||
lines.push('## Assistant\n');
|
||||
lines.push(formatMessageContent(message));
|
||||
} else if (message.type === 'tool_call') {
|
||||
lines.push(formatToolCall(message));
|
||||
} else if (message.type === 'system') {
|
||||
lines.push('### System\n');
|
||||
// Format as blockquote
|
||||
const text = formatMessageContent(message);
|
||||
lines.push(`> ${text.replace(/\n/g, '\n> ')}`);
|
||||
}
|
||||
|
||||
lines.push('\n');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatMessageContent(message: ExportMessage): string {
|
||||
const text = extractTextFromMessage(message);
|
||||
|
||||
// Special handling for "Content from referenced files"
|
||||
// We look for the pattern: --- Content from referenced files --- ... --- End of content ---
|
||||
// and wrap the inner content in code blocks if possible.
|
||||
|
||||
// Note: This simple regex replacement might be fragile if nested, but usually this marker is top-level.
|
||||
// We'll use a replacer function to handle the wrapping.
|
||||
|
||||
const processedText = text.replace(
|
||||
/--- Content from referenced files ---\n([\s\S]*?)\n--- End of content ---/g,
|
||||
(match, content) =>
|
||||
`\n> **Referenced Files:**\n\n${createCodeBlock(content)}\n`,
|
||||
);
|
||||
|
||||
return processedText;
|
||||
}
|
||||
|
||||
function formatToolCall(message: ExportMessage): string {
|
||||
if (!message.toolCall) return '';
|
||||
|
||||
const lines: string[] = [];
|
||||
const { title, status, rawInput, content, locations } = message.toolCall;
|
||||
|
||||
const titleStr = typeof title === 'string' ? title : JSON.stringify(title);
|
||||
|
||||
lines.push(`### Tool: ${sanitizeText(titleStr)}`);
|
||||
lines.push(`**Status**: ${sanitizeText(status)}\n`);
|
||||
|
||||
// Input
|
||||
if (rawInput) {
|
||||
lines.push('**Input:**');
|
||||
const inputStr =
|
||||
typeof rawInput === 'string'
|
||||
? rawInput
|
||||
: JSON.stringify(rawInput, null, 2);
|
||||
lines.push(createCodeBlock(inputStr, 'json'));
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Locations
|
||||
if (locations && locations.length > 0) {
|
||||
lines.push('**Affected Files:**');
|
||||
for (const loc of locations) {
|
||||
const lineSuffix = loc.line ? `:${loc.line}` : '';
|
||||
lines.push(`- \`${sanitizeText(loc.path)}${lineSuffix}\``);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Output Content
|
||||
if (content && content.length > 0) {
|
||||
lines.push('**Output:**');
|
||||
|
||||
for (const item of content) {
|
||||
if (item.type === 'content' && item['content']) {
|
||||
const contentData = item['content'] as { type: string; text?: string };
|
||||
if (contentData.type === 'text' && contentData.text) {
|
||||
// Try to infer language from locations if available and if there is only one location
|
||||
// or if the tool title suggests a file operation.
|
||||
let language = '';
|
||||
if (locations && locations.length === 1 && locations[0].path) {
|
||||
language = getLanguageFromPath(locations[0].path);
|
||||
}
|
||||
|
||||
lines.push(createCodeBlock(contentData.text, language));
|
||||
}
|
||||
} else if (item.type === 'diff') {
|
||||
const path = item['path'] as string;
|
||||
const diffText = item['newText'] as string;
|
||||
lines.push(`\n*Diff for \`${sanitizeText(path)}\`:*`);
|
||||
lines.push(createCodeBlock(diffText, 'diff'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts text content from an export message.
|
||||
*/
|
||||
function extractTextFromMessage(message: ExportMessage): string {
|
||||
if (!message.message?.parts) return '';
|
||||
|
||||
const textParts: string[] = [];
|
||||
for (const part of message.message.parts) {
|
||||
if ('text' in part) {
|
||||
textParts.push(part.text);
|
||||
}
|
||||
}
|
||||
|
||||
return textParts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a markdown code block with dynamic fence length to avoid escaping issues.
|
||||
* Does NOT escape HTML content inside the block, as that would break code readability.
|
||||
* Security is handled by the fence.
|
||||
*/
|
||||
function createCodeBlock(content: string, language: string = ''): string {
|
||||
const fence = buildFence(content);
|
||||
return `${fence}${language}\n${content}\n${fence}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes text to prevent HTML injection while preserving Markdown.
|
||||
* Only escapes < and & to avoid breaking Markdown structures like code blocks (if used inline) or quotes.
|
||||
*/
|
||||
function sanitizeText(value: string): string {
|
||||
return (value ?? '').replace(/&/g, '&').replace(/</g, '<');
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the necessary fence length for a code block.
|
||||
* Ensures the fence is longer than any sequence of backticks in the content.
|
||||
*/
|
||||
function buildFence(value: string): string {
|
||||
const matches = (value ?? '').match(/`+/g);
|
||||
const maxRun = matches
|
||||
? Math.max(...matches.map((match) => match.length))
|
||||
: 0;
|
||||
const fenceLength = Math.max(3, maxRun + 1);
|
||||
return '`'.repeat(fenceLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple helper to guess language from file extension.
|
||||
*/
|
||||
function getLanguageFromPath(path: string): string {
|
||||
const ext = path.split('.').pop()?.toLowerCase();
|
||||
switch (ext) {
|
||||
case 'ts':
|
||||
case 'tsx':
|
||||
return 'typescript';
|
||||
case 'js':
|
||||
case 'jsx':
|
||||
case 'mjs':
|
||||
case 'cjs':
|
||||
return 'javascript';
|
||||
case 'py':
|
||||
return 'python';
|
||||
case 'rb':
|
||||
return 'ruby';
|
||||
case 'go':
|
||||
return 'go';
|
||||
case 'rs':
|
||||
return 'rust';
|
||||
case 'java':
|
||||
return 'java';
|
||||
case 'c':
|
||||
case 'cpp':
|
||||
case 'h':
|
||||
case 'hpp':
|
||||
return 'cpp';
|
||||
case 'cs':
|
||||
return 'csharp';
|
||||
case 'html':
|
||||
return 'html';
|
||||
case 'css':
|
||||
return 'css';
|
||||
case 'json':
|
||||
return 'json';
|
||||
case 'md':
|
||||
return 'markdown';
|
||||
case 'sh':
|
||||
case 'bash':
|
||||
case 'zsh':
|
||||
return 'bash';
|
||||
case 'yaml':
|
||||
case 'yml':
|
||||
return 'yaml';
|
||||
case 'xml':
|
||||
return 'xml';
|
||||
case 'sql':
|
||||
return 'sql';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
18
packages/cli/src/ui/utils/export/index.ts
Normal file
18
packages/cli/src/ui/utils/export/index.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export type { ExportMessage, ExportSessionData } from './types.js';
|
||||
export { collectSessionData } from './collect.js';
|
||||
export { normalizeSessionData } from './normalize.js';
|
||||
export { toMarkdown } from './formatters/markdown.js';
|
||||
export {
|
||||
toHtml,
|
||||
loadHtmlTemplate,
|
||||
injectDataIntoHtmlTemplate,
|
||||
} from './formatters/html.js';
|
||||
export { toJson } from './formatters/json.js';
|
||||
export { toJsonl } from './formatters/jsonl.js';
|
||||
export { generateExportFilename } from './utils.js';
|
||||
324
packages/cli/src/ui/utils/export/normalize.ts
Normal file
324
packages/cli/src/ui/utils/export/normalize.ts
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Part } from '@google/genai';
|
||||
import { ExitPlanModeTool, ToolNames } from '@qwen-code/qwen-code-core';
|
||||
import type { ChatRecord, Config, Kind } from '@qwen-code/qwen-code-core';
|
||||
import type { ExportMessage, ExportSessionData } from './types.js';
|
||||
|
||||
/**
|
||||
* Normalizes export session data by merging tool call information from tool_result records.
|
||||
* This ensures the SSOT contains complete tool call metadata.
|
||||
*/
|
||||
export function normalizeSessionData(
|
||||
sessionData: ExportSessionData,
|
||||
originalRecords: ChatRecord[],
|
||||
config: Config,
|
||||
): ExportSessionData {
|
||||
const normalized = [...sessionData.messages];
|
||||
const toolCallIndexById = new Map<string, number>();
|
||||
|
||||
// Build index of tool call messages
|
||||
normalized.forEach((message, index) => {
|
||||
if (message.type === 'tool_call' && message.toolCall?.toolCallId) {
|
||||
toolCallIndexById.set(message.toolCall.toolCallId, index);
|
||||
}
|
||||
});
|
||||
|
||||
// Merge tool result information into tool call messages
|
||||
for (const record of originalRecords) {
|
||||
if (record.type !== 'tool_result') continue;
|
||||
|
||||
const toolCallMessage = buildToolCallMessageFromResult(record, config);
|
||||
if (!toolCallMessage?.toolCall) continue;
|
||||
|
||||
const existingIndex = toolCallIndexById.get(
|
||||
toolCallMessage.toolCall.toolCallId,
|
||||
);
|
||||
|
||||
if (existingIndex === undefined) {
|
||||
// No existing tool call, add this one
|
||||
toolCallIndexById.set(
|
||||
toolCallMessage.toolCall.toolCallId,
|
||||
normalized.length,
|
||||
);
|
||||
normalized.push(toolCallMessage);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Merge into existing tool call
|
||||
const existingMessage = normalized[existingIndex];
|
||||
if (existingMessage.type !== 'tool_call' || !existingMessage.toolCall) {
|
||||
continue;
|
||||
}
|
||||
|
||||
mergeToolCallData(existingMessage.toolCall, toolCallMessage.toolCall);
|
||||
}
|
||||
|
||||
return {
|
||||
...sessionData,
|
||||
messages: normalized,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges incoming tool call data into existing tool call.
|
||||
*/
|
||||
function mergeToolCallData(
|
||||
existing: NonNullable<ExportMessage['toolCall']>,
|
||||
incoming: NonNullable<ExportMessage['toolCall']>,
|
||||
): void {
|
||||
if (!existing.content || existing.content.length === 0) {
|
||||
existing.content = incoming.content;
|
||||
}
|
||||
if (existing.status === 'pending' || existing.status === 'in_progress') {
|
||||
existing.status = incoming.status;
|
||||
}
|
||||
if (!existing.rawInput && incoming.rawInput) {
|
||||
existing.rawInput = incoming.rawInput;
|
||||
}
|
||||
if (!existing.kind || existing.kind === 'other') {
|
||||
existing.kind = incoming.kind;
|
||||
}
|
||||
if ((!existing.title || existing.title === '') && incoming.title) {
|
||||
existing.title = incoming.title;
|
||||
}
|
||||
if (
|
||||
(!existing.locations || existing.locations.length === 0) &&
|
||||
incoming.locations &&
|
||||
incoming.locations.length > 0
|
||||
) {
|
||||
existing.locations = incoming.locations;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a tool call message from a tool_result ChatRecord.
|
||||
*/
|
||||
function buildToolCallMessageFromResult(
|
||||
record: ChatRecord,
|
||||
config: Config,
|
||||
): ExportMessage | null {
|
||||
const toolCallResult = record.toolCallResult;
|
||||
const toolName = extractToolNameFromRecord(record);
|
||||
|
||||
// Skip todo_write tool - it's already handled by plan update in collect.ts
|
||||
// This prevents duplicate todo messages in the export
|
||||
if (toolName === ToolNames.TODO_WRITE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toolCallId = toolCallResult?.callId ?? record.uuid;
|
||||
const functionCallArgs = extractFunctionCallArgs(record);
|
||||
const { kind, title, locations } = resolveToolMetadata(
|
||||
config,
|
||||
toolName,
|
||||
functionCallArgs ??
|
||||
(toolCallResult as { args?: Record<string, unknown> } | undefined)?.args,
|
||||
);
|
||||
const rawInput = normalizeRawInput(
|
||||
functionCallArgs ??
|
||||
(toolCallResult as { args?: unknown } | undefined)?.args,
|
||||
);
|
||||
|
||||
const content =
|
||||
extractDiffContent(toolCallResult?.resultDisplay) ??
|
||||
transformPartsToToolCallContent(record.message?.parts ?? []);
|
||||
|
||||
return {
|
||||
uuid: record.uuid,
|
||||
parentUuid: record.parentUuid,
|
||||
sessionId: record.sessionId,
|
||||
timestamp: record.timestamp,
|
||||
type: 'tool_call',
|
||||
toolCall: {
|
||||
toolCallId,
|
||||
kind,
|
||||
title,
|
||||
status: toolCallResult?.error ? 'failed' : 'completed',
|
||||
rawInput,
|
||||
content,
|
||||
locations,
|
||||
timestamp: Date.parse(record.timestamp),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts tool name from a ChatRecord.
|
||||
*/
|
||||
function extractToolNameFromRecord(record: ChatRecord): string {
|
||||
if (!record.message?.parts) {
|
||||
return '';
|
||||
}
|
||||
|
||||
for (const part of record.message.parts) {
|
||||
if ('functionResponse' in part && part.functionResponse?.name) {
|
||||
return part.functionResponse.name;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts function call args from a ChatRecord.
|
||||
*/
|
||||
function extractFunctionCallArgs(
|
||||
record: ChatRecord,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!record.message?.parts) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const part of record.message.parts) {
|
||||
if ('functionCall' in part && part.functionCall?.args) {
|
||||
return part.functionCall.args as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves tool metadata (kind, title, locations) from tool registry.
|
||||
*/
|
||||
function resolveToolMetadata(
|
||||
config: Config,
|
||||
toolName: string,
|
||||
args?: Record<string, unknown>,
|
||||
): {
|
||||
kind: string;
|
||||
title: string | object;
|
||||
locations?: Array<{ path: string; line?: number | null }>;
|
||||
} {
|
||||
const toolRegistry = config.getToolRegistry?.();
|
||||
const tool = toolName ? toolRegistry?.getTool?.(toolName) : undefined;
|
||||
|
||||
let title: string | object = tool?.displayName ?? toolName ?? 'tool_call';
|
||||
let locations: Array<{ path: string; line?: number | null }> | undefined;
|
||||
const kind = mapToolKind(tool?.kind as Kind | undefined, toolName);
|
||||
|
||||
if (tool && args) {
|
||||
try {
|
||||
const invocation = tool.build(args);
|
||||
title = `${title}: ${invocation.getDescription()}`;
|
||||
locations = invocation.toolLocations().map((loc) => ({
|
||||
path: loc.path,
|
||||
line: loc.line ?? null,
|
||||
}));
|
||||
} catch {
|
||||
// Keep defaults on build failure
|
||||
}
|
||||
}
|
||||
|
||||
return { kind, title, locations };
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps tool kind to allowed export kinds.
|
||||
*/
|
||||
function mapToolKind(kind: Kind | undefined, toolName?: string): string {
|
||||
if (toolName && toolName === ExitPlanModeTool.Name) {
|
||||
return 'switch_mode';
|
||||
}
|
||||
|
||||
if (toolName && toolName === ToolNames.TODO_WRITE) {
|
||||
return 'todowrite';
|
||||
}
|
||||
|
||||
const allowedKinds = new Set<string>([
|
||||
'read',
|
||||
'edit',
|
||||
'delete',
|
||||
'move',
|
||||
'search',
|
||||
'execute',
|
||||
'think',
|
||||
'fetch',
|
||||
'other',
|
||||
]);
|
||||
|
||||
if (kind && allowedKinds.has(kind)) {
|
||||
return kind;
|
||||
}
|
||||
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts diff content from tool result display.
|
||||
*/
|
||||
function extractDiffContent(
|
||||
resultDisplay: unknown,
|
||||
): Array<{ type: string; [key: string]: unknown }> | null {
|
||||
if (!resultDisplay || typeof resultDisplay !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const display = resultDisplay as Record<string, unknown>;
|
||||
if ('fileName' in display && 'newContent' in display) {
|
||||
return [
|
||||
{
|
||||
type: 'diff',
|
||||
path: display['fileName'] as string,
|
||||
oldText: (display['originalContent'] as string) ?? '',
|
||||
newText: display['newContent'] as string,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes raw input to string or object.
|
||||
*/
|
||||
function normalizeRawInput(value: unknown): string | object | undefined {
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'object' && value !== null) return value;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms Parts to tool call content array.
|
||||
*/
|
||||
function transformPartsToToolCallContent(
|
||||
parts: Part[],
|
||||
): Array<{ type: string; [key: string]: unknown }> {
|
||||
const content: Array<{ type: string; [key: string]: unknown }> = [];
|
||||
|
||||
for (const part of parts) {
|
||||
if ('text' in part && part.text) {
|
||||
content.push({
|
||||
type: 'content',
|
||||
content: { type: 'text', text: part.text },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if ('functionResponse' in part && part.functionResponse) {
|
||||
const response = part.functionResponse.response as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const outputField = response?.['output'];
|
||||
const errorField = response?.['error'];
|
||||
const responseText =
|
||||
typeof outputField === 'string'
|
||||
? outputField
|
||||
: typeof errorField === 'string'
|
||||
? errorField
|
||||
: JSON.stringify(response);
|
||||
content.push({
|
||||
type: 'content',
|
||||
content: { type: 'text', text: responseText },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
54
packages/cli/src/ui/utils/export/types.ts
Normal file
54
packages/cli/src/ui/utils/export/types.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Universal export message format - SSOT for all export formats.
|
||||
* This is format-agnostic and contains all information needed for any export type.
|
||||
*/
|
||||
export interface ExportMessage {
|
||||
uuid: string;
|
||||
parentUuid?: string | null;
|
||||
sessionId?: string;
|
||||
timestamp: string;
|
||||
type: 'user' | 'assistant' | 'system' | 'tool_call';
|
||||
|
||||
/** For user/assistant messages */
|
||||
message?: {
|
||||
role?: string;
|
||||
parts?: Array<{ text: string }>;
|
||||
content?: string;
|
||||
};
|
||||
|
||||
/** Model used for assistant messages */
|
||||
model?: string;
|
||||
|
||||
/** For tool_call messages */
|
||||
toolCall?: {
|
||||
toolCallId: string;
|
||||
kind: string;
|
||||
title: string | object;
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
rawInput?: string | object;
|
||||
content?: Array<{
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
locations?: Array<{
|
||||
path: string;
|
||||
line?: number | null;
|
||||
}>;
|
||||
timestamp?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete export session data - the single source of truth.
|
||||
*/
|
||||
export interface ExportSessionData {
|
||||
sessionId: string;
|
||||
startTime: string;
|
||||
messages: ExportMessage[];
|
||||
}
|
||||
13
packages/cli/src/ui/utils/export/utils.ts
Normal file
13
packages/cli/src/ui/utils/export/utils.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generates a filename with timestamp for export files.
|
||||
*/
|
||||
export function generateExportFilename(extension: string): string {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
return `qwen-code-export-${timestamp}.${extension}`;
|
||||
}
|
||||
|
|
@ -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