From b6a3ab11e0d85bdacecf704bbb4a3881aab63f30 Mon Sep 17 00:00:00 2001 From: kefuxin Date: Thu, 11 Dec 2025 14:23:27 +0800 Subject: [PATCH 01/40] fix: improve Gemini compatibility by adding configurable schema converter This commit addresses issue #1186 by introducing a configurable schema compliance mechanism for tool definitions sent to LLMs. Key changes: 1. **New Configuration**: Added `model.generationConfig.schemaCompliance` setting (defaults to 'auto', optional 'openapi_30'). 2. **Schema Converter**: Implemented `toOpenAPI30` converter in `packages/core` to strictly downgrade modern JSON Schema to OpenAPI 3.0.3 (required for Gemini API), handling: - Nullable types (`["string", "null"]` -> `nullable: true`) - Numeric exclusive limits - Const to Enum conversion - Removal of tuples and invalid keywords (``, `dependencies`, etc.) 3. **Tests**: Added comprehensive unit tests for the schema converter and updated pipeline tests. Fixes #1186 --- packages/cli/src/config/settingsSchema.ts | 16 +++ packages/core/src/core/contentGenerator.ts | 2 + .../core/openaiContentGenerator/converter.ts | 12 +- .../openaiContentGenerator/pipeline.test.ts | 5 +- .../core/openaiContentGenerator/pipeline.ts | 1 + .../core/src/utils/schemaConverter.test.ts | 118 +++++++++++++++ packages/core/src/utils/schemaConverter.ts | 135 ++++++++++++++++++ 7 files changed, 287 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/utils/schemaConverter.test.ts create mode 100644 packages/core/src/utils/schemaConverter.ts diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index d95f4dbb..340cab77 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -628,6 +628,22 @@ const SETTINGS_SCHEMA = { childKey: 'disableCacheControl', showInDialog: true, }, + schemaCompliance: { + type: 'enum', + label: 'Tool Schema Compliance', + category: 'Generation Configuration', + requiresRestart: false, + default: 'auto', + description: + 'The compliance mode for tool schemas sent to the model. Use "openapi_30" for strict OpenAPI 3.0 compatibility (e.g., for Gemini).', + parentKey: 'generationConfig', + childKey: 'schemaCompliance', + showInDialog: true, + options: [ + { value: 'auto', label: 'Auto (Default)' }, + { value: 'openapi_30', label: 'OpenAPI 3.0 Strict' }, + ], + }, }, }, }, diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index 6b480d42..75614ae6 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -76,6 +76,8 @@ export type ContentGeneratorConfig = { }; proxy?: string | undefined; userAgent?: string; + // Schema compliance mode for tool definitions + schemaCompliance?: 'auto' | 'openapi_30'; }; export function createContentGeneratorConfig( diff --git a/packages/core/src/core/openaiContentGenerator/converter.ts b/packages/core/src/core/openaiContentGenerator/converter.ts index b22eb963..c046ec33 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.ts @@ -22,6 +22,10 @@ import { GenerateContentResponse, FinishReason } from '@google/genai'; import type OpenAI from 'openai'; import { safeJsonParse } from '../../utils/safeJsonParse.js'; import { StreamingToolCallParser } from './streamingToolCallParser.js'; +import { + convertSchema, + type SchemaComplianceMode, +} from '../../utils/schemaConverter.js'; /** * Extended usage type that supports both OpenAI standard format and alternative formats @@ -80,11 +84,13 @@ interface ParsedParts { */ export class OpenAIContentConverter { private model: string; + private schemaCompliance: SchemaComplianceMode; private streamingToolCallParser: StreamingToolCallParser = new StreamingToolCallParser(); - constructor(model: string) { + constructor(model: string, schemaCompliance: SchemaComplianceMode = 'auto') { this.model = model; + this.schemaCompliance = schemaCompliance; } /** @@ -207,6 +213,10 @@ export class OpenAIContentConverter { ); } + if (parameters) { + parameters = convertSchema(parameters, this.schemaCompliance); + } + openAITools.push({ type: 'function', function: { diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts index c92e7a79..6ffb1623 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts @@ -108,7 +108,10 @@ describe('ContentGenerationPipeline', () => { describe('constructor', () => { it('should initialize with correct configuration', () => { expect(mockProvider.buildClient).toHaveBeenCalled(); - expect(OpenAIContentConverter).toHaveBeenCalledWith('test-model'); + expect(OpenAIContentConverter).toHaveBeenCalledWith( + 'test-model', + undefined, + ); }); }); diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.ts b/packages/core/src/core/openaiContentGenerator/pipeline.ts index c24d247f..9b40d903 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.ts @@ -34,6 +34,7 @@ export class ContentGenerationPipeline { this.client = this.config.provider.buildClient(); this.converter = new OpenAIContentConverter( this.contentGeneratorConfig.model, + this.contentGeneratorConfig.schemaCompliance, ); } diff --git a/packages/core/src/utils/schemaConverter.test.ts b/packages/core/src/utils/schemaConverter.test.ts new file mode 100644 index 00000000..3d36ce4d --- /dev/null +++ b/packages/core/src/utils/schemaConverter.test.ts @@ -0,0 +1,118 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { convertSchema } from './schemaConverter.js'; + +describe('convertSchema', () => { + describe('mode: auto (default)', () => { + it('should preserve type arrays', () => { + const input = { type: ['string', 'null'] }; + expect(convertSchema(input, 'auto')).toEqual(input); + }); + + it('should preserve items array (tuples)', () => { + const input = { + type: 'array', + items: [{ type: 'string' }, { type: 'number' }], + }; + expect(convertSchema(input, 'auto')).toEqual(input); + }); + + it('should preserve mixed enums', () => { + const input = { enum: [1, 2, '3'] }; + expect(convertSchema(input, 'auto')).toEqual(input); + }); + + it('should preserve unsupported keywords', () => { + const input = { + $schema: 'http://json-schema.org/draft-07/schema#', + exclusiveMinimum: 10, + type: 'number', + }; + expect(convertSchema(input, 'auto')).toEqual(input); + }); + }); + + describe('mode: openapi_30 (strict)', () => { + it('should convert type arrays to nullable', () => { + const input = { type: ['string', 'null'] }; + const expected = { type: 'string', nullable: true }; + expect(convertSchema(input, 'openapi_30')).toEqual(expected); + }); + + it('should fallback to first type for non-nullable arrays', () => { + const input = { type: ['string', 'number'] }; + const expected = { type: 'string' }; + expect(convertSchema(input, 'openapi_30')).toEqual(expected); + }); + + it('should convert const to enum', () => { + const input = { const: 'foo' }; + const expected = { enum: ['foo'] }; + expect(convertSchema(input, 'openapi_30')).toEqual(expected); + }); + + it('should convert exclusiveMinimum number to boolean', () => { + const input = { type: 'number', exclusiveMinimum: 10 }; + const expected = { + type: 'number', + minimum: 10, + exclusiveMinimum: true, + }; + expect(convertSchema(input, 'openapi_30')).toEqual(expected); + }); + + it('should convert nested objects recursively', () => { + const input = { + type: 'object', + properties: { + prop1: { type: ['integer', 'null'], exclusiveMaximum: 5 }, + }, + }; + const expected = { + type: 'object', + properties: { + prop1: { + type: 'integer', + nullable: true, + maximum: 5, + exclusiveMaximum: true, + }, + }, + }; + expect(convertSchema(input, 'openapi_30')).toEqual(expected); + }); + + it('should stringify enums', () => { + const input = { enum: [1, 2, '3'] }; + const expected = { enum: ['1', '2', '3'] }; + expect(convertSchema(input, 'openapi_30')).toEqual(expected); + }); + + it('should remove tuple items (array of schemas)', () => { + const input = { + type: 'array', + items: [{ type: 'string' }, { type: 'number' }], + }; + const expected = { type: 'array' }; + expect(convertSchema(input, 'openapi_30')).toEqual(expected); + }); + + it('should remove unsupported keywords', () => { + const input = { + $schema: 'http://json-schema.org/draft-07/schema#', + $id: '#foo', + type: 'string', + default: 'bar', + dependencies: { foo: ['bar'] }, + patternProperties: { '^foo': { type: 'string' } }, + }; + const expected = { type: 'string' }; + expect(convertSchema(input, 'openapi_30')).toEqual(expected); + }); + }); +}); diff --git a/packages/core/src/utils/schemaConverter.ts b/packages/core/src/utils/schemaConverter.ts new file mode 100644 index 00000000..9d8a45a7 --- /dev/null +++ b/packages/core/src/utils/schemaConverter.ts @@ -0,0 +1,135 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Utility for converting JSON Schemas to be compatible with different LLM providers. + * Specifically focuses on downgrading modern JSON Schema (Draft 7/2020-12) to + * OpenAPI 3.0 compatible Schema Objects, which is required for Google Gemini API. + */ + +export type SchemaComplianceMode = 'auto' | 'openapi_30'; + +/** + * Converts a JSON Schema to be compatible with the specified compliance mode. + */ +export function convertSchema( + schema: Record, + mode: SchemaComplianceMode = 'auto', +): Record { + if (mode === 'openapi_30') { + return toOpenAPI30(schema); + } + + // Default ('auto') mode now does nothing. + return schema; +} + +/** + * Converts Modern JSON Schema to OpenAPI 3.0 Schema Object. + * Attempts to preserve semantics where possible through transformations. + */ +function toOpenAPI30(schema: Record): Record { + const convert = (obj: unknown): unknown => { + if (typeof obj !== 'object' || obj === null) { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map(convert); + } + + const source = obj as Record; + const target: Record = {}; + + // 1. Type Handling + if (Array.isArray(source['type'])) { + const types = source['type'] as string[]; + // Handle ["string", "null"] pattern common in modern schemas + if (types.length === 2 && types.includes('null')) { + target['type'] = types.find((t) => t !== 'null'); + target['nullable'] = true; + } else { + // Fallback for other unions: take the first non-null type + // OpenAPI 3.0 doesn't support type arrays. + // Ideal fix would be anyOf, but simple fallback is safer for now. + target['type'] = types[0]; + } + } else if (source['type'] !== undefined) { + target['type'] = source['type']; + } + + // 2. Const Handling (Draft 6+) -> Enum (OpenAPI 3.0) + if (source['const'] !== undefined) { + target['enum'] = [source['const']]; + delete target['const']; + } + + // 3. Exclusive Limits (Draft 6+ number) -> (Draft 4 boolean) + // exclusiveMinimum: 10 -> minimum: 10, exclusiveMinimum: true + if (typeof source['exclusiveMinimum'] === 'number') { + target['minimum'] = source['exclusiveMinimum']; + target['exclusiveMinimum'] = true; + } + if (typeof source['exclusiveMaximum'] === 'number') { + target['maximum'] = source['exclusiveMaximum']; + target['exclusiveMaximum'] = true; + } + + // 4. Array Items (Tuple -> Single Schema) + // OpenAPI 3.0 items must be a schema object, not an array of schemas + if (Array.isArray(source['items'])) { + // Tuple support is tricky. + // Best effort: Use the first item's schema as a generic array type + // or convert to an empty object (any type) if mixed. + // For now, we'll strip it to allow validation to pass (accepts any items) + // This matches the legacy behavior but is explicit. + // Ideally, we could use `oneOf` on the items if we wanted to be stricter. + delete target['items']; + } else if ( + typeof source['items'] === 'object' && + source['items'] !== null + ) { + target['items'] = convert(source['items']); + } + + // 5. Enum Stringification + // Gemini strictly requires enums to be strings + if (Array.isArray(source['enum'])) { + target['enum'] = source['enum'].map(String); + } + + // 6. Recursively process other properties + for (const [key, value] of Object.entries(source)) { + // Skip fields we've already handled or want to remove + if ( + key === 'type' || + key === 'const' || + key === 'exclusiveMinimum' || + key === 'exclusiveMaximum' || + key === 'items' || + key === 'enum' || + key === '$schema' || + key === '$id' || + key === 'default' || // Optional: Gemini sometimes complains about defaults conflicting with types + key === 'dependencies' || + key === 'patternProperties' + ) { + continue; + } + + target[key] = convert(value); + } + + // Preserve default if it doesn't conflict (simple pass-through) + // if (source['default'] !== undefined) { + // target['default'] = source['default']; + // } + + return target; + }; + + return convert(schema) as Record; +} From 84cccfe99a7049cc52e931c6518c5578fda2346c Mon Sep 17 00:00:00 2001 From: kefuxin Date: Thu, 11 Dec 2025 14:30:38 +0800 Subject: [PATCH 02/40] feat: add i18n for schemaCompliance setting --- packages/cli/src/i18n/locales/en.js | 1 + packages/cli/src/i18n/locales/zh.js | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index c2217757..ddf1d4a7 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -310,6 +310,7 @@ export default { 'Tool Output Truncation Lines': 'Tool Output Truncation Lines', 'Folder Trust': 'Folder Trust', 'Vision Model Preview': 'Vision Model Preview', + 'Tool Schema Compliance': 'Tool Schema Compliance', // Settings enum options 'Auto (detect from system)': 'Auto (detect from system)', Text: 'Text', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index adeb85f1..e82c586a 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -300,6 +300,7 @@ export default { 'Tool Output Truncation Lines': '工具输出截断行数', 'Folder Trust': '文件夹信任', 'Vision Model Preview': '视觉模型预览', + 'Tool Schema Compliance': '工具 Schema 兼容性', // Settings enum options 'Auto (detect from system)': '自动(从系统检测)', Text: '文本', From 68295d0bbf87484f06db5571258b8094d8f3376a Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Wed, 10 Dec 2025 13:00:07 +0100 Subject: [PATCH 03/40] Rename leftover Gemini references to Qwen in UI strings --- packages/cli/src/config/settingsSchema.ts | 6 +++--- packages/cli/src/i18n/locales/en.js | 4 ++-- packages/cli/src/i18n/locales/zh.js | 4 ++-- packages/cli/src/ui/commands/directoryCommand.tsx | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index d95f4dbb..957ef7e4 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -263,7 +263,7 @@ const SETTINGS_SCHEMA = { requiresRestart: false, default: false, description: - 'Show Gemini CLI status and thoughts in the terminal window title', + 'Show Qwen Code status and thoughts in the terminal window title', showInDialog: true, }, hideTips: { @@ -291,7 +291,7 @@ const SETTINGS_SCHEMA = { requiresRestart: false, default: false, description: - 'Hide the context summary (GEMINI.md, MCP servers) above the input.', + 'Hide the context summary (QWEN.md, MCP servers) above the input.', showInDialog: true, }, footer: { @@ -497,7 +497,7 @@ const SETTINGS_SCHEMA = { category: 'Model', requiresRestart: false, default: undefined as string | undefined, - description: 'The Gemini model to use for conversations.', + description: 'The model to use for conversations.', showInDialog: false, }, maxSessionTurns: { diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index c2217757..a58ea0db 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -635,8 +635,8 @@ export default { 'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.': 'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.', "Error adding '{{path}}': {{error}}": "Error adding '{{path}}': {{error}}", - 'Successfully added GEMINI.md files from the following directories if there are:\n- {{directories}}': - 'Successfully added GEMINI.md files from the following directories if there are:\n- {{directories}}', + 'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}': + 'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}', 'Error refreshing memory: {{error}}': 'Error refreshing memory: {{error}}', 'Successfully added directories:\n- {{directories}}': 'Successfully added directories:\n- {{directories}}', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index adeb85f1..bf8efa2c 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -601,8 +601,8 @@ export default { 'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.': '/directory add 命令在限制性沙箱配置文件中不受支持。请改为在启动会话时使用 --include-directories。', "Error adding '{{path}}': {{error}}": "添加 '{{path}}' 时出错:{{error}}", - 'Successfully added GEMINI.md files from the following directories if there are:\n- {{directories}}': - '如果存在,已成功从以下目录添加 GEMINI.md 文件:\n- {{directories}}', + 'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}': + '如果存在,已成功从以下目录添加 QWEN.md 文件:\n- {{directories}}', 'Error refreshing memory: {{error}}': '刷新内存时出错:{{error}}', 'Successfully added directories:\n- {{directories}}': '成功添加目录:\n- {{directories}}', diff --git a/packages/cli/src/ui/commands/directoryCommand.tsx b/packages/cli/src/ui/commands/directoryCommand.tsx index e44530b7..536cc9bb 100644 --- a/packages/cli/src/ui/commands/directoryCommand.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.tsx @@ -130,7 +130,7 @@ export const directoryCommand: SlashCommand = { { type: MessageType.INFO, text: t( - 'Successfully added GEMINI.md files from the following directories if there are:\n- {{directories}}', + 'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}', { directories: added.join('\n- '), }, From 6d3cf4cd98b2d373ff645934126f0a155d3706cb Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Wed, 10 Dec 2025 13:13:56 +0100 Subject: [PATCH 04/40] Rrename Gemini references to Qwen and fix IDE connection path --- packages/cli/src/ui/commands/restoreCommand.ts | 2 +- .../cli/src/ui/components/PermissionsModifyTrustDialog.tsx | 2 +- packages/core/src/ide/ide-client.test.ts | 6 +++--- packages/core/src/ide/ide-client.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/ui/commands/restoreCommand.ts b/packages/cli/src/ui/commands/restoreCommand.ts index 5076e536..fce63327 100644 --- a/packages/cli/src/ui/commands/restoreCommand.ts +++ b/packages/cli/src/ui/commands/restoreCommand.ts @@ -28,7 +28,7 @@ async function restoreAction( return { type: 'message', messageType: 'error', - content: 'Could not determine the .gemini directory path.', + content: 'Could not determine the .qwen directory path.', }; } diff --git a/packages/cli/src/ui/components/PermissionsModifyTrustDialog.tsx b/packages/cli/src/ui/components/PermissionsModifyTrustDialog.tsx index 94da2078..dfed5ba4 100644 --- a/packages/cli/src/ui/components/PermissionsModifyTrustDialog.tsx +++ b/packages/cli/src/ui/components/PermissionsModifyTrustDialog.tsx @@ -115,7 +115,7 @@ export function PermissionsModifyTrustDialog({ {needsRestart && ( - To apply the trust changes, Gemini CLI must be restarted. Press + To apply the trust changes, Qwen Code must be restarted. Press 'r' to restart CLI now. diff --git a/packages/core/src/ide/ide-client.test.ts b/packages/core/src/ide/ide-client.test.ts index ca26f78f..e80565a2 100644 --- a/packages/core/src/ide/ide-client.test.ts +++ b/packages/core/src/ide/ide-client.test.ts @@ -324,7 +324,7 @@ describe('IdeClient', () => { expect(result).toEqual(config); expect(fs.promises.readFile).toHaveBeenCalledWith( - path.join('/tmp/gemini/ide', 'qwen-code-ide-server-12345-123.json'), + path.join('/tmp', 'qwen-code-ide-server-12345-123.json'), 'utf8', ); }); @@ -518,11 +518,11 @@ describe('IdeClient', () => { expect(result).toEqual(validConfig); expect(fs.promises.readFile).toHaveBeenCalledWith( - path.join('/tmp/gemini/ide', 'qwen-code-ide-server-12345-111.json'), + path.join('/tmp', 'qwen-code-ide-server-12345-111.json'), 'utf8', ); expect(fs.promises.readFile).not.toHaveBeenCalledWith( - path.join('/tmp/gemini/ide', 'not-a-config-file.txt'), + path.join('/tmp', 'not-a-config-file.txt'), 'utf8', ); }); diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index b447f46c..e49b81c7 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -591,7 +591,7 @@ export class IdeClient { // exist. } - const portFileDir = path.join(os.tmpdir(), 'gemini', 'ide'); + const portFileDir = os.tmpdir(); let portFiles; try { portFiles = await fs.promises.readdir(portFileDir); From 95d3e5b744fa9fdf9d8a3b01c91f600192f8c249 Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Wed, 10 Dec 2025 13:27:34 +0100 Subject: [PATCH 05/40] Rename more references --- packages/cli/src/config/extension.ts | 2 +- packages/cli/src/ui/commands/restoreCommand.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index c9fde128..2d30cc41 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -581,7 +581,7 @@ function extensionConsentString(extensionConfig: ExtensionConfig): string { } if (extensionConfig.contextFileName) { output.push( - `This extension will append info to your gemini.md context using ${extensionConfig.contextFileName}`, + `This extension will append info to your QWEN.md context using ${extensionConfig.contextFileName}`, ); } if (extensionConfig.excludeTools) { diff --git a/packages/cli/src/ui/commands/restoreCommand.test.ts b/packages/cli/src/ui/commands/restoreCommand.test.ts index a60ee502..a786c3a7 100644 --- a/packages/cli/src/ui/commands/restoreCommand.test.ts +++ b/packages/cli/src/ui/commands/restoreCommand.test.ts @@ -89,7 +89,7 @@ describe('restoreCommand', () => { ).toEqual({ type: 'message', messageType: 'error', - content: 'Could not determine the .gemini directory path.', + content: 'Could not determine the .qwen directory path.', }); }); From bf905dcc173ca8477d981599879d90a393f79d34 Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Wed, 10 Dec 2025 13:48:01 +0100 Subject: [PATCH 06/40] Rename GEMINI_CLI_NO_RELAUNCH to QWEN_CODE_NO_RELAUNCH --- packages/cli/src/gemini.test.tsx | 8 ++++---- packages/cli/src/gemini.tsx | 2 +- packages/cli/src/utils/relaunch.test.ts | 10 +++++----- packages/cli/src/utils/relaunch.ts | 4 ++-- scripts/start.js | 2 +- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index f602d17d..e34396ed 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -379,8 +379,8 @@ describe('gemini.tsx main function kitty protocol', () => { beforeEach(() => { // Set no relaunch in tests since process spawning causing issues in tests - originalEnvNoRelaunch = process.env['GEMINI_CLI_NO_RELAUNCH']; - process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true'; + originalEnvNoRelaunch = process.env['QWEN_CODE_NO_RELAUNCH']; + process.env['QWEN_CODE_NO_RELAUNCH'] = 'true'; // eslint-disable-next-line @typescript-eslint/no-explicit-any if (!(process.stdin as any).setRawMode) { @@ -402,9 +402,9 @@ describe('gemini.tsx main function kitty protocol', () => { afterEach(() => { // Restore original env variables if (originalEnvNoRelaunch !== undefined) { - process.env['GEMINI_CLI_NO_RELAUNCH'] = originalEnvNoRelaunch; + process.env['QWEN_CODE_NO_RELAUNCH'] = originalEnvNoRelaunch; } else { - delete process.env['GEMINI_CLI_NO_RELAUNCH']; + delete process.env['QWEN_CODE_NO_RELAUNCH']; } }); diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 18f191bc..88eb1c52 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -92,7 +92,7 @@ function getNodeMemoryArgs(isDebugMode: boolean): string[] { ); } - if (process.env['GEMINI_CLI_NO_RELAUNCH']) { + if (process.env['QWEN_CODE_NO_RELAUNCH']) { return []; } diff --git a/packages/cli/src/utils/relaunch.test.ts b/packages/cli/src/utils/relaunch.test.ts index e627a07a..0ed8c485 100644 --- a/packages/cli/src/utils/relaunch.test.ts +++ b/packages/cli/src/utils/relaunch.test.ts @@ -115,7 +115,7 @@ describe('relaunchAppInChildProcess', () => { vi.clearAllMocks(); process.env = { ...originalEnv }; - delete process.env['GEMINI_CLI_NO_RELAUNCH']; + delete process.env['QWEN_CODE_NO_RELAUNCH']; process.execArgv = [...originalExecArgv]; process.argv = [...originalArgv]; @@ -145,9 +145,9 @@ describe('relaunchAppInChildProcess', () => { stdinResumeSpy.mockRestore(); }); - describe('when GEMINI_CLI_NO_RELAUNCH is set', () => { + describe('when QWEN_CODE_NO_RELAUNCH is set', () => { it('should return early without spawning a child process', async () => { - process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true'; + process.env['QWEN_CODE_NO_RELAUNCH'] = 'true'; await relaunchAppInChildProcess(['--test'], ['--verbose']); @@ -156,9 +156,9 @@ describe('relaunchAppInChildProcess', () => { }); }); - describe('when GEMINI_CLI_NO_RELAUNCH is not set', () => { + describe('when QWEN_CODE_NO_RELAUNCH is not set', () => { beforeEach(() => { - delete process.env['GEMINI_CLI_NO_RELAUNCH']; + delete process.env['QWEN_CODE_NO_RELAUNCH']; }); it('should construct correct node arguments from execArgv, additionalNodeArgs, script, additionalScriptArgs, and argv', () => { diff --git a/packages/cli/src/utils/relaunch.ts b/packages/cli/src/utils/relaunch.ts index 1142efc7..80d243c7 100644 --- a/packages/cli/src/utils/relaunch.ts +++ b/packages/cli/src/utils/relaunch.ts @@ -27,7 +27,7 @@ export async function relaunchAppInChildProcess( additionalNodeArgs: string[], additionalScriptArgs: string[], ) { - if (process.env['GEMINI_CLI_NO_RELAUNCH']) { + if (process.env['QWEN_CODE_NO_RELAUNCH']) { return; } @@ -44,7 +44,7 @@ export async function relaunchAppInChildProcess( ...additionalScriptArgs, ...scriptArgs, ]; - const newEnv = { ...process.env, GEMINI_CLI_NO_RELAUNCH: 'true' }; + const newEnv = { ...process.env, QWEN_CODE_NO_RELAUNCH: 'true' }; // The parent process should not be reading from stdin while the child is running. process.stdin.pause(); diff --git a/scripts/start.js b/scripts/start.js index baa9fd98..49037b79 100644 --- a/scripts/start.js +++ b/scripts/start.js @@ -67,7 +67,7 @@ const env = { if (process.env.DEBUG) { // If this is not set, the debugger will pause on the outer process rather // than the relaunched process making it harder to debug. - env.GEMINI_CLI_NO_RELAUNCH = 'true'; + env.QWEN_CODE_NO_RELAUNCH = 'true'; } // Use process.cwd() to inherit the working directory from launch.json cwd setting // This allows debugging from a specific directory (e.g., .todo) From 44794121a838c7ea65584914caaab1e630de4616 Mon Sep 17 00:00:00 2001 From: kefuxin Date: Fri, 12 Dec 2025 10:38:00 +0800 Subject: [PATCH 07/40] docs: update MCP server schema compliance documentation Update documentation to reflect the new `schemaCompliance` setting and detailed OpenAPI 3.0 transformation rules. Suggested-by: afarber --- docs/tools/mcp-server.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/tools/mcp-server.md b/docs/tools/mcp-server.md index 4a75b1fc..8d48970a 100644 --- a/docs/tools/mcp-server.md +++ b/docs/tools/mcp-server.md @@ -627,7 +627,12 @@ The MCP integration tracks several states: ### Schema Compatibility -- **Property stripping:** The system automatically removes certain schema properties (`$schema`, `additionalProperties`) for Qwen API compatibility +- **Schema compliance mode:** By default (`schemaCompliance: "auto"`), tool schemas are passed through as-is. Set `"model": { "generationConfig": { "schemaCompliance": "openapi_30" } }` in your `settings.json` to convert models to Strict OpenAPI 3.0 format. +- **OpenAPI 3.0 transformations:** When `openapi_30` mode is enabled, the system handles: + - Nullable types: `["string", "null"]` -> `type: "string", nullable: true` + - Const values: `const: "foo"` -> `enum: ["foo"]` + - Exclusive limits: numeric `exclusiveMinimum` -> boolean form with `minimum` + - Keyword removal: `$schema`, `$id`, `dependencies`, `patternProperties` - **Name sanitization:** Tool names are automatically sanitized to meet API requirements - **Conflict resolution:** Tool name conflicts between servers are resolved through automatic prefixing From 6c77303172ae1b8457ee5d21a7b274fb4f66d220 Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Fri, 12 Dec 2025 18:50:01 +0100 Subject: [PATCH 08/40] Add /resume slash command to switch between previous sessions --- .../cli/src/services/BuiltinCommandLoader.ts | 2 + packages/cli/src/ui/commands/resumeCommand.ts | 89 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 packages/cli/src/ui/commands/resumeCommand.ts diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index d3877a8a..c9fc5801 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -29,6 +29,7 @@ import { modelCommand } from '../ui/commands/modelCommand.js'; import { permissionsCommand } from '../ui/commands/permissionsCommand.js'; import { quitCommand } from '../ui/commands/quitCommand.js'; import { restoreCommand } from '../ui/commands/restoreCommand.js'; +import { resumeCommand } from '../ui/commands/resumeCommand.js'; import { settingsCommand } from '../ui/commands/settingsCommand.js'; import { statsCommand } from '../ui/commands/statsCommand.js'; import { summaryCommand } from '../ui/commands/summaryCommand.js'; @@ -76,6 +77,7 @@ export class BuiltinCommandLoader implements ICommandLoader { ...(this.config?.getFolderTrust() ? [permissionsCommand] : []), quitCommand, restoreCommand(this.config), + resumeCommand, statsCommand, summaryCommand, themeCommand, diff --git a/packages/cli/src/ui/commands/resumeCommand.ts b/packages/cli/src/ui/commands/resumeCommand.ts new file mode 100644 index 00000000..96aac2c0 --- /dev/null +++ b/packages/cli/src/ui/commands/resumeCommand.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + SlashCommand, + SlashCommandActionReturn, + CommandContext, +} from './types.js'; +import { CommandKind } from './types.js'; +import { t } from '../../i18n/index.js'; +import { showResumeSessionPicker } from '../components/ResumeSessionPicker.js'; +import { + SessionService, + buildApiHistoryFromConversation, + replayUiTelemetryFromConversation, + uiTelemetryService, +} from '@qwen-code/qwen-code-core'; +import { buildResumedHistoryItems } from '../utils/resumeHistoryUtils.js'; + +export const resumeCommand: SlashCommand = { + name: 'resume', + kind: CommandKind.BUILT_IN, + get description() { + return t('Resume a previous session'); + }, + action: async ( + context: CommandContext, + ): Promise => { + const { config } = context.services; + + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Config not available', + }; + } + + // Show the session picker + const cwd = config.getTargetDir(); + const selectedSessionId = await showResumeSessionPicker(cwd); + + if (!selectedSessionId) { + // User cancelled + return; + } + + // Load the session data + const sessionService = new SessionService(cwd); + const sessionData = await sessionService.loadSession(selectedSessionId); + + if (!sessionData) { + return { + type: 'message', + messageType: 'error', + content: `Could not load session: ${selectedSessionId}`, + }; + } + + // Reset and replay UI telemetry to restore metrics + uiTelemetryService.reset(); + replayUiTelemetryFromConversation(sessionData.conversation); + + // Build UI history items using existing utility + const uiHistoryWithIds = buildResumedHistoryItems(sessionData, config); + // Strip IDs for LoadHistoryActionReturn (IDs are re-assigned by loadHistory) + const uiHistory = uiHistoryWithIds.map(({ id: _id, ...rest }) => rest); + + // Build API history for the LLM client + const clientHistory = buildApiHistoryFromConversation( + sessionData.conversation, + ); + + // Update session in config and context + config.startNewSession(selectedSessionId); + if (context.session.startNewSession) { + context.session.startNewSession(selectedSessionId); + } + + return { + type: 'load_history', + history: uiHistory, + clientHistory, + }; + }, +}; From a761be80a54777e329d8821e6b528e035a4b35eb Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Fri, 12 Dec 2025 19:06:34 +0100 Subject: [PATCH 09/40] Filter out empty sessions --- .../src/ui/components/ResumeSessionPicker.tsx | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/ui/components/ResumeSessionPicker.tsx b/packages/cli/src/ui/components/ResumeSessionPicker.tsx index 0057d700..c520d5b9 100644 --- a/packages/cli/src/ui/components/ResumeSessionPicker.tsx +++ b/packages/cli/src/ui/components/ResumeSessionPicker.tsx @@ -72,13 +72,18 @@ function SessionPicker({ }; }, []); - // Filter sessions by current branch if filter is enabled - const filteredSessions = - filterByBranch && currentBranch - ? sessionState.sessions.filter( - (session) => session.gitBranch === currentBranch, - ) - : sessionState.sessions; + // Filter sessions: exclude empty sessions (0 messages) and optionally by branch + const filteredSessions = sessionState.sessions.filter((session) => { + // Always exclude sessions with no messages + if (session.messageCount === 0) { + return false; + } + // Apply branch filter if enabled + if (filterByBranch && currentBranch) { + return session.gitBranch === currentBranch; + } + return true; + }); const hasSentinel = sessionState.hasMore; From 2de50ae436ab79c76fd3e99e27b06600a7d6f341 Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Fri, 12 Dec 2025 20:55:54 +0100 Subject: [PATCH 10/40] Add tests --- .../components/ResumeSessionPicker.test.tsx | 587 ++++++++++++++++++ .../src/ui/components/ResumeSessionPicker.tsx | 6 +- 2 files changed, 591 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/ui/components/ResumeSessionPicker.test.tsx diff --git a/packages/cli/src/ui/components/ResumeSessionPicker.test.tsx b/packages/cli/src/ui/components/ResumeSessionPicker.test.tsx new file mode 100644 index 00000000..bd63c3ef --- /dev/null +++ b/packages/cli/src/ui/components/ResumeSessionPicker.test.tsx @@ -0,0 +1,587 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from 'ink-testing-library'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { SessionPicker } from './ResumeSessionPicker.js'; +import type { + SessionListItem, + ListSessionsResult, +} from '@qwen-code/qwen-code-core'; + +// Mock terminal size +const mockTerminalSize = { columns: 80, rows: 24 }; + +beforeEach(() => { + Object.defineProperty(process.stdout, 'columns', { + value: mockTerminalSize.columns, + configurable: true, + }); + Object.defineProperty(process.stdout, 'rows', { + value: mockTerminalSize.rows, + configurable: true, + }); +}); + +// Helper to create mock sessions +function createMockSession( + overrides: Partial = {}, +): SessionListItem { + return { + sessionId: 'test-session-id', + cwd: '/test/path', + startTime: '2025-01-01T00:00:00.000Z', + mtime: Date.now(), + prompt: 'Test prompt', + gitBranch: 'main', + filePath: '/test/path/sessions/test-session-id.jsonl', + messageCount: 5, + ...overrides, + }; +} + +// Helper to create mock session service +function createMockSessionService( + sessions: SessionListItem[] = [], + hasMore = false, +) { + return { + listSessions: vi.fn().mockResolvedValue({ + items: sessions, + hasMore, + nextCursor: hasMore ? Date.now() : undefined, + } as ListSessionsResult), + loadSession: vi.fn(), + loadLastSession: vi + .fn() + .mockResolvedValue(sessions.length > 0 ? {} : undefined), + }; +} + +describe('SessionPicker', () => { + const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms)); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('Empty Sessions Filtering', () => { + it('should filter out sessions with 0 messages', async () => { + const sessions = [ + createMockSession({ + sessionId: 'empty-1', + messageCount: 0, + prompt: '', + }), + createMockSession({ + sessionId: 'with-messages', + messageCount: 5, + prompt: 'Hello', + }), + createMockSession({ + sessionId: 'empty-2', + messageCount: 0, + prompt: '(empty prompt)', + }), + ]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame } = render( + , + ); + + await wait(100); + + const output = lastFrame(); + // Should show the session with messages + expect(output).toContain('Hello'); + // Should NOT show empty sessions + expect(output).not.toContain('empty-1'); + expect(output).not.toContain('empty-2'); + }); + + it('should show "No sessions found" when all sessions are empty', async () => { + const sessions = [ + createMockSession({ sessionId: 'empty-1', messageCount: 0 }), + createMockSession({ sessionId: 'empty-2', messageCount: 0 }), + ]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame } = render( + , + ); + + await wait(100); + + const output = lastFrame(); + expect(output).toContain('No sessions found'); + }); + + it('should show sessions with 1 or more messages', async () => { + const sessions = [ + createMockSession({ + sessionId: 'one-msg', + messageCount: 1, + prompt: 'Single message', + }), + createMockSession({ + sessionId: 'many-msg', + messageCount: 10, + prompt: 'Many messages', + }), + ]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame } = render( + , + ); + + await wait(100); + + const output = lastFrame(); + expect(output).toContain('Single message'); + expect(output).toContain('Many messages'); + expect(output).toContain('1 message'); + expect(output).toContain('10 messages'); + }); + }); + + describe('Branch Filtering', () => { + it('should filter by branch when B is pressed', async () => { + const sessions = [ + createMockSession({ + sessionId: 's1', + gitBranch: 'main', + prompt: 'Main branch', + messageCount: 1, + }), + createMockSession({ + sessionId: 's2', + gitBranch: 'feature', + prompt: 'Feature branch', + messageCount: 1, + }), + createMockSession({ + sessionId: 's3', + gitBranch: 'main', + prompt: 'Also main', + messageCount: 1, + }), + ]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame, stdin } = render( + , + ); + + await wait(100); + + // All sessions should be visible initially + let output = lastFrame(); + expect(output).toContain('Main branch'); + expect(output).toContain('Feature branch'); + + // Press B to filter by branch + stdin.write('B'); + await wait(50); + + output = lastFrame(); + // Only main branch sessions should be visible + expect(output).toContain('Main branch'); + expect(output).toContain('Also main'); + expect(output).not.toContain('Feature branch'); + }); + + it('should combine empty session filter with branch filter', async () => { + const sessions = [ + createMockSession({ + sessionId: 's1', + gitBranch: 'main', + messageCount: 0, + prompt: 'Empty main', + }), + createMockSession({ + sessionId: 's2', + gitBranch: 'main', + messageCount: 5, + prompt: 'Valid main', + }), + createMockSession({ + sessionId: 's3', + gitBranch: 'feature', + messageCount: 5, + prompt: 'Valid feature', + }), + ]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame, stdin } = render( + , + ); + + await wait(100); + + // Press B to filter by branch + stdin.write('B'); + await wait(50); + + const output = lastFrame(); + // Should only show non-empty sessions from main branch + expect(output).toContain('Valid main'); + expect(output).not.toContain('Empty main'); + expect(output).not.toContain('Valid feature'); + }); + }); + + describe('Keyboard Navigation', () => { + it('should navigate with arrow keys', async () => { + const sessions = [ + createMockSession({ + sessionId: 's1', + prompt: 'First session', + messageCount: 1, + }), + createMockSession({ + sessionId: 's2', + prompt: 'Second session', + messageCount: 1, + }), + createMockSession({ + sessionId: 's3', + prompt: 'Third session', + messageCount: 1, + }), + ]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame, stdin } = render( + , + ); + + await wait(100); + + // First session should be selected initially (indicated by >) + let output = lastFrame(); + expect(output).toContain('First session'); + + // Navigate down + stdin.write('\u001B[B'); // Down arrow + await wait(50); + + output = lastFrame(); + // Selection indicator should move + expect(output).toBeDefined(); + }); + + it('should navigate with vim keys (j/k)', async () => { + const sessions = [ + createMockSession({ + sessionId: 's1', + prompt: 'First', + messageCount: 1, + }), + createMockSession({ + sessionId: 's2', + prompt: 'Second', + messageCount: 1, + }), + ]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { stdin, unmount } = render( + , + ); + + await wait(100); + + // Navigate with j (down) + stdin.write('j'); + await wait(50); + + // Navigate with k (up) + stdin.write('k'); + await wait(50); + + unmount(); + }); + + it('should select session on Enter', async () => { + const sessions = [ + createMockSession({ + sessionId: 'selected-session', + prompt: 'Select me', + messageCount: 1, + }), + ]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { stdin } = render( + , + ); + + await wait(100); + + // Press Enter to select + stdin.write('\r'); + await wait(50); + + expect(onSelect).toHaveBeenCalledWith('selected-session'); + }); + + it('should cancel on Escape', async () => { + const sessions = [ + createMockSession({ sessionId: 's1', messageCount: 1 }), + ]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { stdin } = render( + , + ); + + await wait(100); + + // Press Escape to cancel + stdin.write('\u001B'); + await wait(50); + + expect(onCancel).toHaveBeenCalled(); + expect(onSelect).not.toHaveBeenCalled(); + }); + }); + + describe('Display', () => { + it('should show session metadata', async () => { + const sessions = [ + createMockSession({ + sessionId: 's1', + prompt: 'Test prompt text', + messageCount: 5, + gitBranch: 'feature-branch', + }), + ]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame } = render( + , + ); + + await wait(100); + + const output = lastFrame(); + expect(output).toContain('Test prompt text'); + expect(output).toContain('5 messages'); + expect(output).toContain('feature-branch'); + }); + + it('should show header and footer', async () => { + const sessions = [createMockSession({ messageCount: 1 })]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame } = render( + , + ); + + await wait(100); + + const output = lastFrame(); + expect(output).toContain('Resume Session'); + expect(output).toContain('to navigate'); + expect(output).toContain('Esc to cancel'); + }); + + it('should show branch toggle hint when currentBranch is provided', async () => { + const sessions = [createMockSession({ messageCount: 1 })]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame } = render( + , + ); + + await wait(100); + + const output = lastFrame(); + expect(output).toContain('B'); + expect(output).toContain('toggle branch'); + }); + + it('should truncate long prompts', async () => { + const longPrompt = 'A'.repeat(300); + const sessions = [ + createMockSession({ prompt: longPrompt, messageCount: 1 }), + ]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame } = render( + , + ); + + await wait(100); + + const output = lastFrame(); + // Should contain ellipsis for truncated text + expect(output).toContain('...'); + // Should NOT contain the full untruncated prompt (300 A's in a row) + expect(output).not.toContain(longPrompt); + }); + + it('should show "(empty prompt)" for sessions without prompt text', async () => { + const sessions = [createMockSession({ prompt: '', messageCount: 1 })]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame } = render( + , + ); + + await wait(100); + + const output = lastFrame(); + expect(output).toContain('(empty prompt)'); + }); + }); + + describe('Pagination', () => { + it('should load more sessions when scrolling to bottom', async () => { + const firstPage = Array.from({ length: 5 }, (_, i) => + createMockSession({ + sessionId: `session-${i}`, + prompt: `Session ${i}`, + messageCount: 1, + mtime: Date.now() - i * 1000, + }), + ); + const secondPage = Array.from({ length: 3 }, (_, i) => + createMockSession({ + sessionId: `session-${i + 5}`, + prompt: `Session ${i + 5}`, + messageCount: 1, + mtime: Date.now() - (i + 5) * 1000, + }), + ); + + const mockService = { + listSessions: vi + .fn() + .mockResolvedValueOnce({ + items: firstPage, + hasMore: true, + nextCursor: Date.now() - 5000, + }) + .mockResolvedValueOnce({ + items: secondPage, + hasMore: false, + nextCursor: undefined, + }), + loadSession: vi.fn(), + loadLastSession: vi.fn().mockResolvedValue({}), + }; + + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { unmount } = render( + , + ); + + await wait(200); + + // First page should be loaded + expect(mockService.listSessions).toHaveBeenCalled(); + + unmount(); + }); + }); +}); diff --git a/packages/cli/src/ui/components/ResumeSessionPicker.tsx b/packages/cli/src/ui/components/ResumeSessionPicker.tsx index c520d5b9..45acc915 100644 --- a/packages/cli/src/ui/components/ResumeSessionPicker.tsx +++ b/packages/cli/src/ui/components/ResumeSessionPicker.tsx @@ -17,7 +17,8 @@ import { formatRelativeTime } from '../utils/formatters.js'; const PAGE_SIZE = 20; -interface SessionPickerProps { +// Exported for testing +export interface SessionPickerProps { sessionService: SessionService; currentBranch?: string; onSelect: (sessionId: string) => void; @@ -33,7 +34,8 @@ function truncateText(text: string, maxWidth: number): string { return text.slice(0, maxWidth - 3) + '...'; } -function SessionPicker({ +// Exported for testing +export function SessionPicker({ sessionService, currentBranch, onSelect, From 12877ac8497809ef9647c58c4be3ed5ff69e4fc7 Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Fri, 12 Dec 2025 21:34:26 +0100 Subject: [PATCH 11/40] Refactor /resume command to use dialog instead of standalone Ink app --- packages/cli/src/ui/AppContainer.tsx | 71 +++- packages/cli/src/ui/commands/resumeCommand.ts | 78 +--- packages/cli/src/ui/commands/types.ts | 3 +- .../cli/src/ui/components/DialogManager.tsx | 14 + .../src/ui/components/ResumeSessionDialog.tsx | 346 ++++++++++++++++++ .../cli/src/ui/contexts/UIActionsContext.tsx | 4 + .../cli/src/ui/contexts/UIStateContext.tsx | 1 + .../cli/src/ui/hooks/slashCommandProcessor.ts | 4 + packages/cli/src/ui/hooks/useResumeCommand.ts | 25 ++ 9 files changed, 471 insertions(+), 75 deletions(-) create mode 100644 packages/cli/src/ui/components/ResumeSessionDialog.tsx create mode 100644 packages/cli/src/ui/hooks/useResumeCommand.ts diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index ff16c53d..2ee02db4 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -53,6 +53,7 @@ import { useEditorSettings } from './hooks/useEditorSettings.js'; import { useSettingsCommand } from './hooks/useSettingsCommand.js'; import { useModelCommand } from './hooks/useModelCommand.js'; import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js'; +import { useResumeCommand } from './hooks/useResumeCommand.js'; import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; import { useVimMode } from './contexts/VimModeContext.js'; import { useConsoleMessages } from './hooks/useConsoleMessages.js'; @@ -203,7 +204,7 @@ export const AppContainer = (props: AppContainerProps) => { const { stdout } = useStdout(); // Additional hooks moved from App.tsx - const { stats: sessionStats } = useSessionStats(); + const { stats: sessionStats, startNewSession } = useSessionStats(); const logger = useLogger(config.storage, sessionStats.sessionId); const branchName = useGitBranchName(config.getTargetDir()); @@ -435,6 +436,62 @@ export const AppContainer = (props: AppContainerProps) => { const { isModelDialogOpen, openModelDialog, closeModelDialog } = useModelCommand(); + const { isResumeDialogOpen, openResumeDialog, closeResumeDialog } = + useResumeCommand(); + + // Handle resume session selection + const handleResumeSessionSelect = useCallback( + async (sessionId: string) => { + if (!config) return; + + const { + SessionService, + buildApiHistoryFromConversation, + replayUiTelemetryFromConversation, + uiTelemetryService, + } = await import('@qwen-code/qwen-code-core'); + const { buildResumedHistoryItems } = await import( + './utils/resumeHistoryUtils.js' + ); + + const cwd = config.getTargetDir(); + const sessionService = new SessionService(cwd); + const sessionData = await sessionService.loadSession(sessionId); + + if (!sessionData) { + closeResumeDialog(); + return; + } + + // Reset and replay UI telemetry to restore metrics + uiTelemetryService.reset(); + replayUiTelemetryFromConversation(sessionData.conversation); + + // Build UI history items using existing utility + const uiHistoryItems = buildResumedHistoryItems(sessionData, config); + + // Build API history for the LLM client + const clientHistory = buildApiHistoryFromConversation( + sessionData.conversation, + ); + + // Update client history + config.getGeminiClient()?.setHistory(clientHistory); + config.getGeminiClient()?.stripThoughtsFromHistory(); + + // Update session in config + config.startNewSession(sessionId); + startNewSession(sessionId); + + // Clear and load history + historyManager.clearItems(); + historyManager.loadHistory(uiHistoryItems); + + closeResumeDialog(); + }, + [config, closeResumeDialog, historyManager, startNewSession], + ); + const { showWorkspaceMigrationDialog, workspaceExtensions, @@ -488,6 +545,7 @@ export const AppContainer = (props: AppContainerProps) => { addConfirmUpdateExtensionRequest, openSubagentCreateDialog, openAgentsManagerDialog, + openResumeDialog, }), [ openAuthDialog, @@ -502,6 +560,7 @@ export const AppContainer = (props: AppContainerProps) => { addConfirmUpdateExtensionRequest, openSubagentCreateDialog, openAgentsManagerDialog, + openResumeDialog, ], ); @@ -1222,6 +1281,7 @@ export const AppContainer = (props: AppContainerProps) => { isModelDialogOpen, isPermissionsDialogOpen, isApprovalModeDialogOpen, + isResumeDialogOpen, slashCommands, pendingSlashCommandHistoryItems, commandContext, @@ -1312,6 +1372,7 @@ export const AppContainer = (props: AppContainerProps) => { isModelDialogOpen, isPermissionsDialogOpen, isApprovalModeDialogOpen, + isResumeDialogOpen, slashCommands, pendingSlashCommandHistoryItems, commandContext, @@ -1421,6 +1482,10 @@ export const AppContainer = (props: AppContainerProps) => { // Subagent dialogs closeSubagentCreateDialog, closeAgentsManagerDialog, + // Resume session dialog + openResumeDialog, + closeResumeDialog, + handleResumeSessionSelect, }), [ handleThemeSelect, @@ -1453,6 +1518,10 @@ export const AppContainer = (props: AppContainerProps) => { // Subagent dialogs closeSubagentCreateDialog, closeAgentsManagerDialog, + // Resume session dialog + openResumeDialog, + closeResumeDialog, + handleResumeSessionSelect, ], ); diff --git a/packages/cli/src/ui/commands/resumeCommand.ts b/packages/cli/src/ui/commands/resumeCommand.ts index 96aac2c0..20592bf3 100644 --- a/packages/cli/src/ui/commands/resumeCommand.ts +++ b/packages/cli/src/ui/commands/resumeCommand.ts @@ -4,21 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { - SlashCommand, - SlashCommandActionReturn, - CommandContext, -} from './types.js'; +import type { SlashCommand, SlashCommandActionReturn } from './types.js'; import { CommandKind } from './types.js'; import { t } from '../../i18n/index.js'; -import { showResumeSessionPicker } from '../components/ResumeSessionPicker.js'; -import { - SessionService, - buildApiHistoryFromConversation, - replayUiTelemetryFromConversation, - uiTelemetryService, -} from '@qwen-code/qwen-code-core'; -import { buildResumedHistoryItems } from '../utils/resumeHistoryUtils.js'; export const resumeCommand: SlashCommand = { name: 'resume', @@ -26,64 +14,8 @@ export const resumeCommand: SlashCommand = { get description() { return t('Resume a previous session'); }, - action: async ( - context: CommandContext, - ): Promise => { - const { config } = context.services; - - if (!config) { - return { - type: 'message', - messageType: 'error', - content: 'Config not available', - }; - } - - // Show the session picker - const cwd = config.getTargetDir(); - const selectedSessionId = await showResumeSessionPicker(cwd); - - if (!selectedSessionId) { - // User cancelled - return; - } - - // Load the session data - const sessionService = new SessionService(cwd); - const sessionData = await sessionService.loadSession(selectedSessionId); - - if (!sessionData) { - return { - type: 'message', - messageType: 'error', - content: `Could not load session: ${selectedSessionId}`, - }; - } - - // Reset and replay UI telemetry to restore metrics - uiTelemetryService.reset(); - replayUiTelemetryFromConversation(sessionData.conversation); - - // Build UI history items using existing utility - const uiHistoryWithIds = buildResumedHistoryItems(sessionData, config); - // Strip IDs for LoadHistoryActionReturn (IDs are re-assigned by loadHistory) - const uiHistory = uiHistoryWithIds.map(({ id: _id, ...rest }) => rest); - - // Build API history for the LLM client - const clientHistory = buildApiHistoryFromConversation( - sessionData.conversation, - ); - - // Update session in config and context - config.startNewSession(selectedSessionId); - if (context.session.startNewSession) { - context.session.startNewSession(selectedSessionId); - } - - return { - type: 'load_history', - history: uiHistory, - clientHistory, - }; - }, + action: async (): Promise => ({ + type: 'dialog', + dialog: 'resume', + }), }; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index f2ec2173..8bcc872f 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -124,7 +124,8 @@ export interface OpenDialogActionReturn { | 'subagent_create' | 'subagent_list' | 'permissions' - | 'approval-mode'; + | 'approval-mode' + | 'resume'; } /** diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index d696c87a..c0907400 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -36,6 +36,7 @@ import { WelcomeBackDialog } from './WelcomeBackDialog.js'; import { ModelSwitchDialog } from './ModelSwitchDialog.js'; import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js'; import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js'; +import { ResumeSessionDialog } from './ResumeSessionDialog.js'; interface DialogManagerProps { addItem: UseHistoryManagerReturn['addItem']; @@ -290,5 +291,18 @@ export const DialogManager = ({ ); } + if (uiState.isResumeDialogOpen) { + return ( + + ); + } + return null; }; diff --git a/packages/cli/src/ui/components/ResumeSessionDialog.tsx b/packages/cli/src/ui/components/ResumeSessionDialog.tsx new file mode 100644 index 00000000..c83e80eb --- /dev/null +++ b/packages/cli/src/ui/components/ResumeSessionDialog.tsx @@ -0,0 +1,346 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { Box, Text, useInput } from 'ink'; +import { + SessionService, + type SessionListItem, + type ListSessionsResult, + getGitBranch, +} from '@qwen-code/qwen-code-core'; +import { theme } from '../semantic-colors.js'; +import { formatRelativeTime } from '../utils/formatters.js'; + +const PAGE_SIZE = 20; + +export interface ResumeSessionDialogProps { + cwd: string; + onSelect: (sessionId: string) => void; + onCancel: () => void; + availableTerminalHeight?: number; +} + +/** + * Truncates text to fit within a given width, adding ellipsis if needed. + */ +function truncateText(text: string, maxWidth: number): string { + if (text.length <= maxWidth) return text; + if (maxWidth <= 3) return text.slice(0, maxWidth); + return text.slice(0, maxWidth - 3) + '...'; +} + +export function ResumeSessionDialog({ + cwd, + onSelect, + onCancel, + availableTerminalHeight, +}: ResumeSessionDialogProps): React.JSX.Element { + const [selectedIndex, setSelectedIndex] = useState(0); + const [sessionState, setSessionState] = useState<{ + sessions: SessionListItem[]; + hasMore: boolean; + nextCursor?: number; + }>({ + sessions: [], + hasMore: false, + nextCursor: undefined, + }); + const [filterByBranch, setFilterByBranch] = useState(false); + const [currentBranch, setCurrentBranch] = useState(); + const [isLoading, setIsLoading] = useState(true); + const [scrollOffset, setScrollOffset] = useState(0); + + const sessionServiceRef = useRef(null); + const isLoadingMoreRef = useRef(false); + + // Calculate visible items based on terminal height + const maxVisibleItems = availableTerminalHeight + ? Math.max(3, Math.floor((availableTerminalHeight - 6) / 3)) + : 5; + + // Initialize session service and load sessions + useEffect(() => { + const sessionService = new SessionService(cwd); + sessionServiceRef.current = sessionService; + + const branch = getGitBranch(cwd); + setCurrentBranch(branch); + + const loadInitialSessions = async () => { + try { + const result: ListSessionsResult = await sessionService.listSessions({ + size: PAGE_SIZE, + }); + setSessionState({ + sessions: result.items, + hasMore: result.hasMore, + nextCursor: result.nextCursor, + }); + } finally { + setIsLoading(false); + } + }; + + loadInitialSessions(); + }, [cwd]); + + // Filter sessions: exclude empty sessions (0 messages) and optionally by branch + const filteredSessions = sessionState.sessions.filter((session) => { + // Always exclude sessions with no messages + if (session.messageCount === 0) { + return false; + } + // Apply branch filter if enabled + if (filterByBranch && currentBranch) { + return session.gitBranch === currentBranch; + } + return true; + }); + + // Load more sessions when scrolling near the end + const loadMoreSessions = useCallback(async () => { + if ( + !sessionState.hasMore || + isLoadingMoreRef.current || + !sessionServiceRef.current + ) { + return; + } + + isLoadingMoreRef.current = true; + try { + const result: ListSessionsResult = + await sessionServiceRef.current.listSessions({ + size: PAGE_SIZE, + cursor: sessionState.nextCursor, + }); + setSessionState((prev) => ({ + sessions: [...prev.sessions, ...result.items], + hasMore: result.hasMore, + nextCursor: result.nextCursor, + })); + } finally { + isLoadingMoreRef.current = false; + } + }, [sessionState.hasMore, sessionState.nextCursor]); + + // Handle keyboard input + useInput((input, key) => { + // Escape to cancel + if (key.escape) { + onCancel(); + return; + } + + // Enter to select + if (key.return) { + const session = filteredSessions[selectedIndex]; + if (session) { + onSelect(session.sessionId); + } + return; + } + + // Navigation + if (key.upArrow || input === 'k') { + setSelectedIndex((prev) => { + const newIndex = Math.max(0, prev - 1); + // Adjust scroll offset if needed + if (newIndex < scrollOffset) { + setScrollOffset(newIndex); + } + return newIndex; + }); + return; + } + + if (key.downArrow || input === 'j') { + if (filteredSessions.length === 0) { + return; + } + setSelectedIndex((prev) => { + const newIndex = Math.min(filteredSessions.length - 1, prev + 1); + // Adjust scroll offset if needed + if (newIndex >= scrollOffset + maxVisibleItems) { + setScrollOffset(newIndex - maxVisibleItems + 1); + } + // Load more if near the end + if (newIndex >= filteredSessions.length - 3 && sessionState.hasMore) { + loadMoreSessions(); + } + return newIndex; + }); + return; + } + + // Toggle branch filter + if (input === 'b' || input === 'B') { + if (currentBranch) { + setFilterByBranch((prev) => !prev); + setSelectedIndex(0); + setScrollOffset(0); + } + return; + } + }); + + // Reset selection when filter changes + useEffect(() => { + setSelectedIndex(0); + setScrollOffset(0); + }, [filterByBranch]); + + // Get visible sessions for rendering + const visibleSessions = filteredSessions.slice( + scrollOffset, + scrollOffset + maxVisibleItems, + ); + const showScrollUp = scrollOffset > 0; + const showScrollDown = + scrollOffset + maxVisibleItems < filteredSessions.length; + + if (isLoading) { + return ( + + + Resume Session + + + Loading sessions... + + + ); + } + + return ( + + {/* Header */} + + + Resume Session + + {filterByBranch && currentBranch && ( + (branch: {currentBranch}) + )} + + + {/* Session List */} + + {filteredSessions.length === 0 ? ( + + + {filterByBranch + ? `No sessions found for branch "${currentBranch}"` + : 'No sessions found'} + + + ) : ( + visibleSessions.map((session, visibleIndex) => { + const actualIndex = scrollOffset + visibleIndex; + const isSelected = actualIndex === selectedIndex; + const isFirst = visibleIndex === 0; + const isLast = visibleIndex === visibleSessions.length - 1; + const timeAgo = formatRelativeTime(session.mtime); + const messageText = + session.messageCount === 1 + ? '1 message' + : `${session.messageCount} messages`; + + // Show scroll indicator on first/last visible items + const showUpIndicator = isFirst && showScrollUp; + const showDownIndicator = isLast && showScrollDown; + + // Determine the prefix + const prefix = isSelected + ? '> ' + : showUpIndicator + ? '^ ' + : showDownIndicator + ? 'v ' + : ' '; + + const promptText = session.prompt || '(empty prompt)'; + const truncatedPrompt = truncateText( + promptText, + (process.stdout.columns || 80) - 10, + ); + + return ( + + {/* First line: prefix + prompt text */} + + + {prefix} + + + {truncatedPrompt} + + + {/* Second line: metadata */} + + + {timeAgo} · {messageText} + {session.gitBranch && ` · ${session.gitBranch}`} + + + + ); + }) + )} + + + {/* Footer */} + + + {currentBranch && ( + <> + + B + + {' to toggle branch · '} + + )} + {'↑↓ to navigate · Enter to select · Esc to cancel'} + + + + ); +} diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 4788f7fa..f8456430 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -64,6 +64,10 @@ export interface UIActions { // Subagent dialogs closeSubagentCreateDialog: () => void; closeAgentsManagerDialog: () => void; + // Resume session dialog + openResumeDialog: () => void; + closeResumeDialog: () => void; + handleResumeSessionSelect: (sessionId: string) => void; } export const UIActionsContext = createContext(null); diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 62e54204..d009d59e 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -60,6 +60,7 @@ export interface UIState { isModelDialogOpen: boolean; isPermissionsDialogOpen: boolean; isApprovalModeDialogOpen: boolean; + isResumeDialogOpen: boolean; slashCommands: readonly SlashCommand[]; pendingSlashCommandHistoryItems: HistoryItemWithoutId[]; commandContext: CommandContext; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 6439c934..ff7b5909 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -66,6 +66,7 @@ interface SlashCommandProcessorActions { openModelDialog: () => void; openPermissionsDialog: () => void; openApprovalModeDialog: () => void; + openResumeDialog: () => void; quit: (messages: HistoryItem[]) => void; setDebugMessage: (message: string) => void; dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void; @@ -417,6 +418,9 @@ export const useSlashCommandProcessor = ( case 'approval-mode': actions.openApprovalModeDialog(); return { type: 'handled' }; + case 'resume': + actions.openResumeDialog(); + return { type: 'handled' }; case 'help': return { type: 'handled' }; default: { diff --git a/packages/cli/src/ui/hooks/useResumeCommand.ts b/packages/cli/src/ui/hooks/useResumeCommand.ts new file mode 100644 index 00000000..a0f683bf --- /dev/null +++ b/packages/cli/src/ui/hooks/useResumeCommand.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback } from 'react'; + +export function useResumeCommand() { + const [isResumeDialogOpen, setIsResumeDialogOpen] = useState(false); + + const openResumeDialog = useCallback(() => { + setIsResumeDialogOpen(true); + }, []); + + const closeResumeDialog = useCallback(() => { + setIsResumeDialogOpen(false); + }, []); + + return { + isResumeDialogOpen, + openResumeDialog, + closeResumeDialog, + }; +} From 0d40cf221346bd0cade39e36c9a26a6ee213fa5e Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Fri, 12 Dec 2025 21:46:43 +0100 Subject: [PATCH 12/40] Refactor /resume command to use dialog instead of stand alone Ink app --- packages/cli/src/ui/AppContainer.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 2ee02db4..7605f0dd 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1253,7 +1253,8 @@ export const AppContainer = (props: AppContainerProps) => { !!proQuotaRequest || isSubagentCreateDialogOpen || isAgentsManagerDialogOpen || - isApprovalModeDialogOpen; + isApprovalModeDialogOpen || + isResumeDialogOpen; const pendingHistoryItems = useMemo( () => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems], From f5c868702bf2b85b94d338dde8781d7bc34b0700 Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Sat, 13 Dec 2025 09:20:58 +0100 Subject: [PATCH 13/40] Put shared code in new files --- .../src/ui/components/ResumeSessionDialog.tsx | 279 +++--------------- .../src/ui/components/ResumeSessionPicker.tsx | 279 +++--------------- .../cli/src/ui/components/SessionListItem.tsx | 108 +++++++ packages/cli/src/ui/hooks/useSessionPicker.ts | 275 +++++++++++++++++ .../cli/src/ui/utils/sessionPickerUtils.ts | 49 +++ 5 files changed, 512 insertions(+), 478 deletions(-) create mode 100644 packages/cli/src/ui/components/SessionListItem.tsx create mode 100644 packages/cli/src/ui/hooks/useSessionPicker.ts create mode 100644 packages/cli/src/ui/utils/sessionPickerUtils.ts diff --git a/packages/cli/src/ui/components/ResumeSessionDialog.tsx b/packages/cli/src/ui/components/ResumeSessionDialog.tsx index c83e80eb..1de989b1 100644 --- a/packages/cli/src/ui/components/ResumeSessionDialog.tsx +++ b/packages/cli/src/ui/components/ResumeSessionDialog.tsx @@ -4,18 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useEffect, useCallback, useRef } from 'react'; -import { Box, Text, useInput } from 'ink'; -import { - SessionService, - type SessionListItem, - type ListSessionsResult, - getGitBranch, -} from '@qwen-code/qwen-code-core'; +import { useState, useEffect, useRef } from 'react'; +import { Box, Text } from 'ink'; +import { SessionService, getGitBranch } from '@qwen-code/qwen-code-core'; import { theme } from '../semantic-colors.js'; -import { formatRelativeTime } from '../utils/formatters.js'; - -const PAGE_SIZE = 20; +import { useSessionPicker } from '../hooks/useSessionPicker.js'; +import { SessionListItemView } from './SessionListItem.js'; export interface ResumeSessionDialogProps { cwd: string; @@ -24,186 +18,39 @@ export interface ResumeSessionDialogProps { availableTerminalHeight?: number; } -/** - * Truncates text to fit within a given width, adding ellipsis if needed. - */ -function truncateText(text: string, maxWidth: number): string { - if (text.length <= maxWidth) return text; - if (maxWidth <= 3) return text.slice(0, maxWidth); - return text.slice(0, maxWidth - 3) + '...'; -} - export function ResumeSessionDialog({ cwd, onSelect, onCancel, availableTerminalHeight, }: ResumeSessionDialogProps): React.JSX.Element { - const [selectedIndex, setSelectedIndex] = useState(0); - const [sessionState, setSessionState] = useState<{ - sessions: SessionListItem[]; - hasMore: boolean; - nextCursor?: number; - }>({ - sessions: [], - hasMore: false, - nextCursor: undefined, - }); - const [filterByBranch, setFilterByBranch] = useState(false); - const [currentBranch, setCurrentBranch] = useState(); - const [isLoading, setIsLoading] = useState(true); - const [scrollOffset, setScrollOffset] = useState(0); - const sessionServiceRef = useRef(null); - const isLoadingMoreRef = useRef(false); + const [currentBranch, setCurrentBranch] = useState(); + const [isReady, setIsReady] = useState(false); + + // Initialize session service + useEffect(() => { + sessionServiceRef.current = new SessionService(cwd); + setCurrentBranch(getGitBranch(cwd)); + setIsReady(true); + }, [cwd]); // Calculate visible items based on terminal height const maxVisibleItems = availableTerminalHeight ? Math.max(3, Math.floor((availableTerminalHeight - 6) / 3)) : 5; - // Initialize session service and load sessions - useEffect(() => { - const sessionService = new SessionService(cwd); - sessionServiceRef.current = sessionService; - - const branch = getGitBranch(cwd); - setCurrentBranch(branch); - - const loadInitialSessions = async () => { - try { - const result: ListSessionsResult = await sessionService.listSessions({ - size: PAGE_SIZE, - }); - setSessionState({ - sessions: result.items, - hasMore: result.hasMore, - nextCursor: result.nextCursor, - }); - } finally { - setIsLoading(false); - } - }; - - loadInitialSessions(); - }, [cwd]); - - // Filter sessions: exclude empty sessions (0 messages) and optionally by branch - const filteredSessions = sessionState.sessions.filter((session) => { - // Always exclude sessions with no messages - if (session.messageCount === 0) { - return false; - } - // Apply branch filter if enabled - if (filterByBranch && currentBranch) { - return session.gitBranch === currentBranch; - } - return true; + const picker = useSessionPicker({ + sessionService: sessionServiceRef.current!, + currentBranch, + onSelect, + onCancel, + maxVisibleItems, + centerSelection: false, + isActive: isReady, }); - // Load more sessions when scrolling near the end - const loadMoreSessions = useCallback(async () => { - if ( - !sessionState.hasMore || - isLoadingMoreRef.current || - !sessionServiceRef.current - ) { - return; - } - - isLoadingMoreRef.current = true; - try { - const result: ListSessionsResult = - await sessionServiceRef.current.listSessions({ - size: PAGE_SIZE, - cursor: sessionState.nextCursor, - }); - setSessionState((prev) => ({ - sessions: [...prev.sessions, ...result.items], - hasMore: result.hasMore, - nextCursor: result.nextCursor, - })); - } finally { - isLoadingMoreRef.current = false; - } - }, [sessionState.hasMore, sessionState.nextCursor]); - - // Handle keyboard input - useInput((input, key) => { - // Escape to cancel - if (key.escape) { - onCancel(); - return; - } - - // Enter to select - if (key.return) { - const session = filteredSessions[selectedIndex]; - if (session) { - onSelect(session.sessionId); - } - return; - } - - // Navigation - if (key.upArrow || input === 'k') { - setSelectedIndex((prev) => { - const newIndex = Math.max(0, prev - 1); - // Adjust scroll offset if needed - if (newIndex < scrollOffset) { - setScrollOffset(newIndex); - } - return newIndex; - }); - return; - } - - if (key.downArrow || input === 'j') { - if (filteredSessions.length === 0) { - return; - } - setSelectedIndex((prev) => { - const newIndex = Math.min(filteredSessions.length - 1, prev + 1); - // Adjust scroll offset if needed - if (newIndex >= scrollOffset + maxVisibleItems) { - setScrollOffset(newIndex - maxVisibleItems + 1); - } - // Load more if near the end - if (newIndex >= filteredSessions.length - 3 && sessionState.hasMore) { - loadMoreSessions(); - } - return newIndex; - }); - return; - } - - // Toggle branch filter - if (input === 'b' || input === 'B') { - if (currentBranch) { - setFilterByBranch((prev) => !prev); - setSelectedIndex(0); - setScrollOffset(0); - } - return; - } - }); - - // Reset selection when filter changes - useEffect(() => { - setSelectedIndex(0); - setScrollOffset(0); - }, [filterByBranch]); - - // Get visible sessions for rendering - const visibleSessions = filteredSessions.slice( - scrollOffset, - scrollOffset + maxVisibleItems, - ); - const showScrollUp = scrollOffset > 0; - const showScrollDown = - scrollOffset + maxVisibleItems < filteredSessions.length; - - if (isLoading) { + if (!isReady || picker.isLoading) { return ( Resume Session - {filterByBranch && currentBranch && ( + {picker.filterByBranch && currentBranch && ( (branch: {currentBranch}) )} {/* Session List */} - {filteredSessions.length === 0 ? ( + {picker.filteredSessions.length === 0 ? ( - {filterByBranch + {picker.filterByBranch ? `No sessions found for branch "${currentBranch}"` : 'No sessions found'} ) : ( - visibleSessions.map((session, visibleIndex) => { - const actualIndex = scrollOffset + visibleIndex; - const isSelected = actualIndex === selectedIndex; - const isFirst = visibleIndex === 0; - const isLast = visibleIndex === visibleSessions.length - 1; - const timeAgo = formatRelativeTime(session.mtime); - const messageText = - session.messageCount === 1 - ? '1 message' - : `${session.messageCount} messages`; - - // Show scroll indicator on first/last visible items - const showUpIndicator = isFirst && showScrollUp; - const showDownIndicator = isLast && showScrollDown; - - // Determine the prefix - const prefix = isSelected - ? '> ' - : showUpIndicator - ? '^ ' - : showDownIndicator - ? 'v ' - : ' '; - - const promptText = session.prompt || '(empty prompt)'; - const truncatedPrompt = truncateText( - promptText, - (process.stdout.columns || 80) - 10, - ); - + picker.visibleSessions.map((session, visibleIndex) => { + const actualIndex = picker.scrollOffset + visibleIndex; return ( - - {/* First line: prefix + prompt text */} - - - {prefix} - - - {truncatedPrompt} - - - {/* Second line: metadata */} - - - {timeAgo} · {messageText} - {session.gitBranch && ` · ${session.gitBranch}`} - - - + session={session} + isSelected={actualIndex === picker.selectedIndex} + isFirst={visibleIndex === 0} + isLast={visibleIndex === picker.visibleSessions.length - 1} + showScrollUp={picker.showScrollUp} + showScrollDown={picker.showScrollDown} + maxPromptWidth={(process.stdout.columns || 80) - 10} + /> ); }) )} diff --git a/packages/cli/src/ui/components/ResumeSessionPicker.tsx b/packages/cli/src/ui/components/ResumeSessionPicker.tsx index 45acc915..3a10c883 100644 --- a/packages/cli/src/ui/components/ResumeSessionPicker.tsx +++ b/packages/cli/src/ui/components/ResumeSessionPicker.tsx @@ -4,18 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useEffect, useCallback, useRef } from 'react'; -import { render, Box, Text, useInput, useApp } from 'ink'; -import { - SessionService, - type SessionListItem, - type ListSessionsResult, - getGitBranch, -} from '@qwen-code/qwen-code-core'; +import { useState, useEffect } from 'react'; +import { render, Box, Text, useApp } from 'ink'; +import { SessionService, getGitBranch } from '@qwen-code/qwen-code-core'; import { theme } from '../semantic-colors.js'; -import { formatRelativeTime } from '../utils/formatters.js'; - -const PAGE_SIZE = 20; +import { useSessionPicker } from '../hooks/useSessionPicker.js'; +import { SessionListItemView } from './SessionListItem.js'; // Exported for testing export interface SessionPickerProps { @@ -25,14 +19,13 @@ export interface SessionPickerProps { onCancel: () => void; } -/** - * Truncates text to fit within a given width, adding ellipsis if needed. - */ -function truncateText(text: string, maxWidth: number): string { - if (text.length <= maxWidth) return text; - if (maxWidth <= 3) return text.slice(0, maxWidth); - return text.slice(0, maxWidth - 3) + '...'; -} +// Prefix characters for standalone fullscreen picker +const STANDALONE_PREFIX_CHARS = { + selected: '› ', + scrollUp: '↑ ', + scrollDown: '↓ ', + normal: ' ', +}; // Exported for testing export function SessionPicker({ @@ -42,18 +35,6 @@ export function SessionPicker({ onCancel, }: SessionPickerProps): React.JSX.Element { const { exit } = useApp(); - const [selectedIndex, setSelectedIndex] = useState(0); - const [sessionState, setSessionState] = useState<{ - sessions: SessionListItem[]; - hasMore: boolean; - nextCursor?: number; - }>({ - sessions: [], - hasMore: true, - nextCursor: undefined, - }); - const isLoadingMoreRef = useRef(false); - const [filterByBranch, setFilterByBranch] = useState(false); const [isExiting, setIsExiting] = useState(false); const [terminalSize, setTerminalSize] = useState({ width: process.stdout.columns || 80, @@ -74,159 +55,35 @@ export function SessionPicker({ }; }, []); - // Filter sessions: exclude empty sessions (0 messages) and optionally by branch - const filteredSessions = sessionState.sessions.filter((session) => { - // Always exclude sessions with no messages - if (session.messageCount === 0) { - return false; - } - // Apply branch filter if enabled - if (filterByBranch && currentBranch) { - return session.gitBranch === currentBranch; - } - return true; - }); - - const hasSentinel = sessionState.hasMore; - - // Reset selection when filter changes - useEffect(() => { - setSelectedIndex(0); - }, [filterByBranch]); - - const loadMoreSessions = useCallback(async () => { - if (!sessionState.hasMore || isLoadingMoreRef.current) return; - isLoadingMoreRef.current = true; - try { - const result: ListSessionsResult = await sessionService.listSessions({ - size: PAGE_SIZE, - cursor: sessionState.nextCursor, - }); - - setSessionState((prev) => ({ - sessions: [...prev.sessions, ...result.items], - hasMore: result.hasMore && result.nextCursor !== undefined, - nextCursor: result.nextCursor, - })); - } finally { - isLoadingMoreRef.current = false; - } - }, [sessionService, sessionState.hasMore, sessionState.nextCursor]); - // Calculate visible items // Reserved space: header (1), footer (1), separators (2), borders (2) const reservedLines = 6; // Each item takes 2 lines (prompt + metadata) + 1 line margin between items - // On average, this is ~3 lines per item, but the last item has no margin const itemHeight = 3; const maxVisibleItems = Math.max( 1, Math.floor((terminalSize.height - reservedLines) / itemHeight), ); - // Calculate scroll offset - const scrollOffset = (() => { - if (filteredSessions.length <= maxVisibleItems) return 0; - const halfVisible = Math.floor(maxVisibleItems / 2); - let offset = selectedIndex - halfVisible; - offset = Math.max(0, offset); - offset = Math.min(filteredSessions.length - maxVisibleItems, offset); - return offset; - })(); + const handleExit = () => { + setIsExiting(true); + exit(); + }; - const visibleSessions = filteredSessions.slice( - scrollOffset, - scrollOffset + maxVisibleItems, - ); - const showScrollUp = scrollOffset > 0; - const showScrollDown = - scrollOffset + maxVisibleItems < filteredSessions.length; - - // Sentinel (invisible) sits after the last session item; consider it visible - // once the viewport reaches the final real item. - const sentinelVisible = - hasSentinel && scrollOffset + maxVisibleItems >= filteredSessions.length; - - // Load more when sentinel enters view or when filtered list is empty. - useEffect(() => { - if (!sessionState.hasMore || isLoadingMoreRef.current) return; - - const shouldLoadMore = - filteredSessions.length === 0 || - sentinelVisible || - isLoadingMoreRef.current; - - if (shouldLoadMore) { - void loadMoreSessions(); - } - }, [ - filteredSessions.length, - loadMoreSessions, - sessionState.hasMore, - sentinelVisible, - ]); - - // Handle keyboard input - useInput((input, key) => { - // Ignore input if already exiting - if (isExiting) { - return; - } - - // Escape or Ctrl+C to cancel - if (key.escape || (key.ctrl && input === 'c')) { - setIsExiting(true); - onCancel(); - exit(); - return; - } - - if (key.return) { - const session = filteredSessions[selectedIndex]; - if (session) { - setIsExiting(true); - onSelect(session.sessionId); - exit(); - } - return; - } - - if (key.upArrow || input === 'k') { - setSelectedIndex((prev) => Math.max(0, prev - 1)); - return; - } - - if (key.downArrow || input === 'j') { - if (filteredSessions.length === 0) { - return; - } - setSelectedIndex((prev) => - Math.min(filteredSessions.length - 1, prev + 1), - ); - return; - } - - if (input === 'b' || input === 'B') { - if (currentBranch) { - setFilterByBranch((prev) => !prev); - } - return; - } + const picker = useSessionPicker({ + sessionService, + currentBranch, + onSelect, + onCancel, + maxVisibleItems, + centerSelection: true, + onExit: handleExit, + isActive: !isExiting, }); - // Filtered sessions may have changed, ensure selectedIndex is valid - useEffect(() => { - if ( - selectedIndex >= filteredSessions.length && - filteredSessions.length > 0 - ) { - setSelectedIndex(filteredSessions.length - 1); - } - }, [filteredSessions.length, selectedIndex]); - // Calculate content width (terminal width minus border padding) const contentWidth = terminalSize.width - 4; - const promptMaxWidth = contentWidth - 4; // Account for "› " prefix + const promptMaxWidth = contentWidth - 4; // Return empty while exiting to prevent visual glitches if (isExiting) { @@ -265,80 +122,30 @@ export function SessionPicker({ {/* Session list with auto-scrolling */} - {filteredSessions.length === 0 ? ( + {picker.filteredSessions.length === 0 ? ( - {filterByBranch + {picker.filterByBranch ? `No sessions found for branch "${currentBranch}"` : 'No sessions found'} ) : ( - visibleSessions.map((session, visibleIndex) => { - const actualIndex = scrollOffset + visibleIndex; - const isSelected = actualIndex === selectedIndex; - const isFirst = visibleIndex === 0; - const isLast = visibleIndex === visibleSessions.length - 1; - const timeAgo = formatRelativeTime(session.mtime); - const messageText = - session.messageCount === 1 - ? '1 message' - : `${session.messageCount} messages`; - - // Show scroll indicator on first/last visible items - const showUpIndicator = isFirst && showScrollUp; - const showDownIndicator = isLast && showScrollDown; - - // Determine the prefix: selector takes priority over scroll indicator - const prefix = isSelected - ? '› ' - : showUpIndicator - ? '↑ ' - : showDownIndicator - ? '↓ ' - : ' '; - + picker.visibleSessions.map((session, visibleIndex) => { + const actualIndex = picker.scrollOffset + visibleIndex; return ( - - {/* First line: prefix (selector or scroll indicator) + prompt text */} - - - {prefix} - - - {truncateText( - session.prompt || '(empty prompt)', - promptMaxWidth, - )} - - - - {/* Second line: metadata (aligned with prompt text) */} - - {' '} - - {timeAgo} · {messageText} - {session.gitBranch && ` · ${session.gitBranch}`} - - - + session={session} + isSelected={actualIndex === picker.selectedIndex} + isFirst={visibleIndex === 0} + isLast={visibleIndex === picker.visibleSessions.length - 1} + showScrollUp={picker.showScrollUp} + showScrollDown={picker.showScrollDown} + maxPromptWidth={promptMaxWidth} + prefixChars={STANDALONE_PREFIX_CHARS} + boldSelectedPrefix={false} + /> ); }) )} @@ -357,8 +164,8 @@ export function SessionPicker({ {currentBranch && ( <> B diff --git a/packages/cli/src/ui/components/SessionListItem.tsx b/packages/cli/src/ui/components/SessionListItem.tsx new file mode 100644 index 00000000..5d51b7bf --- /dev/null +++ b/packages/cli/src/ui/components/SessionListItem.tsx @@ -0,0 +1,108 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Text } from 'ink'; +import type { SessionListItem as SessionData } from '@qwen-code/qwen-code-core'; +import { theme } from '../semantic-colors.js'; +import { formatRelativeTime } from '../utils/formatters.js'; +import { + truncateText, + formatMessageCount, +} from '../utils/sessionPickerUtils.js'; + +export interface SessionListItemViewProps { + session: SessionData; + isSelected: boolean; + isFirst: boolean; + isLast: boolean; + showScrollUp: boolean; + showScrollDown: boolean; + maxPromptWidth: number; + /** + * Prefix characters to use for selected, scroll up, and scroll down states. + * Defaults to ['> ', '^ ', 'v '] (dialog style). + * Use ['> ', '^ ', 'v '] for dialog or ['> ', '^ ', 'v '] for standalone. + */ + prefixChars?: { + selected: string; + scrollUp: string; + scrollDown: string; + normal: string; + }; + /** + * Whether to bold the prefix when selected. + */ + boldSelectedPrefix?: boolean; +} + +const DEFAULT_PREFIX_CHARS = { + selected: '> ', + scrollUp: '^ ', + scrollDown: 'v ', + normal: ' ', +}; + +export function SessionListItemView({ + session, + isSelected, + isFirst, + isLast, + showScrollUp, + showScrollDown, + maxPromptWidth, + prefixChars = DEFAULT_PREFIX_CHARS, + boldSelectedPrefix = true, +}: SessionListItemViewProps): React.JSX.Element { + const timeAgo = formatRelativeTime(session.mtime); + const messageText = formatMessageCount(session.messageCount); + + const showUpIndicator = isFirst && showScrollUp; + const showDownIndicator = isLast && showScrollDown; + + const prefix = isSelected + ? prefixChars.selected + : showUpIndicator + ? prefixChars.scrollUp + : showDownIndicator + ? prefixChars.scrollDown + : prefixChars.normal; + + const promptText = session.prompt || '(empty prompt)'; + const truncatedPrompt = truncateText(promptText, maxPromptWidth); + + return ( + + {/* First line: prefix + prompt text */} + + + {prefix} + + + {truncatedPrompt} + + + {/* Second line: metadata */} + + + {timeAgo} · {messageText} + {session.gitBranch && ` · ${session.gitBranch}`} + + + + ); +} diff --git a/packages/cli/src/ui/hooks/useSessionPicker.ts b/packages/cli/src/ui/hooks/useSessionPicker.ts new file mode 100644 index 00000000..65f3d377 --- /dev/null +++ b/packages/cli/src/ui/hooks/useSessionPicker.ts @@ -0,0 +1,275 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { useInput } from 'ink'; +import type { + SessionService, + SessionListItem, + ListSessionsResult, +} from '@qwen-code/qwen-code-core'; +import { + SESSION_PAGE_SIZE, + filterSessions, +} from '../utils/sessionPickerUtils.js'; + +export interface SessionState { + sessions: SessionListItem[]; + hasMore: boolean; + nextCursor?: number; +} + +export interface UseSessionPickerOptions { + sessionService: SessionService; + currentBranch?: string; + onSelect: (sessionId: string) => void; + onCancel: () => void; + maxVisibleItems: number; + /** + * If true, computes centered scroll offset (keeps selection near middle). + * If false, uses follow mode (scrolls when selection reaches edge). + */ + centerSelection?: boolean; + /** + * Optional callback when exiting (for standalone mode). + */ + onExit?: () => void; + /** + * Enable/disable input handling. + */ + isActive?: boolean; +} + +export interface UseSessionPickerResult { + // State + selectedIndex: number; + sessionState: SessionState; + filteredSessions: SessionListItem[]; + filterByBranch: boolean; + isLoading: boolean; + scrollOffset: number; + visibleSessions: SessionListItem[]; + showScrollUp: boolean; + showScrollDown: boolean; + + // Actions + loadMoreSessions: () => Promise; +} + +export function useSessionPicker({ + sessionService, + currentBranch, + onSelect, + onCancel, + maxVisibleItems, + centerSelection = false, + onExit, + isActive = true, +}: UseSessionPickerOptions): UseSessionPickerResult { + const [selectedIndex, setSelectedIndex] = useState(0); + const [sessionState, setSessionState] = useState({ + sessions: [], + hasMore: true, + nextCursor: undefined, + }); + const [filterByBranch, setFilterByBranch] = useState(false); + const [isLoading, setIsLoading] = useState(true); + // For follow mode (non-centered) + const [followScrollOffset, setFollowScrollOffset] = useState(0); + + const isLoadingMoreRef = useRef(false); + + // Filter sessions + const filteredSessions = filterSessions( + sessionState.sessions, + filterByBranch, + currentBranch, + ); + + // Calculate scroll offset based on mode + const scrollOffset = centerSelection + ? (() => { + if (filteredSessions.length <= maxVisibleItems) return 0; + const halfVisible = Math.floor(maxVisibleItems / 2); + let offset = selectedIndex - halfVisible; + offset = Math.max(0, offset); + offset = Math.min(filteredSessions.length - maxVisibleItems, offset); + return offset; + })() + : followScrollOffset; + + const visibleSessions = filteredSessions.slice( + scrollOffset, + scrollOffset + maxVisibleItems, + ); + const showScrollUp = scrollOffset > 0; + const showScrollDown = + scrollOffset + maxVisibleItems < filteredSessions.length; + + // Load initial sessions + useEffect(() => { + const loadInitialSessions = async () => { + try { + const result: ListSessionsResult = await sessionService.listSessions({ + size: SESSION_PAGE_SIZE, + }); + setSessionState({ + sessions: result.items, + hasMore: result.hasMore, + nextCursor: result.nextCursor, + }); + } finally { + setIsLoading(false); + } + }; + loadInitialSessions(); + }, [sessionService]); + + // Load more sessions + const loadMoreSessions = useCallback(async () => { + if (!sessionState.hasMore || isLoadingMoreRef.current) return; + + isLoadingMoreRef.current = true; + try { + const result: ListSessionsResult = await sessionService.listSessions({ + size: SESSION_PAGE_SIZE, + cursor: sessionState.nextCursor, + }); + setSessionState((prev) => ({ + sessions: [...prev.sessions, ...result.items], + hasMore: result.hasMore && result.nextCursor !== undefined, + nextCursor: result.nextCursor, + })); + } finally { + isLoadingMoreRef.current = false; + } + }, [sessionService, sessionState.hasMore, sessionState.nextCursor]); + + // Reset selection when filter changes + useEffect(() => { + setSelectedIndex(0); + setFollowScrollOffset(0); + }, [filterByBranch]); + + // Ensure selectedIndex is valid when filtered sessions change + useEffect(() => { + if ( + selectedIndex >= filteredSessions.length && + filteredSessions.length > 0 + ) { + setSelectedIndex(filteredSessions.length - 1); + } + }, [filteredSessions.length, selectedIndex]); + + // Auto-load more when list is empty or near end (for centered mode) + useEffect(() => { + // Don't auto-load during initial load or if not in centered mode + if ( + isLoading || + !sessionState.hasMore || + isLoadingMoreRef.current || + !centerSelection + ) { + return; + } + + const sentinelVisible = + sessionState.hasMore && + scrollOffset + maxVisibleItems >= filteredSessions.length; + const shouldLoadMore = filteredSessions.length === 0 || sentinelVisible; + + if (shouldLoadMore) { + void loadMoreSessions(); + } + }, [ + isLoading, + filteredSessions.length, + loadMoreSessions, + sessionState.hasMore, + scrollOffset, + maxVisibleItems, + centerSelection, + ]); + + // Handle keyboard input + useInput( + (input, key) => { + // Escape or Ctrl+C to cancel + if (key.escape || (key.ctrl && input === 'c')) { + onCancel(); + onExit?.(); + return; + } + + // Enter to select + if (key.return) { + const session = filteredSessions[selectedIndex]; + if (session) { + onSelect(session.sessionId); + onExit?.(); + } + return; + } + + // Navigation up + if (key.upArrow || input === 'k') { + setSelectedIndex((prev) => { + const newIndex = Math.max(0, prev - 1); + // Adjust scroll offset if needed (for follow mode) + if (!centerSelection && newIndex < followScrollOffset) { + setFollowScrollOffset(newIndex); + } + return newIndex; + }); + return; + } + + // Navigation down + if (key.downArrow || input === 'j') { + if (filteredSessions.length === 0) return; + + setSelectedIndex((prev) => { + const newIndex = Math.min(filteredSessions.length - 1, prev + 1); + // Adjust scroll offset if needed (for follow mode) + if ( + !centerSelection && + newIndex >= followScrollOffset + maxVisibleItems + ) { + setFollowScrollOffset(newIndex - maxVisibleItems + 1); + } + // Load more if near the end + if (newIndex >= filteredSessions.length - 3 && sessionState.hasMore) { + loadMoreSessions(); + } + return newIndex; + }); + return; + } + + // Toggle branch filter + if (input === 'b' || input === 'B') { + if (currentBranch) { + setFilterByBranch((prev) => !prev); + } + return; + } + }, + { isActive }, + ); + + return { + selectedIndex, + sessionState, + filteredSessions, + filterByBranch, + isLoading, + scrollOffset, + visibleSessions, + showScrollUp, + showScrollDown, + loadMoreSessions, + }; +} diff --git a/packages/cli/src/ui/utils/sessionPickerUtils.ts b/packages/cli/src/ui/utils/sessionPickerUtils.ts new file mode 100644 index 00000000..3cc47470 --- /dev/null +++ b/packages/cli/src/ui/utils/sessionPickerUtils.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { SessionListItem } from '@qwen-code/qwen-code-core'; + +/** + * Page size for loading sessions. + */ +export const SESSION_PAGE_SIZE = 20; + +/** + * Truncates text to fit within a given width, adding ellipsis if needed. + */ +export function truncateText(text: string, maxWidth: number): string { + if (text.length <= maxWidth) return text; + if (maxWidth <= 3) return text.slice(0, maxWidth); + return text.slice(0, maxWidth - 3) + '...'; +} + +/** + * Filters sessions to exclude empty ones (0 messages) and optionally by branch. + */ +export function filterSessions( + sessions: SessionListItem[], + filterByBranch: boolean, + currentBranch?: string, +): SessionListItem[] { + return sessions.filter((session) => { + // Always exclude sessions with no messages + if (session.messageCount === 0) { + return false; + } + // Apply branch filter if enabled + if (filterByBranch && currentBranch) { + return session.gitBranch === currentBranch; + } + return true; + }); +} + +/** + * Formats message count for display with proper pluralization. + */ +export function formatMessageCount(count: number): string { + return count === 1 ? '1 message' : `${count} messages`; +} From e76f47512cf1ac39599eb75698d217fe2b569966 Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Sat, 13 Dec 2025 09:53:05 +0100 Subject: [PATCH 14/40] Add guards --- .../src/ui/components/ResumeSessionDialog.tsx | 2 +- packages/cli/src/ui/hooks/useSessionPicker.ts | 19 +++++++++++++++---- .../cli/src/ui/utils/sessionPickerUtils.ts | 8 ++++++-- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/ui/components/ResumeSessionDialog.tsx b/packages/cli/src/ui/components/ResumeSessionDialog.tsx index 1de989b1..a0835493 100644 --- a/packages/cli/src/ui/components/ResumeSessionDialog.tsx +++ b/packages/cli/src/ui/components/ResumeSessionDialog.tsx @@ -41,7 +41,7 @@ export function ResumeSessionDialog({ : 5; const picker = useSessionPicker({ - sessionService: sessionServiceRef.current!, + sessionService: sessionServiceRef.current, currentBranch, onSelect, onCancel, diff --git a/packages/cli/src/ui/hooks/useSessionPicker.ts b/packages/cli/src/ui/hooks/useSessionPicker.ts index 65f3d377..f45f26fc 100644 --- a/packages/cli/src/ui/hooks/useSessionPicker.ts +++ b/packages/cli/src/ui/hooks/useSessionPicker.ts @@ -23,7 +23,7 @@ export interface SessionState { } export interface UseSessionPickerOptions { - sessionService: SessionService; + sessionService: SessionService | null; currentBranch?: string; onSelect: (sessionId: string) => void; onCancel: () => void; @@ -92,7 +92,9 @@ export function useSessionPicker({ // Calculate scroll offset based on mode const scrollOffset = centerSelection ? (() => { - if (filteredSessions.length <= maxVisibleItems) return 0; + if (filteredSessions.length <= maxVisibleItems) { + return 0; + } const halfVisible = Math.floor(maxVisibleItems / 2); let offset = selectedIndex - halfVisible; offset = Math.max(0, offset); @@ -111,6 +113,11 @@ export function useSessionPicker({ // Load initial sessions useEffect(() => { + // Guard: don't load if sessionService is not ready + if (!sessionService) { + return; + } + const loadInitialSessions = async () => { try { const result: ListSessionsResult = await sessionService.listSessions({ @@ -130,7 +137,9 @@ export function useSessionPicker({ // Load more sessions const loadMoreSessions = useCallback(async () => { - if (!sessionState.hasMore || isLoadingMoreRef.current) return; + if (!sessionService || !sessionState.hasMore || isLoadingMoreRef.current) { + return; + } isLoadingMoreRef.current = true; try { @@ -229,7 +238,9 @@ export function useSessionPicker({ // Navigation down if (key.downArrow || input === 'j') { - if (filteredSessions.length === 0) return; + if (filteredSessions.length === 0) { + return; + } setSelectedIndex((prev) => { const newIndex = Math.min(filteredSessions.length - 1, prev + 1); diff --git a/packages/cli/src/ui/utils/sessionPickerUtils.ts b/packages/cli/src/ui/utils/sessionPickerUtils.ts index 3cc47470..09d9f704 100644 --- a/packages/cli/src/ui/utils/sessionPickerUtils.ts +++ b/packages/cli/src/ui/utils/sessionPickerUtils.ts @@ -15,8 +15,12 @@ export const SESSION_PAGE_SIZE = 20; * Truncates text to fit within a given width, adding ellipsis if needed. */ export function truncateText(text: string, maxWidth: number): string { - if (text.length <= maxWidth) return text; - if (maxWidth <= 3) return text.slice(0, maxWidth); + if (text.length <= maxWidth) { + return text; + } + if (maxWidth <= 3) { + return text.slice(0, maxWidth); + } return text.slice(0, maxWidth - 3) + '...'; } From 1098c23b265016a8e41f7cfc528be400e3640494 Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Sat, 13 Dec 2025 12:13:33 +0100 Subject: [PATCH 15/40] Close dialog before async operations to prevent input capture --- packages/cli/src/ui/AppContainer.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 7605f0dd..38b6a936 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -442,7 +442,12 @@ export const AppContainer = (props: AppContainerProps) => { // Handle resume session selection const handleResumeSessionSelect = useCallback( async (sessionId: string) => { - if (!config) return; + if (!config) { + return; + } + + // Close dialog immediately to prevent input capture during async operations + closeResumeDialog(); const { SessionService, @@ -459,7 +464,6 @@ export const AppContainer = (props: AppContainerProps) => { const sessionData = await sessionService.loadSession(sessionId); if (!sessionData) { - closeResumeDialog(); return; } @@ -486,8 +490,6 @@ export const AppContainer = (props: AppContainerProps) => { // Clear and load history historyManager.clearItems(); historyManager.loadHistory(uiHistoryItems); - - closeResumeDialog(); }, [config, closeResumeDialog, historyManager, startNewSession], ); From 56a62bcb2af25da7c68a5094da55c4574b470ad5 Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Sat, 13 Dec 2025 13:04:01 +0100 Subject: [PATCH 16/40] Fix input focus issue by using useKeypress instead of useInput for ResumeSessionDialog --- .../src/ui/components/ResumeSessionDialog.tsx | 4 +- .../src/ui/hooks/useDialogSessionPicker.ts | 287 ++++++++++++++++++ 2 files changed, 289 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/ui/hooks/useDialogSessionPicker.ts diff --git a/packages/cli/src/ui/components/ResumeSessionDialog.tsx b/packages/cli/src/ui/components/ResumeSessionDialog.tsx index a0835493..8593eeb7 100644 --- a/packages/cli/src/ui/components/ResumeSessionDialog.tsx +++ b/packages/cli/src/ui/components/ResumeSessionDialog.tsx @@ -8,7 +8,7 @@ import { useState, useEffect, useRef } from 'react'; import { Box, Text } from 'ink'; import { SessionService, getGitBranch } from '@qwen-code/qwen-code-core'; import { theme } from '../semantic-colors.js'; -import { useSessionPicker } from '../hooks/useSessionPicker.js'; +import { useDialogSessionPicker } from '../hooks/useDialogSessionPicker.js'; import { SessionListItemView } from './SessionListItem.js'; export interface ResumeSessionDialogProps { @@ -40,7 +40,7 @@ export function ResumeSessionDialog({ ? Math.max(3, Math.floor((availableTerminalHeight - 6) / 3)) : 5; - const picker = useSessionPicker({ + const picker = useDialogSessionPicker({ sessionService: sessionServiceRef.current, currentBranch, onSelect, diff --git a/packages/cli/src/ui/hooks/useDialogSessionPicker.ts b/packages/cli/src/ui/hooks/useDialogSessionPicker.ts new file mode 100644 index 00000000..35547f4a --- /dev/null +++ b/packages/cli/src/ui/hooks/useDialogSessionPicker.ts @@ -0,0 +1,287 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Session picker hook for dialog mode (within main app). + * Uses useKeypress (KeypressContext) instead of useInput (ink). + * For standalone mode, use useSessionPicker instead. + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import type { + SessionService, + SessionListItem, + ListSessionsResult, +} from '@qwen-code/qwen-code-core'; +import { + SESSION_PAGE_SIZE, + filterSessions, +} from '../utils/sessionPickerUtils.js'; +import { useKeypress } from './useKeypress.js'; + +export interface SessionState { + sessions: SessionListItem[]; + hasMore: boolean; + nextCursor?: number; +} + +export interface UseDialogSessionPickerOptions { + sessionService: SessionService | null; + currentBranch?: string; + onSelect: (sessionId: string) => void; + onCancel: () => void; + maxVisibleItems: number; + /** + * If true, computes centered scroll offset (keeps selection near middle). + * If false, uses follow mode (scrolls when selection reaches edge). + */ + centerSelection?: boolean; + /** + * Enable/disable input handling. + */ + isActive?: boolean; +} + +export interface UseDialogSessionPickerResult { + // State + selectedIndex: number; + sessionState: SessionState; + filteredSessions: SessionListItem[]; + filterByBranch: boolean; + isLoading: boolean; + scrollOffset: number; + visibleSessions: SessionListItem[]; + showScrollUp: boolean; + showScrollDown: boolean; + + // Actions + loadMoreSessions: () => Promise; +} + +export function useDialogSessionPicker({ + sessionService, + currentBranch, + onSelect, + onCancel, + maxVisibleItems, + centerSelection = false, + isActive = true, +}: UseDialogSessionPickerOptions): UseDialogSessionPickerResult { + const [selectedIndex, setSelectedIndex] = useState(0); + const [sessionState, setSessionState] = useState({ + sessions: [], + hasMore: true, + nextCursor: undefined, + }); + const [filterByBranch, setFilterByBranch] = useState(false); + const [isLoading, setIsLoading] = useState(true); + // For follow mode (non-centered) + const [followScrollOffset, setFollowScrollOffset] = useState(0); + + const isLoadingMoreRef = useRef(false); + + // Filter sessions + const filteredSessions = filterSessions( + sessionState.sessions, + filterByBranch, + currentBranch, + ); + + // Calculate scroll offset based on mode + const scrollOffset = centerSelection + ? (() => { + if (filteredSessions.length <= maxVisibleItems) { + return 0; + } + const halfVisible = Math.floor(maxVisibleItems / 2); + let offset = selectedIndex - halfVisible; + offset = Math.max(0, offset); + offset = Math.min(filteredSessions.length - maxVisibleItems, offset); + return offset; + })() + : followScrollOffset; + + const visibleSessions = filteredSessions.slice( + scrollOffset, + scrollOffset + maxVisibleItems, + ); + const showScrollUp = scrollOffset > 0; + const showScrollDown = + scrollOffset + maxVisibleItems < filteredSessions.length; + + // Load initial sessions + useEffect(() => { + // Guard: don't load if sessionService is not ready + if (!sessionService) { + return; + } + + const loadInitialSessions = async () => { + try { + const result: ListSessionsResult = await sessionService.listSessions({ + size: SESSION_PAGE_SIZE, + }); + setSessionState({ + sessions: result.items, + hasMore: result.hasMore, + nextCursor: result.nextCursor, + }); + } finally { + setIsLoading(false); + } + }; + loadInitialSessions(); + }, [sessionService]); + + // Load more sessions + const loadMoreSessions = useCallback(async () => { + if (!sessionService || !sessionState.hasMore || isLoadingMoreRef.current) { + return; + } + + isLoadingMoreRef.current = true; + try { + const result: ListSessionsResult = await sessionService.listSessions({ + size: SESSION_PAGE_SIZE, + cursor: sessionState.nextCursor, + }); + setSessionState((prev) => ({ + sessions: [...prev.sessions, ...result.items], + hasMore: result.hasMore && result.nextCursor !== undefined, + nextCursor: result.nextCursor, + })); + } finally { + isLoadingMoreRef.current = false; + } + }, [sessionService, sessionState.hasMore, sessionState.nextCursor]); + + // Reset selection when filter changes + useEffect(() => { + setSelectedIndex(0); + setFollowScrollOffset(0); + }, [filterByBranch]); + + // Ensure selectedIndex is valid when filtered sessions change + useEffect(() => { + if ( + selectedIndex >= filteredSessions.length && + filteredSessions.length > 0 + ) { + setSelectedIndex(filteredSessions.length - 1); + } + }, [filteredSessions.length, selectedIndex]); + + // Auto-load more when list is empty or near end (for centered mode) + useEffect(() => { + // Don't auto-load during initial load or if not in centered mode + if ( + isLoading || + !sessionState.hasMore || + isLoadingMoreRef.current || + !centerSelection + ) { + return; + } + + const sentinelVisible = + sessionState.hasMore && + scrollOffset + maxVisibleItems >= filteredSessions.length; + const shouldLoadMore = filteredSessions.length === 0 || sentinelVisible; + + if (shouldLoadMore) { + void loadMoreSessions(); + } + }, [ + isLoading, + filteredSessions.length, + loadMoreSessions, + sessionState.hasMore, + scrollOffset, + maxVisibleItems, + centerSelection, + ]); + + // Handle keyboard input using useKeypress (KeypressContext) + useKeypress( + (key) => { + const { name, sequence, ctrl } = key; + + // Escape or Ctrl+C to cancel + if (name === 'escape' || (ctrl && name === 'c')) { + onCancel(); + return; + } + + // Enter to select + if (name === 'return') { + const session = filteredSessions[selectedIndex]; + if (session) { + onSelect(session.sessionId); + } + return; + } + + // Navigation up + if (name === 'up' || name === 'k') { + setSelectedIndex((prev) => { + const newIndex = Math.max(0, prev - 1); + // Adjust scroll offset if needed (for follow mode) + if (!centerSelection && newIndex < followScrollOffset) { + setFollowScrollOffset(newIndex); + } + return newIndex; + }); + return; + } + + // Navigation down + if (name === 'down' || name === 'j') { + if (filteredSessions.length === 0) { + return; + } + + setSelectedIndex((prev) => { + const newIndex = Math.min(filteredSessions.length - 1, prev + 1); + // Adjust scroll offset if needed (for follow mode) + if ( + !centerSelection && + newIndex >= followScrollOffset + maxVisibleItems + ) { + setFollowScrollOffset(newIndex - maxVisibleItems + 1); + } + // Load more if near the end + if (newIndex >= filteredSessions.length - 3 && sessionState.hasMore) { + loadMoreSessions(); + } + return newIndex; + }); + return; + } + + // Toggle branch filter + if (sequence === 'b' || sequence === 'B') { + if (currentBranch) { + setFilterByBranch((prev) => !prev); + } + return; + } + }, + { isActive }, + ); + + return { + selectedIndex, + sessionState, + filteredSessions, + filterByBranch, + isLoading, + scrollOffset, + visibleSessions, + showScrollUp, + showScrollDown, + loadMoreSessions, + }; +} From 4504c7a0ac6777c389cffe8ffd345a83a7d9478b Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Sat, 13 Dec 2025 13:33:44 +0100 Subject: [PATCH 17/40] Rename ResumeSessionPicker to StandaloneSessionPicker and add documentation --- docs/cli/commands.md | 10 +++ docs/features/session-resume.md | 74 +++++++++++++++++++ packages/cli/src/gemini.tsx | 2 +- ...t.tsx => StandaloneSessionPicker.test.tsx} | 2 +- ...Picker.tsx => StandaloneSessionPicker.tsx} | 2 +- ...icker.ts => useStandaloneSessionPicker.ts} | 0 6 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 docs/features/session-resume.md rename packages/cli/src/ui/components/{ResumeSessionPicker.test.tsx => StandaloneSessionPicker.test.tsx} (99%) rename packages/cli/src/ui/components/{ResumeSessionPicker.tsx => StandaloneSessionPicker.tsx} (98%) rename packages/cli/src/ui/hooks/{useSessionPicker.ts => useStandaloneSessionPicker.ts} (100%) diff --git a/docs/cli/commands.md b/docs/cli/commands.md index aa056a43..09ea3e8d 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -93,6 +93,16 @@ Slash commands provide meta-level control over the CLI itself. - **Usage:** `/restore [tool_call_id]` - **Note:** Only available if the CLI is invoked with the `--checkpointing` option or configured via [settings](./configuration.md). See [Checkpointing documentation](../checkpointing.md) for more details. +- **`/resume`** + - **Description:** Resume a previous conversation session. Opens a session picker dialog to browse and select from saved sessions. + - **Usage:** `/resume` + - **Features:** + - Browse all saved sessions for the current project + - Filter sessions by git branch with the **B** key + - Sessions show first prompt, message count, and timestamp + - Navigate with arrow keys or **j/k**, select with **Enter** + - **Note:** For command-line session resumption, see `--resume` and `--continue` flags. For more details, see [Session Resume](../features/session-resume.md). + - **`/settings`** - **Description:** Open the settings editor to view and modify Qwen Code settings. - **Details:** This command provides a user-friendly interface for changing settings that control the behavior and appearance of Qwen Code. It is equivalent to manually editing the `.qwen/settings.json` file, but with validation and guidance to prevent errors. diff --git a/docs/features/session-resume.md b/docs/features/session-resume.md new file mode 100644 index 00000000..00984f30 --- /dev/null +++ b/docs/features/session-resume.md @@ -0,0 +1,74 @@ +# Session Resume + +Qwen Code automatically saves your conversation history, allowing you to resume previous sessions at any time. + +## Overview + +Sessions are saved automatically as you work. You can resume them either from the command line when starting Qwen Code, or from within an active session using the `/resume` command. + +## How Sessions Are Stored + +Sessions are stored as JSONL files (one JSON record per line) at: + +``` +~/.qwen/tmp//chats/.jsonl +``` + +Each session captures: + +- User messages and assistant responses +- Tool calls and their results +- Metadata: timestamps, git branch, working directory, model used + +## Resuming Sessions + +### From the Command Line + +**Resume most recent session:** + +```bash +qwen --continue +``` + +**Show session picker:** + +```bash +qwen --resume +``` + +**Resume specific session by ID:** + +```bash +qwen --resume +``` + +### From Within the App + +Use the `/resume` slash command to open a session picker dialog: + +``` +/resume +``` + +### Session Picker Controls + +- **Arrow keys** or **j/k**: Navigate between sessions +- **Enter**: Select and resume the highlighted session +- **B**: Toggle branch filter (show only sessions from current git branch) +- **Escape**: Cancel and return to current session + +## Session List Display + +Each session shows: + +- First prompt text (truncated if long) +- Number of messages +- Last modified timestamp +- Git branch name (if available) + +Sessions are sorted by last modified time, with most recent first. + +## Related Features + +- [Welcome Back](./welcome-back.md) - Automatic session context restoration +- [/summary command](../cli/commands.md) - Generate project summaries for future reference diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 18f191bc..ee327c2b 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -58,7 +58,7 @@ import { getUserStartupWarnings } from './utils/userStartupWarnings.js'; import { getCliVersion } from './utils/version.js'; import { computeWindowTitle } from './utils/windowTitle.js'; import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'; -import { showResumeSessionPicker } from './ui/components/ResumeSessionPicker.js'; +import { showResumeSessionPicker } from './ui/components/StandaloneSessionPicker.js'; export function validateDnsResolutionOrder( order: string | undefined, diff --git a/packages/cli/src/ui/components/ResumeSessionPicker.test.tsx b/packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx similarity index 99% rename from packages/cli/src/ui/components/ResumeSessionPicker.test.tsx rename to packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx index bd63c3ef..c6841f2f 100644 --- a/packages/cli/src/ui/components/ResumeSessionPicker.test.tsx +++ b/packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx @@ -6,7 +6,7 @@ import { render } from 'ink-testing-library'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { SessionPicker } from './ResumeSessionPicker.js'; +import { SessionPicker } from './StandaloneSessionPicker.js'; import type { SessionListItem, ListSessionsResult, diff --git a/packages/cli/src/ui/components/ResumeSessionPicker.tsx b/packages/cli/src/ui/components/StandaloneSessionPicker.tsx similarity index 98% rename from packages/cli/src/ui/components/ResumeSessionPicker.tsx rename to packages/cli/src/ui/components/StandaloneSessionPicker.tsx index 3a10c883..b519710e 100644 --- a/packages/cli/src/ui/components/ResumeSessionPicker.tsx +++ b/packages/cli/src/ui/components/StandaloneSessionPicker.tsx @@ -8,7 +8,7 @@ import { useState, useEffect } from 'react'; import { render, Box, Text, useApp } from 'ink'; import { SessionService, getGitBranch } from '@qwen-code/qwen-code-core'; import { theme } from '../semantic-colors.js'; -import { useSessionPicker } from '../hooks/useSessionPicker.js'; +import { useSessionPicker } from '../hooks/useStandaloneSessionPicker.js'; import { SessionListItemView } from './SessionListItem.js'; // Exported for testing diff --git a/packages/cli/src/ui/hooks/useSessionPicker.ts b/packages/cli/src/ui/hooks/useStandaloneSessionPicker.ts similarity index 100% rename from packages/cli/src/ui/hooks/useSessionPicker.ts rename to packages/cli/src/ui/hooks/useStandaloneSessionPicker.ts From 7a97fcd5f130ca4319faf0378d6d78fbda55ebfb Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Sat, 13 Dec 2025 14:03:35 +0100 Subject: [PATCH 18/40] Add tests for /resume command and update SettingsDialog snapshots --- .../cli/src/ui/commands/resumeCommand.test.ts | 38 +++ .../components/ResumeSessionDialog.test.tsx | 303 ++++++++++++++++++ .../SettingsDialog.test.tsx.snap | 40 +-- .../cli/src/ui/hooks/useResumeCommand.test.ts | 57 ++++ 4 files changed, 418 insertions(+), 20 deletions(-) create mode 100644 packages/cli/src/ui/commands/resumeCommand.test.ts create mode 100644 packages/cli/src/ui/components/ResumeSessionDialog.test.tsx create mode 100644 packages/cli/src/ui/hooks/useResumeCommand.test.ts diff --git a/packages/cli/src/ui/commands/resumeCommand.test.ts b/packages/cli/src/ui/commands/resumeCommand.test.ts new file mode 100644 index 00000000..7fe14ab0 --- /dev/null +++ b/packages/cli/src/ui/commands/resumeCommand.test.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { resumeCommand } from './resumeCommand.js'; +import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; + +describe('resumeCommand', () => { + let mockContext: CommandContext; + + beforeEach(() => { + mockContext = createMockCommandContext(); + }); + + it('should return a dialog action to open the resume dialog', async () => { + // Ensure the command has an action to test. + if (!resumeCommand.action) { + throw new Error('The resume command must have an action.'); + } + + const result = await resumeCommand.action(mockContext, ''); + + // Assert that the action returns the correct object to trigger the resume dialog. + expect(result).toEqual({ + type: 'dialog', + dialog: 'resume', + }); + }); + + it('should have the correct name and description', () => { + expect(resumeCommand.name).toBe('resume'); + expect(resumeCommand.description).toBe('Resume a previous session'); + }); +}); diff --git a/packages/cli/src/ui/components/ResumeSessionDialog.test.tsx b/packages/cli/src/ui/components/ResumeSessionDialog.test.tsx new file mode 100644 index 00000000..52330624 --- /dev/null +++ b/packages/cli/src/ui/components/ResumeSessionDialog.test.tsx @@ -0,0 +1,303 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from 'ink-testing-library'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ResumeSessionDialog } from './ResumeSessionDialog.js'; +import { KeypressProvider } from '../contexts/KeypressContext.js'; +import type { + SessionListItem, + ListSessionsResult, +} from '@qwen-code/qwen-code-core'; + +// Mock terminal size +const mockTerminalSize = { columns: 80, rows: 24 }; + +beforeEach(() => { + Object.defineProperty(process.stdout, 'columns', { + value: mockTerminalSize.columns, + configurable: true, + }); + Object.defineProperty(process.stdout, 'rows', { + value: mockTerminalSize.rows, + configurable: true, + }); +}); + +// Mock SessionService and getGitBranch +vi.mock('@qwen-code/qwen-code-core', async () => { + const actual = await vi.importActual('@qwen-code/qwen-code-core'); + return { + ...actual, + SessionService: vi.fn().mockImplementation(() => mockSessionService), + getGitBranch: vi.fn().mockReturnValue('main'), + }; +}); + +// Helper to create mock sessions +function createMockSession( + overrides: Partial = {}, +): SessionListItem { + return { + sessionId: 'test-session-id', + cwd: '/test/path', + startTime: '2025-01-01T00:00:00.000Z', + mtime: Date.now(), + prompt: 'Test prompt', + gitBranch: 'main', + filePath: '/test/path/sessions/test-session-id.jsonl', + messageCount: 5, + ...overrides, + }; +} + +// Default mock session service +let mockSessionService = { + listSessions: vi.fn().mockResolvedValue({ + items: [], + hasMore: false, + nextCursor: undefined, + } as ListSessionsResult), +}; + +describe('ResumeSessionDialog', () => { + const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms)); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('Loading State', () => { + it('should show loading state initially', () => { + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame } = render( + + + , + ); + + const output = lastFrame(); + expect(output).toContain('Resume Session'); + expect(output).toContain('Loading sessions...'); + }); + }); + + describe('Empty State', () => { + it('should show "No sessions found" when there are no sessions', async () => { + mockSessionService = { + listSessions: vi.fn().mockResolvedValue({ + items: [], + hasMore: false, + nextCursor: undefined, + } as ListSessionsResult), + }; + + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame } = render( + + + , + ); + + await wait(100); + + const output = lastFrame(); + expect(output).toContain('No sessions found'); + }); + }); + + describe('Session Display', () => { + it('should display sessions after loading', async () => { + const sessions = [ + createMockSession({ + sessionId: 'session-1', + prompt: 'First session prompt', + messageCount: 10, + }), + createMockSession({ + sessionId: 'session-2', + prompt: 'Second session prompt', + messageCount: 5, + }), + ]; + + mockSessionService = { + listSessions: vi.fn().mockResolvedValue({ + items: sessions, + hasMore: false, + nextCursor: undefined, + } as ListSessionsResult), + }; + + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame } = render( + + + , + ); + + await wait(100); + + const output = lastFrame(); + expect(output).toContain('First session prompt'); + }); + + it('should filter out empty sessions', async () => { + const sessions = [ + createMockSession({ + sessionId: 'empty-session', + prompt: '', + messageCount: 0, + }), + createMockSession({ + sessionId: 'valid-session', + prompt: 'Valid prompt', + messageCount: 5, + }), + ]; + + mockSessionService = { + listSessions: vi.fn().mockResolvedValue({ + items: sessions, + hasMore: false, + nextCursor: undefined, + } as ListSessionsResult), + }; + + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame } = render( + + + , + ); + + await wait(100); + + const output = lastFrame(); + expect(output).toContain('Valid prompt'); + // Empty session should be filtered out + expect(output).not.toContain('empty-session'); + }); + }); + + describe('Footer', () => { + it('should show navigation instructions in footer', async () => { + mockSessionService = { + listSessions: vi.fn().mockResolvedValue({ + items: [createMockSession()], + hasMore: false, + nextCursor: undefined, + } as ListSessionsResult), + }; + + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame } = render( + + + , + ); + + await wait(100); + + const output = lastFrame(); + expect(output).toContain('to navigate'); + expect(output).toContain('Enter to select'); + expect(output).toContain('Esc to cancel'); + }); + + it('should show branch toggle hint when currentBranch is available', async () => { + mockSessionService = { + listSessions: vi.fn().mockResolvedValue({ + items: [createMockSession()], + hasMore: false, + nextCursor: undefined, + } as ListSessionsResult), + }; + + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame } = render( + + + , + ); + + await wait(100); + + const output = lastFrame(); + // Should show B key hint since getGitBranch is mocked to return 'main' + expect(output).toContain('B'); + expect(output).toContain('toggle branch'); + }); + }); + + describe('Terminal Height', () => { + it('should accept availableTerminalHeight prop', async () => { + mockSessionService = { + listSessions: vi.fn().mockResolvedValue({ + items: [createMockSession()], + hasMore: false, + nextCursor: undefined, + } as ListSessionsResult), + }; + + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + // Should not throw with availableTerminalHeight prop + const { lastFrame } = render( + + + , + ); + + await wait(100); + + const output = lastFrame(); + expect(output).toContain('Resume Session'); + }); + }); +}); diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap index 7c2c04f9..fbc2244b 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap @@ -14,14 +14,14 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -48,14 +48,14 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -82,14 +82,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -116,14 +116,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false* │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips false* │ -│ │ │ ▼ │ │ │ │ │ @@ -150,14 +150,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -184,14 +184,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -218,14 +218,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -252,14 +252,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false* │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -286,14 +286,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -320,14 +320,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title true* │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips true* │ -│ │ │ ▼ │ │ │ │ │ diff --git a/packages/cli/src/ui/hooks/useResumeCommand.test.ts b/packages/cli/src/ui/hooks/useResumeCommand.test.ts new file mode 100644 index 00000000..3303b644 --- /dev/null +++ b/packages/cli/src/ui/hooks/useResumeCommand.test.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { act, renderHook } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { useResumeCommand } from './useResumeCommand.js'; + +describe('useResumeCommand', () => { + it('should initialize with dialog closed', () => { + const { result } = renderHook(() => useResumeCommand()); + + expect(result.current.isResumeDialogOpen).toBe(false); + }); + + it('should open the dialog when openResumeDialog is called', () => { + const { result } = renderHook(() => useResumeCommand()); + + act(() => { + result.current.openResumeDialog(); + }); + + expect(result.current.isResumeDialogOpen).toBe(true); + }); + + it('should close the dialog when closeResumeDialog is called', () => { + const { result } = renderHook(() => useResumeCommand()); + + // Open the dialog first + act(() => { + result.current.openResumeDialog(); + }); + + expect(result.current.isResumeDialogOpen).toBe(true); + + // Close the dialog + act(() => { + result.current.closeResumeDialog(); + }); + + expect(result.current.isResumeDialogOpen).toBe(false); + }); + + it('should maintain stable function references across renders', () => { + const { result, rerender } = renderHook(() => useResumeCommand()); + + const initialOpenFn = result.current.openResumeDialog; + const initialCloseFn = result.current.closeResumeDialog; + + rerender(); + + expect(result.current.openResumeDialog).toBe(initialOpenFn); + expect(result.current.closeResumeDialog).toBe(initialCloseFn); + }); +}); From 4930a24d07ed09093cf801075480e642dd59dc0b Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Sat, 13 Dec 2025 14:35:40 +0100 Subject: [PATCH 19/40] Polish the PR, minor improvements --- .../src/ui/components/ResumeSessionDialog.tsx | 22 ++++++++++++------- .../cli/src/ui/components/SessionListItem.tsx | 6 ++--- .../ui/components/StandaloneSessionPicker.tsx | 13 ++++++----- .../src/ui/hooks/useDialogSessionPicker.ts | 7 +----- .../ui/hooks/useStandaloneSessionPicker.ts | 13 ++++++----- .../cli/src/ui/utils/sessionPickerUtils.ts | 9 ++++++++ 6 files changed, 42 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/ui/components/ResumeSessionDialog.tsx b/packages/cli/src/ui/components/ResumeSessionDialog.tsx index 8593eeb7..a52f89d0 100644 --- a/packages/cli/src/ui/components/ResumeSessionDialog.tsx +++ b/packages/cli/src/ui/components/ResumeSessionDialog.tsx @@ -10,6 +10,7 @@ import { SessionService, getGitBranch } from '@qwen-code/qwen-code-core'; import { theme } from '../semantic-colors.js'; import { useDialogSessionPicker } from '../hooks/useDialogSessionPicker.js'; import { SessionListItemView } from './SessionListItem.js'; +import { t } from '../../i18n/index.js'; export interface ResumeSessionDialogProps { cwd: string; @@ -59,10 +60,10 @@ export function ResumeSessionDialog({ padding={1} > - Resume Session + {t('Resume Session')} - Loading sessions... + {t('Loading sessions...')} ); @@ -78,10 +79,13 @@ export function ResumeSessionDialog({ {/* Header */} - Resume Session + {t('Resume Session')} {picker.filterByBranch && currentBranch && ( - (branch: {currentBranch}) + + {' '} + {t('(branch: {{branch}})', { branch: currentBranch })} + )} @@ -91,8 +95,10 @@ export function ResumeSessionDialog({ {picker.filterByBranch - ? `No sessions found for branch "${currentBranch}"` - : 'No sessions found'} + ? t('No sessions found for branch "{{branch}}"', { + branch: currentBranch ?? '', + }) + : t('No sessions found')} ) : ( @@ -130,10 +136,10 @@ export function ResumeSessionDialog({ B - {' to toggle branch · '} + {t(' to toggle branch') + ' · '} )} - {'↑↓ to navigate · Enter to select · Esc to cancel'} + {t('to navigate · Enter to select · Esc to cancel')} diff --git a/packages/cli/src/ui/components/SessionListItem.tsx b/packages/cli/src/ui/components/SessionListItem.tsx index 5d51b7bf..7e577b4c 100644 --- a/packages/cli/src/ui/components/SessionListItem.tsx +++ b/packages/cli/src/ui/components/SessionListItem.tsx @@ -22,9 +22,9 @@ export interface SessionListItemViewProps { showScrollDown: boolean; maxPromptWidth: number; /** - * Prefix characters to use for selected, scroll up, and scroll down states. - * Defaults to ['> ', '^ ', 'v '] (dialog style). - * Use ['> ', '^ ', 'v '] for dialog or ['> ', '^ ', 'v '] for standalone. + * Prefix characters for selection indicator and scroll hints. + * Dialog style uses '> ', '^ ', 'v ' (ASCII). + * Standalone style uses special Unicode characters. */ prefixChars?: { selected: string; diff --git a/packages/cli/src/ui/components/StandaloneSessionPicker.tsx b/packages/cli/src/ui/components/StandaloneSessionPicker.tsx index b519710e..2f13f75c 100644 --- a/packages/cli/src/ui/components/StandaloneSessionPicker.tsx +++ b/packages/cli/src/ui/components/StandaloneSessionPicker.tsx @@ -10,6 +10,7 @@ import { SessionService, getGitBranch } from '@qwen-code/qwen-code-core'; import { theme } from '../semantic-colors.js'; import { useSessionPicker } from '../hooks/useStandaloneSessionPicker.js'; import { SessionListItemView } from './SessionListItem.js'; +import { t } from '../../i18n/index.js'; // Exported for testing export interface SessionPickerProps { @@ -109,7 +110,7 @@ export function SessionPicker({ {/* Header row */} - Resume Session + {t('Resume Session')} @@ -126,8 +127,10 @@ export function SessionPicker({ {picker.filterByBranch - ? `No sessions found for branch "${currentBranch}"` - : 'No sessions found'} + ? t('No sessions found for branch "{{branch}}"', { + branch: currentBranch ?? '', + }) + : t('No sessions found')} ) : ( @@ -169,10 +172,10 @@ export function SessionPicker({ > B - {' to toggle branch · '} + {t(' to toggle branch') + ' · '} )} - {'↑↓ to navigate · Esc to cancel'} + {t('to navigate · Esc to cancel')} diff --git a/packages/cli/src/ui/hooks/useDialogSessionPicker.ts b/packages/cli/src/ui/hooks/useDialogSessionPicker.ts index 35547f4a..0292f829 100644 --- a/packages/cli/src/ui/hooks/useDialogSessionPicker.ts +++ b/packages/cli/src/ui/hooks/useDialogSessionPicker.ts @@ -19,15 +19,10 @@ import type { import { SESSION_PAGE_SIZE, filterSessions, + type SessionState, } from '../utils/sessionPickerUtils.js'; import { useKeypress } from './useKeypress.js'; -export interface SessionState { - sessions: SessionListItem[]; - hasMore: boolean; - nextCursor?: number; -} - export interface UseDialogSessionPickerOptions { sessionService: SessionService | null; currentBranch?: string; diff --git a/packages/cli/src/ui/hooks/useStandaloneSessionPicker.ts b/packages/cli/src/ui/hooks/useStandaloneSessionPicker.ts index f45f26fc..601f49ed 100644 --- a/packages/cli/src/ui/hooks/useStandaloneSessionPicker.ts +++ b/packages/cli/src/ui/hooks/useStandaloneSessionPicker.ts @@ -4,6 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** + * Session picker hook for standalone mode (fullscreen CLI picker). + * Uses useInput (ink) instead of useKeypress (KeypressContext). + * For dialog mode within the main app, use useDialogSessionPicker instead. + */ + import { useState, useEffect, useCallback, useRef } from 'react'; import { useInput } from 'ink'; import type { @@ -14,14 +20,9 @@ import type { import { SESSION_PAGE_SIZE, filterSessions, + type SessionState, } from '../utils/sessionPickerUtils.js'; -export interface SessionState { - sessions: SessionListItem[]; - hasMore: boolean; - nextCursor?: number; -} - export interface UseSessionPickerOptions { sessionService: SessionService | null; currentBranch?: string; diff --git a/packages/cli/src/ui/utils/sessionPickerUtils.ts b/packages/cli/src/ui/utils/sessionPickerUtils.ts index 09d9f704..89942fd8 100644 --- a/packages/cli/src/ui/utils/sessionPickerUtils.ts +++ b/packages/cli/src/ui/utils/sessionPickerUtils.ts @@ -6,6 +6,15 @@ import type { SessionListItem } from '@qwen-code/qwen-code-core'; +/** + * State for managing loaded sessions in the session picker. + */ +export interface SessionState { + sessions: SessionListItem[]; + hasMore: boolean; + nextCursor?: number; +} + /** * Page size for loading sessions. */ From 3b9d38a325be8b87968a914feb0190741544a87f Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Thu, 11 Dec 2025 21:33:03 +0100 Subject: [PATCH 20/40] Expose gitCoAuthor setting in settings.json and document it --- docs/cli/configuration.md | 19 +++++++++ packages/cli/src/config/config.ts | 1 + packages/cli/src/config/settingsSchema.ts | 52 +++++++++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/docs/cli/configuration.md b/docs/cli/configuration.md index aef6bc4f..11c6400a 100644 --- a/docs/cli/configuration.md +++ b/docs/cli/configuration.md @@ -306,6 +306,20 @@ Settings are organized into categories. All settings should be placed within the - **Default:** `1000` - **Requires restart:** Yes +#### `git` + +- **`git.gitCoAuthor.enabled`** (boolean): + - **Description:** Automatically add a Co-authored-by trailer to git commit messages when commits are made through Qwen Code. + - **Default:** `true` + +- **`git.gitCoAuthor.name`** (string): + - **Description:** The name to use in the Co-authored-by trailer. + - **Default:** `"Qwen-Coder"` + +- **`git.gitCoAuthor.email`** (string): + - **Description:** The email to use in the Co-authored-by trailer. + - **Default:** `"qwen-coder@alibabacloud.com"` + #### `mcp` - **`mcp.serverCommand`** (string): @@ -418,6 +432,11 @@ Here is an example of a `settings.json` file with the nested structure, new as o "callCommand": "bin/call_tool", "exclude": ["write_file"] }, + "git": { + "gitCoAuthor": { + "enabled": false + } + }, "mcpServers": { "mainServer": { "command": "bin/mcp_server.py" diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index ab4f087d..988bec17 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -992,6 +992,7 @@ export async function loadCliConfig( enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation, eventEmitter: appEvents, useSmartEdit: argv.useSmartEdit ?? settings.useSmartEdit, + gitCoAuthor: settings.git?.gitCoAuthor, output: { format: outputSettingsFormat, }, diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 439bc5d9..097ff6d4 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -933,6 +933,58 @@ const SETTINGS_SCHEMA = { }, }, + git: { + type: 'object', + label: 'Git', + category: 'Git', + requiresRestart: false, + default: {}, + description: 'Git-related settings.', + showInDialog: false, + properties: { + gitCoAuthor: { + type: 'object', + label: 'Git Co-Author', + category: 'Git', + requiresRestart: false, + default: {}, + description: + 'Settings for automatic Co-authored-by trailer in git commits.', + showInDialog: false, + properties: { + enabled: { + type: 'boolean', + label: 'Enable Git Co-Author', + category: 'Git', + requiresRestart: false, + default: true, + description: + 'Automatically add Co-authored-by trailer to git commit messages.', + showInDialog: true, + }, + name: { + type: 'string', + label: 'Co-Author Name', + category: 'Git', + requiresRestart: false, + default: 'Qwen-Coder' as string | undefined, + description: 'The name to use in the Co-authored-by trailer.', + showInDialog: true, + }, + email: { + type: 'string', + label: 'Co-Author Email', + category: 'Git', + requiresRestart: false, + default: 'qwen-coder@alibabacloud.com' as string | undefined, + description: 'The email to use in the Co-authored-by trailer.', + showInDialog: true, + }, + }, + }, + }, + }, + mcp: { type: 'object', label: 'MCP', From 65392a057de429e2d1933c32f9d04673ca5b52d5 Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Fri, 12 Dec 2025 11:02:29 +0100 Subject: [PATCH 21/40] Detect git commit anywhere in command, not just at start --- packages/core/src/tools/shell.test.ts | 63 +++++++++++++++++++++++++++ packages/core/src/tools/shell.ts | 6 +-- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index 043ab0c6..3760266a 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -768,6 +768,69 @@ describe('ShellTool', () => { {}, ); }); + + it('should add co-author when git commit is prefixed with cd command', async () => { + const command = 'cd /tmp/test && git commit -m "Test commit"'; + const invocation = shellTool.build({ command, is_background: false }); + const promise = invocation.execute(mockAbortSignal); + + resolveExecutionPromise({ + rawOutput: Buffer.from(''), + output: '', + exitCode: 0, + signal: null, + error: null, + aborted: false, + pid: 12345, + executionMethod: 'child_process', + }); + + await promise; + + expect(mockShellExecutionService).toHaveBeenCalledWith( + expect.stringContaining( + 'Co-authored-by: Qwen-Coder ', + ), + expect.any(String), + expect.any(Function), + mockAbortSignal, + false, + {}, + ); + }); + + it('should add co-author to git commit with multi-line message', async () => { + const command = `git commit -m "Fix bug + +This is a detailed description +spanning multiple lines"`; + const invocation = shellTool.build({ command, is_background: false }); + const promise = invocation.execute(mockAbortSignal); + + resolveExecutionPromise({ + rawOutput: Buffer.from(''), + output: '', + exitCode: 0, + signal: null, + error: null, + aborted: false, + pid: 12345, + executionMethod: 'child_process', + }); + + await promise; + + expect(mockShellExecutionService).toHaveBeenCalledWith( + expect.stringContaining( + 'Co-authored-by: Qwen-Coder ', + ), + expect.any(String), + expect.any(Function), + mockAbortSignal, + false, + {}, + ); + }); }); }); diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 8ff3047e..6e92954e 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -338,9 +338,9 @@ export class ShellToolInvocation extends BaseToolInvocation< return command; } - // Check if this is a git commit command - const gitCommitPattern = /^git\s+commit/; - if (!gitCommitPattern.test(command.trim())) { + // Check if this is a git commit command (anywhere in the command, e.g., after "cd /path &&") + const gitCommitPattern = /\bgit\s+commit\b/; + if (!gitCommitPattern.test(command)) { return command; } From 5bd1822b7d740da689695ab0fe22f7a8eac558b4 Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Mon, 15 Dec 2025 11:00:21 +0100 Subject: [PATCH 22/40] Fix gitCoAuthor not added for combined flags like -am --- packages/core/src/tools/shell.test.ts | 30 +++++++++++++++++++++++++++ packages/core/src/tools/shell.ts | 4 ++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index 3760266a..0b34b8c1 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -608,6 +608,36 @@ describe('ShellTool', () => { ); }); + it('should handle git commit with combined short flags like -am', async () => { + const command = 'git commit -am "Add feature"'; + const invocation = shellTool.build({ command, is_background: false }); + const promise = invocation.execute(mockAbortSignal); + + resolveExecutionPromise({ + rawOutput: Buffer.from(''), + output: '', + exitCode: 0, + signal: null, + error: null, + aborted: false, + pid: 12345, + executionMethod: 'child_process', + }); + + await promise; + + expect(mockShellExecutionService).toHaveBeenCalledWith( + expect.stringContaining( + 'Co-authored-by: Qwen-Coder ', + ), + expect.any(String), + expect.any(Function), + mockAbortSignal, + false, + {}, + ); + }); + it('should not modify non-git commands', async () => { const command = 'npm install'; const invocation = shellTool.build({ command, is_background: false }); diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 6e92954e..077bc693 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -350,8 +350,8 @@ export class ShellToolInvocation extends BaseToolInvocation< Co-authored-by: ${gitCoAuthorSettings.name} <${gitCoAuthorSettings.email}>`; // Handle different git commit patterns - // Match -m "message" or -m 'message' - const messagePattern = /(-m\s+)(['"])((?:\\.|[^\\])*?)(\2)/; + // Match -m "message" or -m 'message', including combined flags like -am + const messagePattern = /(-[a-zA-Z]*m\s+)(['"])((?:\\.|[^\\])*?)(\2)/; const match = command.match(messagePattern); if (match) { From 5d94763581360b8fe1762753c879fb5804b63e9b Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Mon, 15 Dec 2025 11:06:09 +0100 Subject: [PATCH 23/40] Add logs (TODO remove later) --- packages/core/src/tools/shell.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 077bc693..4deb02a7 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -334,13 +334,24 @@ export class ShellToolInvocation extends BaseToolInvocation< private addCoAuthorToGitCommit(command: string): string { // Check if co-author feature is enabled const gitCoAuthorSettings = this.config.getGitCoAuthor(); + + // Debug logging for gitCoAuthor feature + // TODO: Remove after debugging is complete + console.error( + '[gitCoAuthor] Settings:', + JSON.stringify(gitCoAuthorSettings), + ); + console.error('[gitCoAuthor] Command:', command); + if (!gitCoAuthorSettings.enabled) { + console.error('[gitCoAuthor] Feature disabled, skipping'); return command; } // Check if this is a git commit command (anywhere in the command, e.g., after "cd /path &&") const gitCommitPattern = /\bgit\s+commit\b/; if (!gitCommitPattern.test(command)) { + console.error('[gitCoAuthor] Not a git commit command, skipping'); return command; } @@ -354,16 +365,20 @@ Co-authored-by: ${gitCoAuthorSettings.name} <${gitCoAuthorSettings.email}>`; const messagePattern = /(-[a-zA-Z]*m\s+)(['"])((?:\\.|[^\\])*?)(\2)/; const match = command.match(messagePattern); + console.error('[gitCoAuthor] Message pattern match:', match ? 'YES' : 'NO'); + if (match) { const [fullMatch, prefix, quote, existingMessage, closingQuote] = match; const newMessage = existingMessage + coAuthor; const replacement = prefix + quote + newMessage + closingQuote; + console.error('[gitCoAuthor] Adding co-author trailer'); return command.replace(fullMatch, replacement); } // If no -m flag found, the command might open an editor // In this case, we can't easily modify it, so return as-is + console.error('[gitCoAuthor] No -m flag found, skipping'); return command; } } From 1956507d9014b7fdc558d5f9b88f48461b848869 Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Mon, 15 Dec 2025 16:23:17 +0100 Subject: [PATCH 24/40] Avoid ReDoS by using better regexes --- packages/core/src/tools/shell.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 4deb02a7..b78c6729 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -362,15 +362,19 @@ Co-authored-by: ${gitCoAuthorSettings.name} <${gitCoAuthorSettings.email}>`; // Handle different git commit patterns // Match -m "message" or -m 'message', including combined flags like -am - const messagePattern = /(-[a-zA-Z]*m\s+)(['"])((?:\\.|[^\\])*?)(\2)/; - const match = command.match(messagePattern); + // Use separate patterns to avoid ReDoS (catastrophic backtracking) + const doubleQuotePattern = /(-[a-zA-Z]*m\s+)"((?:[^"\\]|\\.)*)"/; + const singleQuotePattern = /(-[a-zA-Z]*m\s+)'((?:[^'\\]|\\.)*)'/; + const match = + command.match(doubleQuotePattern) || command.match(singleQuotePattern); + const quote = command.match(doubleQuotePattern) ? '"' : "'"; console.error('[gitCoAuthor] Message pattern match:', match ? 'YES' : 'NO'); if (match) { - const [fullMatch, prefix, quote, existingMessage, closingQuote] = match; + const [fullMatch, prefix, existingMessage] = match; const newMessage = existingMessage + coAuthor; - const replacement = prefix + quote + newMessage + closingQuote; + const replacement = prefix + quote + newMessage + quote; console.error('[gitCoAuthor] Adding co-author trailer'); return command.replace(fullMatch, replacement); From 07fb6faf5fb00002815aa7430b29b8f5cd23ef29 Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Mon, 15 Dec 2025 16:26:52 +0100 Subject: [PATCH 25/40] Add comments explaining regexes --- packages/core/src/tools/shell.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index b78c6729..1a32e6b1 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -360,9 +360,16 @@ export class ShellToolInvocation extends BaseToolInvocation< Co-authored-by: ${gitCoAuthorSettings.name} <${gitCoAuthorSettings.email}>`; - // Handle different git commit patterns + // Handle different git commit patterns: // Match -m "message" or -m 'message', including combined flags like -am // Use separate patterns to avoid ReDoS (catastrophic backtracking) + // + // Pattern breakdown: + // -[a-zA-Z]*m matches -m, -am, -nm, etc. (combined short flags) + // \s+ matches whitespace after the flag + // [^"\\] matches any char except double-quote and backslash + // \\. matches escape sequences like \" or \\ + // (?:...|...)* matches normal chars or escapes, repeated const doubleQuotePattern = /(-[a-zA-Z]*m\s+)"((?:[^"\\]|\\.)*)"/; const singleQuotePattern = /(-[a-zA-Z]*m\s+)'((?:[^'\\]|\\.)*)'/; const match = From 36fb6b82918fb16129ea80fce93156386f64a671 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Tue, 16 Dec 2025 13:48:10 +0800 Subject: [PATCH 26/40] feat: update docs --- docs/users/overview.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/users/overview.md b/docs/users/overview.md index 5ae43303..d720a804 100644 --- a/docs/users/overview.md +++ b/docs/users/overview.md @@ -38,11 +38,11 @@ what does this project do? ![](https://gw.alicdn.com/imgextra/i2/O1CN01XoPbZm1CrsZzvMQ6m_!!6000000000135-1-tps-772-646.gif) -You'll be prompted to log in on first use. That's it! [Continue with Quickstart (5 mins) →](/users/quickstart) +You'll be prompted to log in on first use. That's it! [Continue with Quickstart (5 mins) →](./quickstart) > [!tip] > -> See [troubleshooting](/users/support/troubleshooting) if you hit issues. +> See [troubleshooting](./support/troubleshooting) if you hit issues. > [!note] > From 633148b2577c542451a73aaf5d6f58aa3e3aec80 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Tue, 16 Dec 2025 14:30:25 +0800 Subject: [PATCH 27/40] Revert IDE client discovery path changes --- packages/core/src/ide/ide-client.test.ts | 6 +++--- packages/core/src/ide/ide-client.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/src/ide/ide-client.test.ts b/packages/core/src/ide/ide-client.test.ts index e80565a2..ca26f78f 100644 --- a/packages/core/src/ide/ide-client.test.ts +++ b/packages/core/src/ide/ide-client.test.ts @@ -324,7 +324,7 @@ describe('IdeClient', () => { expect(result).toEqual(config); expect(fs.promises.readFile).toHaveBeenCalledWith( - path.join('/tmp', 'qwen-code-ide-server-12345-123.json'), + path.join('/tmp/gemini/ide', 'qwen-code-ide-server-12345-123.json'), 'utf8', ); }); @@ -518,11 +518,11 @@ describe('IdeClient', () => { expect(result).toEqual(validConfig); expect(fs.promises.readFile).toHaveBeenCalledWith( - path.join('/tmp', 'qwen-code-ide-server-12345-111.json'), + path.join('/tmp/gemini/ide', 'qwen-code-ide-server-12345-111.json'), 'utf8', ); expect(fs.promises.readFile).not.toHaveBeenCalledWith( - path.join('/tmp', 'not-a-config-file.txt'), + path.join('/tmp/gemini/ide', 'not-a-config-file.txt'), 'utf8', ); }); diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index e49b81c7..b447f46c 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -591,7 +591,7 @@ export class IdeClient { // exist. } - const portFileDir = os.tmpdir(); + const portFileDir = path.join(os.tmpdir(), 'gemini', 'ide'); let portFiles; try { portFiles = await fs.promises.readdir(portFileDir); From 61e378644e1b5c835c6d93c75cb0bfe792928ea9 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Tue, 16 Dec 2025 15:02:59 +0800 Subject: [PATCH 28/40] feat: update configuration and shell tool implementations Co-authored-by: Qwen-Coder --- docs/users/configuration/settings.md | 23 +++------ packages/cli/src/config/config.ts | 2 +- packages/cli/src/config/settingsSchema.ts | 62 ++++------------------- packages/core/src/config/config.ts | 8 +-- packages/core/src/tools/shell.ts | 21 ++------ 5 files changed, 27 insertions(+), 89 deletions(-) diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index de710d27..9fecc6d3 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -50,13 +50,14 @@ Settings are organized into categories. All settings should be placed within the #### general -| Setting | Type | Description | Default | -| ------------------------------- | ------- | ------------------------------------------ | ----------- | -| `general.preferredEditor` | string | The preferred editor to open files in. | `undefined` | -| `general.vimMode` | boolean | Enable Vim keybindings. | `false` | -| `general.disableAutoUpdate` | boolean | Disable automatic updates. | `false` | -| `general.disableUpdateNag` | boolean | Disable update notification prompts. | `false` | -| `general.checkpointing.enabled` | boolean | Enable session checkpointing for recovery. | `false` | +| Setting | Type | Description | Default | +| ------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------- | ----------- | +| `general.preferredEditor` | string | The preferred editor to open files in. | `undefined` | +| `general.vimMode` | boolean | Enable Vim keybindings. | `false` | +| `general.disableAutoUpdate` | boolean | Disable automatic updates. | `false` | +| `general.disableUpdateNag` | boolean | Disable update notification prompts. | `false` | +| `general.gitCoAuthor` | boolean | Automatically add a Co-authored-by trailer to git commit messages when commits are made through Qwen Code. | `true` | +| `general.checkpointing.enabled` | boolean | Enable session checkpointing for recovery. | `false` | #### output @@ -175,14 +176,6 @@ If you are experiencing performance issues with file searching (e.g., with `@` c | `tools.truncateToolOutputLines` | number | Maximum lines or entries kept when truncating tool output. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools. | `1000` | Requires restart: Yes | | `tools.autoAccept` | boolean | Controls whether the CLI automatically accepts and executes tool calls that are considered safe (e.g., read-only operations) without explicit user confirmation. If set to `true`, the CLI will bypass the confirmation prompt for tools deemed safe. | `false` | | -#### git - -| Setting | Type | Description | Default | -| ------------------------- | ------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------- | -| `git.gitCoAuthor.enabled` | boolean | Automatically add a Co-authored-by trailer to git commit messages when commits are made through Qwen Code. | `true` | -| `git.gitCoAuthor.name` | string | The name to use in the Co-authored-by trailer. | `"Qwen-Coder"` | -| `git.gitCoAuthor.email` | string | The email to use in the Co-authored-by trailer. | `"qwen-coder@alibabacloud.com"` | - #### mcp | Setting | Type | Description | Default | diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 16915558..07ac1967 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -1002,7 +1002,7 @@ export async function loadCliConfig( enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation, eventEmitter: appEvents, useSmartEdit: argv.useSmartEdit ?? settings.useSmartEdit, - gitCoAuthor: settings.git?.gitCoAuthor, + gitCoAuthor: settings.general?.gitCoAuthor, output: { format: outputSettingsFormat, }, diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index e3a792b3..d653d85b 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -147,6 +147,16 @@ const SETTINGS_SCHEMA = { description: 'Disable update notification prompts.', showInDialog: false, }, + gitCoAuthor: { + type: 'boolean', + label: 'Git Co-Author', + category: 'General', + requiresRestart: false, + default: true, + description: + 'Automatically add a Co-authored-by trailer to git commit messages when commits are made through Qwen Code.', + showInDialog: false, + }, checkpointing: { type: 'object', label: 'Checkpointing', @@ -943,58 +953,6 @@ const SETTINGS_SCHEMA = { }, }, - git: { - type: 'object', - label: 'Git', - category: 'Git', - requiresRestart: false, - default: {}, - description: 'Git-related settings.', - showInDialog: false, - properties: { - gitCoAuthor: { - type: 'object', - label: 'Git Co-Author', - category: 'Git', - requiresRestart: false, - default: {}, - description: - 'Settings for automatic Co-authored-by trailer in git commits.', - showInDialog: false, - properties: { - enabled: { - type: 'boolean', - label: 'Enable Git Co-Author', - category: 'Git', - requiresRestart: false, - default: true, - description: - 'Automatically add Co-authored-by trailer to git commit messages.', - showInDialog: true, - }, - name: { - type: 'string', - label: 'Co-Author Name', - category: 'Git', - requiresRestart: false, - default: 'Qwen-Coder' as string | undefined, - description: 'The name to use in the Co-authored-by trailer.', - showInDialog: true, - }, - email: { - type: 'string', - label: 'Co-Author Email', - category: 'Git', - requiresRestart: false, - default: 'qwen-coder@alibabacloud.com' as string | undefined, - description: 'The email to use in the Co-authored-by trailer.', - showInDialog: true, - }, - }, - }, - }, - }, - mcp: { type: 'object', label: 'MCP', diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index d3c9b14a..d5b7f4be 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -287,7 +287,7 @@ export interface ConfigParameters { contextFileName?: string | string[]; accessibility?: AccessibilitySettings; telemetry?: TelemetrySettings; - gitCoAuthor?: GitCoAuthorSettings; + gitCoAuthor?: boolean; usageStatisticsEnabled?: boolean; fileFiltering?: { respectGitIgnore?: boolean; @@ -534,9 +534,9 @@ export class Config { useCollector: params.telemetry?.useCollector, }; this.gitCoAuthor = { - enabled: params.gitCoAuthor?.enabled ?? true, - name: params.gitCoAuthor?.name ?? 'Qwen-Coder', - email: params.gitCoAuthor?.email ?? 'qwen-coder@alibabacloud.com', + enabled: params.gitCoAuthor ?? true, + name: 'Qwen-Coder', + email: 'qwen-coder@alibabacloud.com', }; this.usageStatisticsEnabled = params.usageStatisticsEnabled ?? true; diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 1a32e6b1..5354f925 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -335,23 +335,13 @@ export class ShellToolInvocation extends BaseToolInvocation< // Check if co-author feature is enabled const gitCoAuthorSettings = this.config.getGitCoAuthor(); - // Debug logging for gitCoAuthor feature - // TODO: Remove after debugging is complete - console.error( - '[gitCoAuthor] Settings:', - JSON.stringify(gitCoAuthorSettings), - ); - console.error('[gitCoAuthor] Command:', command); - if (!gitCoAuthorSettings.enabled) { - console.error('[gitCoAuthor] Feature disabled, skipping'); return command; } // Check if this is a git commit command (anywhere in the command, e.g., after "cd /path &&") const gitCommitPattern = /\bgit\s+commit\b/; if (!gitCommitPattern.test(command)) { - console.error('[gitCoAuthor] Not a git commit command, skipping'); return command; } @@ -372,24 +362,21 @@ Co-authored-by: ${gitCoAuthorSettings.name} <${gitCoAuthorSettings.email}>`; // (?:...|...)* matches normal chars or escapes, repeated const doubleQuotePattern = /(-[a-zA-Z]*m\s+)"((?:[^"\\]|\\.)*)"/; const singleQuotePattern = /(-[a-zA-Z]*m\s+)'((?:[^'\\]|\\.)*)'/; - const match = - command.match(doubleQuotePattern) || command.match(singleQuotePattern); - const quote = command.match(doubleQuotePattern) ? '"' : "'"; - - console.error('[gitCoAuthor] Message pattern match:', match ? 'YES' : 'NO'); + const doubleMatch = command.match(doubleQuotePattern); + const singleMatch = command.match(singleQuotePattern); + const match = doubleMatch ?? singleMatch; + const quote = doubleMatch ? '"' : "'"; if (match) { const [fullMatch, prefix, existingMessage] = match; const newMessage = existingMessage + coAuthor; const replacement = prefix + quote + newMessage + quote; - console.error('[gitCoAuthor] Adding co-author trailer'); return command.replace(fullMatch, replacement); } // If no -m flag found, the command might open an editor // In this case, we can't easily modify it, so return as-is - console.error('[gitCoAuthor] No -m flag found, skipping'); return command; } } From 4b8b4e2fe82e63da3639c1cf1ba2aa4b6fed3cac Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Tue, 16 Dec 2025 15:32:21 +0800 Subject: [PATCH 29/40] feat: update docs --- docs/users/integration-zed.md | 2 +- docs/users/overview.md | 4 ++-- docs/users/quickstart.md | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/users/integration-zed.md b/docs/users/integration-zed.md index 5a2fa7e5..cd4cb2ae 100644 --- a/docs/users/integration-zed.md +++ b/docs/users/integration-zed.md @@ -7,7 +7,7 @@ ### Features - **Native agent experience**: Integrated AI assistant panel within Zed's interface -- **Agent Control Protocol**: Full support for ACP enabling advanced IDE interactions +- **Agent Client Protocol**: Full support for ACP enabling advanced IDE interactions - **File management**: @-mention files to add them to the conversation context - **Conversation history**: Access to past conversations within Zed diff --git a/docs/users/overview.md b/docs/users/overview.md index d720a804..032a49d7 100644 --- a/docs/users/overview.md +++ b/docs/users/overview.md @@ -38,11 +38,11 @@ what does this project do? ![](https://gw.alicdn.com/imgextra/i2/O1CN01XoPbZm1CrsZzvMQ6m_!!6000000000135-1-tps-772-646.gif) -You'll be prompted to log in on first use. That's it! [Continue with Quickstart (5 mins) →](./quickstart) +You'll be prompted to log in on first use. That's it! [Continue with Quickstart (5 mins) →](../users/quickstart) > [!tip] > -> See [troubleshooting](./support/troubleshooting) if you hit issues. +> See [troubleshooting](../users/support/troubleshooting) if you hit issues. > [!note] > diff --git a/docs/users/quickstart.md b/docs/users/quickstart.md index 2f4318c3..791f9e5a 100644 --- a/docs/users/quickstart.md +++ b/docs/users/quickstart.md @@ -225,9 +225,9 @@ See the [CLI reference](/users/reference/cli-reference) for a complete list of c 3. build a webpage that allows users to see and edit their information ``` -**Let Claude explore first** +**Let Qwen Code explore first** -- Before making changes, let Claude understand your code: +- Before making changes, let Qwen Code understand your code: ``` analyze the database schema From 1c62499977cd1f8d0407c66b18f8bf0ec9353318 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Tue, 16 Dec 2025 15:40:01 +0800 Subject: [PATCH 30/40] feat: fix link --- docs/users/overview.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/users/overview.md b/docs/users/overview.md index 032a49d7..5b6e8b8b 100644 --- a/docs/users/overview.md +++ b/docs/users/overview.md @@ -52,7 +52,7 @@ You'll be prompted to log in on first use. That's it! [Continue with Quickstart - **Build features from descriptions**: Tell Qwen Code what you want to build in plain language. It will make a plan, write the code, and ensure it works. - **Debug and fix issues**: Describe a bug or paste an error message. Qwen Code will analyze your codebase, identify the problem, and implement a fix. -- **Navigate any codebase**: Ask anything about your team's codebase, and get a thoughtful answer back. Qwen Code maintains awareness of your entire project structure, can find up-to-date information from the web, and with [MCP](/users/features/mcp) can pull from external datasources like Google Drive, Figma, and Slack. +- **Navigate any codebase**: Ask anything about your team's codebase, and get a thoughtful answer back. Qwen Code maintains awareness of your entire project structure, can find up-to-date information from the web, and with [MCP](../users/features/mcp) can pull from external datasources like Google Drive, Figma, and Slack. - **Automate tedious tasks**: Fix fiddly lint issues, resolve merge conflicts, and write release notes. Do all this in a single command from your developer machines, or automatically in CI. ## Why developers love Qwen Code From d1a6b3207e062022d445d2aecb40a5adf4cbdacb Mon Sep 17 00:00:00 2001 From: joeytoday Date: Tue, 16 Dec 2025 17:01:47 +0800 Subject: [PATCH 31/40] docs: updated inline links --- docs/index.md | 3 +-- docs/users/common-workflow.md | 6 ++--- docs/users/configuration/settings.md | 22 +++++++++---------- docs/users/configuration/trusted-folders.md | 2 +- docs/users/features/headless.md | 10 ++++----- docs/users/features/sandbox.md | 6 ++--- docs/users/ide-integration/ide-integration.md | 2 +- docs/users/overview.md | 2 +- docs/users/quickstart.md | 2 +- docs/users/support/tos-privacy.md | 4 ++-- docs/users/support/troubleshooting.md | 4 ++-- 11 files changed, 31 insertions(+), 32 deletions(-) diff --git a/docs/index.md b/docs/index.md index 802c6899..839e5545 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,8 +4,7 @@ Welcome to the Qwen Code documentation. Qwen Code is an agentic coding tool that ## Documentation Sections -### [User Guide](./users/overview) - +### [User Guide](../users/overview) Learn how to use Qwen Code as an end user. This section covers: - Basic installation and setup diff --git a/docs/users/common-workflow.md b/docs/users/common-workflow.md index 5dde4a97..632a2127 100644 --- a/docs/users/common-workflow.md +++ b/docs/users/common-workflow.md @@ -189,8 +189,8 @@ Then select "create" and follow the prompts to define: > - Create project-specific subagents in `.qwen/agents/` for team sharing > - Use descriptive `description` fields to enable automatic delegation > - Limit tool access to what each subagent actually needs -> - Know more about [Sub Agents](/users/features/sub-agents) -> - Know more about [Approval Mode](/users/features/approval-mode) +> - Know more about [Sub Agents](../users/features/sub-agents) +> - Know more about [Approval Mode](../users/features/approval-mode) ## Work with tests @@ -318,7 +318,7 @@ This provides a directory listing with file information. Show me the data from @github: repos/owner/repo/issues ``` -This fetches data from connected MCP servers using the format @server: resource. See [MCP](/users/features/mcp) for details. +This fetches data from connected MCP servers using the format @server: resource. See [MCP](../users/features/mcp) for details. > [!tip] > diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index a62ef2fe..7f97b8f0 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -2,7 +2,7 @@ > [!tip] > -> **Authentication / API keys:** Authentication (Qwen OAuth vs OpenAI-compatible API) and auth-related environment variables (like `OPENAI_API_KEY`) are documented in **[Authentication](/users/configuration/auth)**. +> **Authentication / API keys:** Authentication (Qwen OAuth vs OpenAI-compatible API) and auth-related environment variables (like `OPENAI_API_KEY`) are documented in **[Authentication](../users/configuration/auth)**. > [!note] > @@ -42,7 +42,7 @@ Qwen Code uses JSON settings files for persistent configuration. There are four In addition to a project settings file, a project's `.qwen` directory can contain other project-specific files related to Qwen Code's operation, such as: -- [Custom sandbox profiles](/users/features/sandbox) (e.g. `.qwen/sandbox-macos-custom.sb`, `.qwen/sandbox.Dockerfile`). +- [Custom sandbox profiles](../users/features/sandbox) (e.g. `.qwen/sandbox-macos-custom.sb`, `.qwen/sandbox.Dockerfile`). ### Available settings in `settings.json` @@ -68,7 +68,7 @@ Settings are organized into categories. All settings should be placed within the | Setting | Type | Description | Default | | ---------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | -| `ui.theme` | string | The color theme for the UI. See [Themes](/users/configuration/themes) for available options. | `undefined` | +| `ui.theme` | string | The color theme for the UI. See [Themes](../users/configuration/themes) for available options. | `undefined` | | `ui.customThemes` | object | Custom theme definitions. | `{}` | | `ui.hideWindowTitle` | boolean | Hide the window title bar. | `false` | | `ui.hideTips` | boolean | Hide helpful tips in the UI. | `false` | @@ -325,7 +325,7 @@ The CLI keeps a history of shell commands you run. To avoid conflicts between di Environment variables are a common way to configure applications, especially for sensitive information (like tokens) or for settings that might change between environments. Qwen Code can automatically load environment variables from `.env` files. -For authentication-related variables (like `OPENAI_*`) and the recommended `.qwen/.env` approach, see **[Authentication](/users/configuration/auth)**. +For authentication-related variables (like `OPENAI_*`) and the recommended `.qwen/.env` approach, see **[Authentication](../users/configuration/auth)**. > [!tip] > @@ -361,9 +361,9 @@ Arguments passed directly when running the CLI can override other configurations | `--model` | `-m` | Specifies the Qwen model to use for this session. | Model name | Example: `npm start -- --model qwen3-coder-plus` | | `--prompt` | `-p` | Used to pass a prompt directly to the command. This invokes Qwen Code in a non-interactive mode. | Your prompt text | For scripting examples, use the `--output-format json` flag to get structured output. | | `--prompt-interactive` | `-i` | Starts an interactive session with the provided prompt as the initial input. | Your prompt text | The prompt is processed within the interactive session, not before it. Cannot be used when piping input from stdin. Example: `qwen -i "explain this code"` | -| `--output-format` | `-o` | Specifies the format of the CLI output for non-interactive mode. | `text`, `json`, `stream-json` | `text`: (Default) The standard human-readable output. `json`: A machine-readable JSON output emitted at the end of execution. `stream-json`: Streaming JSON messages emitted as they occur during execution. For structured output and scripting, use the `--output-format json` or `--output-format stream-json` flag. See [Headless Mode](/users/features/headless) for detailed information. | -| `--input-format` | | Specifies the format consumed from standard input. | `text`, `stream-json` | `text`: (Default) Standard text input from stdin or command-line arguments. `stream-json`: JSON message protocol via stdin for bidirectional communication. Requirement: `--input-format stream-json` requires `--output-format stream-json` to be set. When using `stream-json`, stdin is reserved for protocol messages. See [Headless Mode](/users/features/headless) for detailed information. | -| `--include-partial-messages` | | Include partial assistant messages when using `stream-json` output format. When enabled, emits stream events (message_start, content_block_delta, etc.) as they occur during streaming. | | Default: `false`. Requirement: Requires `--output-format stream-json` to be set. See [Headless Mode](/users/features/headless) for detailed information about stream events. | +| `--output-format` | `-o` | Specifies the format of the CLI output for non-interactive mode. | `text`, `json`, `stream-json` | `text`: (Default) The standard human-readable output. `json`: A machine-readable JSON output emitted at the end of execution. `stream-json`: Streaming JSON messages emitted as they occur during execution. For structured output and scripting, use the `--output-format json` or `--output-format stream-json` flag. See [Headless Mode](../users/features/headless) for detailed information. | +| `--input-format` | | Specifies the format consumed from standard input. | `text`, `stream-json` | `text`: (Default) Standard text input from stdin or command-line arguments. `stream-json`: JSON message protocol via stdin for bidirectional communication. Requirement: `--input-format stream-json` requires `--output-format stream-json` to be set. When using `stream-json`, stdin is reserved for protocol messages. See [Headless Mode](../users/features/headless) for detailed information. | +| `--include-partial-messages` | | Include partial assistant messages when using `stream-json` output format. When enabled, emits stream events (message_start, content_block_delta, etc.) as they occur during streaming. | | Default: `false`. Requirement: Requires `--output-format stream-json` to be set. See [Headless Mode](../users/features/headless) for detailed information about stream events. | | `--sandbox` | `-s` | Enables sandbox mode for this session. | | | | `--sandbox-image` | | Sets the sandbox image URI. | | | | `--debug` | `-d` | Enables debug mode for this session, providing more verbose output. | | | @@ -378,7 +378,7 @@ Arguments passed directly when running the CLI can override other configurations | `--telemetry-otlp-endpoint` | | Sets the OTLP endpoint for telemetry. | | See [telemetry](/developers/development/telemetry) for more information. | | `--telemetry-otlp-protocol` | | Sets the OTLP protocol for telemetry (`grpc` or `http`). | | Defaults to `grpc`. See [telemetry](/developers/development/telemetry) for more information. | | `--telemetry-log-prompts` | | Enables logging of prompts for telemetry. | | See [telemetry](/developers/development/telemetry) for more information. | -| `--checkpointing` | | Enables [checkpointing](/users/features/checkpointing). | | | +| `--checkpointing` | | Enables [checkpointing](../users/features/checkpointing). | | | | `--extensions` | `-e` | Specifies a list of extensions to use for the session. | Extension names | If not provided, all available extensions are used. Use the special term `qwen -e none` to disable all extensions. Example: `qwen -e my-extension -e my-other-extension` | | `--list-extensions` | `-l` | Lists all available extensions and exits. | | | | `--proxy` | | Sets the proxy for the CLI. | Proxy URL | Example: `--proxy http://localhost:7890`. | @@ -437,11 +437,11 @@ This example demonstrates how you can provide general project context, specific - Location: The CLI also scans for the configured context file in subdirectories _below_ the current working directory (respecting common ignore patterns like `node_modules`, `.git`, etc.). The breadth of this search is limited to 200 directories by default, but can be configured with the `context.discoveryMaxDirs` setting in your `settings.json` file. - Scope: Allows for highly specific instructions relevant to a particular component, module, or subsection of your project. - **Concatenation & UI Indication:** The contents of all found context files are concatenated (with separators indicating their origin and path) and provided as part of the system prompt. The CLI footer displays the count of loaded context files, giving you a quick visual cue about the active instructional context. -- **Importing Content:** You can modularize your context files by importing other Markdown files using the `@path/to/file.md` syntax. For more details, see the [Memory Import Processor documentation](/users/configuration/memory). +- **Importing Content:** You can modularize your context files by importing other Markdown files using the `@path/to/file.md` syntax. For more details, see the [Memory Import Processor documentation](../users/configuration/memory). - **Commands for Memory Management:** - Use `/memory refresh` to force a re-scan and reload of all context files from all configured locations. This updates the AI's instructional context. - Use `/memory show` to display the combined instructional context currently loaded, allowing you to verify the hierarchy and content being used by the AI. - - See the [Commands documentation](/users/reference/cli-reference) for full details on the `/memory` command and its sub-commands (`show` and `refresh`). + - See the [Commands documentation](../users/reference/cli-reference) for full details on the `/memory` command and its sub-commands (`show` and `refresh`). By understanding and utilizing these configuration layers and the hierarchical nature of context files, you can effectively manage the AI's memory and tailor Qwen Code's responses to your specific needs and projects. @@ -449,7 +449,7 @@ By understanding and utilizing these configuration layers and the hierarchical n Qwen Code can execute potentially unsafe operations (like shell commands and file modifications) within a sandboxed environment to protect your system. -[Sandbox](/users/features/sandbox) is disabled by default, but you can enable it in a few ways: +[Sandbox](../users/features/sandbox) is disabled by default, but you can enable it in a few ways: - Using `--sandbox` or `-s` flag. - Setting `GEMINI_SANDBOX` environment variable. diff --git a/docs/users/configuration/trusted-folders.md b/docs/users/configuration/trusted-folders.md index afe955ef..ad3f8f55 100644 --- a/docs/users/configuration/trusted-folders.md +++ b/docs/users/configuration/trusted-folders.md @@ -56,6 +56,6 @@ If you need to change a decision or see all your settings, you have a couple of For advanced users, it's helpful to know the exact order of operations for how trust is determined: -1. **IDE Trust Signal**: If you are using the [IDE Integration](/users/ide-integration/ide-integration), the CLI first asks the IDE if the workspace is trusted. The IDE's response takes highest priority. +1. **IDE Trust Signal**: If you are using the [IDE Integration](../users/ide-integration/ide-integration), the CLI first asks the IDE if the workspace is trusted. The IDE's response takes highest priority. 2. **Local Trust File**: If the IDE is not connected, the CLI checks the central `~/.qwen/trustedFolders.json` file. diff --git a/docs/users/features/headless.md b/docs/users/features/headless.md index 2a92bb1b..d42b370d 100644 --- a/docs/users/features/headless.md +++ b/docs/users/features/headless.md @@ -203,7 +203,7 @@ Key command-line options for headless usage: | `--continue` | Resume the most recent session for this project | `qwen --continue -p "Pick up where we left off"` | | `--resume [sessionId]` | Resume a specific session (or choose interactively) | `qwen --resume 123e... -p "Finish the refactor"` | -For complete details on all available configuration options, settings files, and environment variables, see the [Configuration Guide](/users/configuration/settings). +For complete details on all available configuration options, settings files, and environment variables, see the [Configuration Guide](../users/configuration/settings). ## Examples @@ -276,7 +276,7 @@ tail -5 usage.log ## Resources -- [CLI Configuration](/users/configuration/settings#command-line-arguments) - Complete configuration guide -- [Authentication](/users/configuration/settings#environment-variables-for-api-access) - Setup authentication -- [Commands](/users/reference/cli-reference) - Interactive commands reference -- [Tutorials](/users/quickstart) - Step-by-step automation guides +- [CLI Configuration](../users/configuration/settings#command-line-arguments) - Complete configuration guide +- [Authentication](../users/configuration/settings#environment-variables-for-api-access) - Setup authentication +- [Commands](../users/reference/cli-reference) - Interactive commands reference +- [Tutorials](../users/quickstart) - Step-by-step automation guides diff --git a/docs/users/features/sandbox.md b/docs/users/features/sandbox.md index 4d4e8f25..ee0b0e9a 100644 --- a/docs/users/features/sandbox.md +++ b/docs/users/features/sandbox.md @@ -220,6 +220,6 @@ qwen -s -p "run shell command: mount | grep workspace" ## Related documentation -- [Configuration](/users/configuration/settings): Full configuration options. -- [Commands](/users/reference/cli-reference): Available commands. -- [Troubleshooting](/users/support/troubleshooting): General troubleshooting. +- [Configuration](../users/configuration/settings): Full configuration options. +- [Commands](../users/reference/cli-reference): Available commands. +- [Troubleshooting](../users/support/troubleshooting): General troubleshooting. diff --git a/docs/users/ide-integration/ide-integration.md b/docs/users/ide-integration/ide-integration.md index af7d1b7a..5b282fc1 100644 --- a/docs/users/ide-integration/ide-integration.md +++ b/docs/users/ide-integration/ide-integration.md @@ -2,7 +2,7 @@ Qwen Code can integrate with your IDE to provide a more seamless and context-aware experience. This integration allows the CLI to understand your workspace better and enables powerful features like native in-editor diffing. -Currently, the only supported IDE is [Visual Studio Code](https://code.visualstudio.com/) and other editors that support VS Code extensions. To build support for other editors, see the [IDE Companion Extension Spec](/users/ide-integration/ide-companion-spec). +Currently, the only supported IDE is [Visual Studio Code](https://code.visualstudio.com/) and other editors that support VS Code extensions. To build support for other editors, see the [IDE Companion Extension Spec](../users/ide-integration/ide-companion-spec). ## Features diff --git a/docs/users/overview.md b/docs/users/overview.md index 5b6e8b8b..50062864 100644 --- a/docs/users/overview.md +++ b/docs/users/overview.md @@ -58,5 +58,5 @@ You'll be prompted to log in on first use. That's it! [Continue with Quickstart ## Why developers love Qwen Code - **Works in your terminal**: Not another chat window. Not another IDE. Qwen Code meets you where you already work, with the tools you already love. -- **Takes action**: Qwen Code can directly edit files, run commands, and create commits. Need more? [MCP](/users/features/mcp) lets Qwen Code read your design docs in Google Drive, update your tickets in Jira, or use _your_ custom developer tooling. +- **Takes action**: Qwen Code can directly edit files, run commands, and create commits. Need more? [MCP](../users/features/mcp) lets Qwen Code read your design docs in Google Drive, update your tickets in Jira, or use _your_ custom developer tooling. - **Unix philosophy**: Qwen Code is composable and scriptable. `tail -f app.log | qwen -p "Slack me if you see any anomalies appear in this log stream"` _works_. Your CI can run `qwen -p "If there are new text strings, translate them into French and raise a PR for @lang-fr-team to review"`. diff --git a/docs/users/quickstart.md b/docs/users/quickstart.md index 791f9e5a..8fc6a2c8 100644 --- a/docs/users/quickstart.md +++ b/docs/users/quickstart.md @@ -206,7 +206,7 @@ Here are the most important commands for daily use: | → `output [language]` | Set LLM output language | `/language output Chinese` | | `/quit` | Exit Qwen Code immediately | `/quit` or `/exit` | -See the [CLI reference](/users/reference/cli-reference) for a complete list of commands. +See the [CLI reference](../users/reference/cli-reference) for a complete list of commands. ## Pro tips for beginners diff --git a/docs/users/support/tos-privacy.md b/docs/users/support/tos-privacy.md index 84ec5c0a..d5b9a83b 100644 --- a/docs/users/support/tos-privacy.md +++ b/docs/users/support/tos-privacy.md @@ -23,7 +23,7 @@ When you authenticate using your qwen.ai account, these Terms of Service and Pri - **Terms of Service:** Your use is governed by the [Qwen Terms of Service](https://qwen.ai/termsservice). - **Privacy Notice:** The collection and use of your data is described in the [Qwen Privacy Policy](https://qwen.ai/privacypolicy). -For details about authentication setup, quotas, and supported features, see [Authentication Setup](/users/configuration/settings). +For details about authentication setup, quotas, and supported features, see [Authentication Setup](../users/configuration/settings). ## 2. If you are using OpenAI-Compatible API Authentication @@ -91,4 +91,4 @@ You can switch between Qwen OAuth and OpenAI-compatible API authentication at an 2. **Within the CLI**: Use the `/auth` command to reconfigure your authentication method 3. **Environment variables**: Set up `.env` files for automatic OpenAI-compatible API authentication -For detailed instructions, see the [Authentication Setup](/users/configuration/settings#environment-variables-for-api-access) documentation. +For detailed instructions, see the [Authentication Setup](../users/configuration/settings#environment-variables-for-api-access) documentation. diff --git a/docs/users/support/troubleshooting.md b/docs/users/support/troubleshooting.md index f5129300..633d6394 100644 --- a/docs/users/support/troubleshooting.md +++ b/docs/users/support/troubleshooting.md @@ -31,7 +31,7 @@ This guide provides solutions to common issues and debugging tips, including top 1. In your home directory: `~/.qwen/settings.json`. 2. In your project's root directory: `./.qwen/settings.json`. - Refer to [Qwen Code Configuration](/users/configuration/settings) for more details. + Refer to [Qwen Code Configuration](../users/configuration/settings) for more details. - **Q: Why don't I see cached token counts in my stats output?** - A: Cached token information is only displayed when cached tokens are being used. This feature is available for API key users (Qwen API key or Google Cloud Vertex AI) but not for OAuth users (such as Google Personal/Enterprise accounts like Google Gmail or Google Workspace, respectively). This is because the Qwen Code Assist API does not support cached content creation. You can still view your total token usage using the `/stats` command. @@ -59,7 +59,7 @@ This guide provides solutions to common issues and debugging tips, including top - **Error: "Operation not permitted", "Permission denied", or similar.** - **Cause:** When sandboxing is enabled, Qwen Code may attempt operations that are restricted by your sandbox configuration, such as writing outside the project directory or system temp directory. - - **Solution:** Refer to the [Configuration: Sandboxing](/users/features/sandbox) documentation for more information, including how to customize your sandbox configuration. + - **Solution:** Refer to the [Configuration: Sandboxing](../users/features/sandbox) documentation for more information, including how to customize your sandbox configuration. - **Qwen Code is not running in interactive mode in "CI" environments** - **Issue:** Qwen Code does not enter interactive mode (no prompt appears) if an environment variable starting with `CI_` (e.g. `CI_TOKEN`) is set. This is because the `is-in-ci` package, used by the underlying UI framework, detects these variables and assumes a non-interactive CI environment. From 2837aa6b7ce24b1c8b5cfca6cdceb13fd89a27e8 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Tue, 16 Dec 2025 19:54:55 +0800 Subject: [PATCH 32/40] rework /resume slash command --- packages/cli/src/ui/AppContainer.tsx | 62 +--- .../cli/src/ui/components/DialogManager.tsx | 12 +- .../components/ResumeSessionDialog.test.tsx | 303 ------------------ .../src/ui/components/ResumeSessionDialog.tsx | 147 --------- .../cli/src/ui/components/SessionListItem.tsx | 108 ------- .../cli/src/ui/components/SessionPicker.tsx | 275 ++++++++++++++++ .../StandaloneSessionPicker.test.tsx | 217 +++++++------ .../ui/components/StandaloneSessionPicker.tsx | 201 ++---------- .../cli/src/ui/hooks/slashCommandProcessor.ts | 1 + ...ogSessionPicker.ts => useSessionPicker.ts} | 117 ++++--- .../cli/src/ui/hooks/useSessionSelect.test.ts | 97 ++++++ packages/cli/src/ui/hooks/useSessionSelect.ts | 64 ++++ .../ui/hooks/useStandaloneSessionPicker.ts | 287 ----------------- .../src/ui/utils/sessionPickerUtils.test.ts | 45 +++ .../cli/src/ui/utils/sessionPickerUtils.ts | 13 +- packages/core/src/config/config.ts | 7 +- 16 files changed, 724 insertions(+), 1232 deletions(-) delete mode 100644 packages/cli/src/ui/components/ResumeSessionDialog.test.tsx delete mode 100644 packages/cli/src/ui/components/ResumeSessionDialog.tsx delete mode 100644 packages/cli/src/ui/components/SessionListItem.tsx create mode 100644 packages/cli/src/ui/components/SessionPicker.tsx rename packages/cli/src/ui/hooks/{useDialogSessionPicker.ts => useSessionPicker.ts} (73%) create mode 100644 packages/cli/src/ui/hooks/useSessionSelect.test.ts create mode 100644 packages/cli/src/ui/hooks/useSessionSelect.ts delete mode 100644 packages/cli/src/ui/hooks/useStandaloneSessionPicker.ts create mode 100644 packages/cli/src/ui/utils/sessionPickerUtils.test.ts diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 38b6a936..5da98f0a 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -99,6 +99,7 @@ import { processVisionSwitchOutcome } from './hooks/useVisionAutoSwitch.js'; import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js'; import { useAgentsManagerDialog } from './hooks/useAgentsManagerDialog.js'; import { useAttentionNotifications } from './hooks/useAttentionNotifications.js'; +import { useSessionSelect } from './hooks/useSessionSelect.js'; const CTRL_EXIT_PROMPT_DURATION_MS = 1000; @@ -439,60 +440,13 @@ export const AppContainer = (props: AppContainerProps) => { const { isResumeDialogOpen, openResumeDialog, closeResumeDialog } = useResumeCommand(); - // Handle resume session selection - const handleResumeSessionSelect = useCallback( - async (sessionId: string) => { - if (!config) { - return; - } - - // Close dialog immediately to prevent input capture during async operations - closeResumeDialog(); - - const { - SessionService, - buildApiHistoryFromConversation, - replayUiTelemetryFromConversation, - uiTelemetryService, - } = await import('@qwen-code/qwen-code-core'); - const { buildResumedHistoryItems } = await import( - './utils/resumeHistoryUtils.js' - ); - - const cwd = config.getTargetDir(); - const sessionService = new SessionService(cwd); - const sessionData = await sessionService.loadSession(sessionId); - - if (!sessionData) { - return; - } - - // Reset and replay UI telemetry to restore metrics - uiTelemetryService.reset(); - replayUiTelemetryFromConversation(sessionData.conversation); - - // Build UI history items using existing utility - const uiHistoryItems = buildResumedHistoryItems(sessionData, config); - - // Build API history for the LLM client - const clientHistory = buildApiHistoryFromConversation( - sessionData.conversation, - ); - - // Update client history - config.getGeminiClient()?.setHistory(clientHistory); - config.getGeminiClient()?.stripThoughtsFromHistory(); - - // Update session in config - config.startNewSession(sessionId); - startNewSession(sessionId); - - // Clear and load history - historyManager.clearItems(); - historyManager.loadHistory(uiHistoryItems); - }, - [config, closeResumeDialog, historyManager, startNewSession], - ); + const handleResumeSessionSelect = useSessionSelect({ + config, + historyManager, + closeResumeDialog, + startNewSession, + remount: refreshStatic, + }); const { showWorkspaceMigrationDialog, diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index c0907400..d79014e8 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -28,7 +28,7 @@ import { useConfig } from '../contexts/ConfigContext.js'; import { useSettings } from '../contexts/SettingsContext.js'; import { SettingScope } from '../../config/settings.js'; import { AuthState } from '../types.js'; -import { AuthType } from '@qwen-code/qwen-code-core'; +import { AuthType, getGitBranch } from '@qwen-code/qwen-code-core'; import process from 'node:process'; import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js'; @@ -36,7 +36,7 @@ import { WelcomeBackDialog } from './WelcomeBackDialog.js'; import { ModelSwitchDialog } from './ModelSwitchDialog.js'; import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js'; import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js'; -import { ResumeSessionDialog } from './ResumeSessionDialog.js'; +import { SessionPicker } from './SessionPicker.js'; interface DialogManagerProps { addItem: UseHistoryManagerReturn['addItem']; @@ -293,13 +293,11 @@ export const DialogManager = ({ if (uiState.isResumeDialogOpen) { return ( - ); } diff --git a/packages/cli/src/ui/components/ResumeSessionDialog.test.tsx b/packages/cli/src/ui/components/ResumeSessionDialog.test.tsx deleted file mode 100644 index 52330624..00000000 --- a/packages/cli/src/ui/components/ResumeSessionDialog.test.tsx +++ /dev/null @@ -1,303 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Code - * SPDX-License-Identifier: Apache-2.0 - */ - -import { render } from 'ink-testing-library'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { ResumeSessionDialog } from './ResumeSessionDialog.js'; -import { KeypressProvider } from '../contexts/KeypressContext.js'; -import type { - SessionListItem, - ListSessionsResult, -} from '@qwen-code/qwen-code-core'; - -// Mock terminal size -const mockTerminalSize = { columns: 80, rows: 24 }; - -beforeEach(() => { - Object.defineProperty(process.stdout, 'columns', { - value: mockTerminalSize.columns, - configurable: true, - }); - Object.defineProperty(process.stdout, 'rows', { - value: mockTerminalSize.rows, - configurable: true, - }); -}); - -// Mock SessionService and getGitBranch -vi.mock('@qwen-code/qwen-code-core', async () => { - const actual = await vi.importActual('@qwen-code/qwen-code-core'); - return { - ...actual, - SessionService: vi.fn().mockImplementation(() => mockSessionService), - getGitBranch: vi.fn().mockReturnValue('main'), - }; -}); - -// Helper to create mock sessions -function createMockSession( - overrides: Partial = {}, -): SessionListItem { - return { - sessionId: 'test-session-id', - cwd: '/test/path', - startTime: '2025-01-01T00:00:00.000Z', - mtime: Date.now(), - prompt: 'Test prompt', - gitBranch: 'main', - filePath: '/test/path/sessions/test-session-id.jsonl', - messageCount: 5, - ...overrides, - }; -} - -// Default mock session service -let mockSessionService = { - listSessions: vi.fn().mockResolvedValue({ - items: [], - hasMore: false, - nextCursor: undefined, - } as ListSessionsResult), -}; - -describe('ResumeSessionDialog', () => { - const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms)); - - afterEach(() => { - vi.clearAllMocks(); - }); - - describe('Loading State', () => { - it('should show loading state initially', () => { - const onSelect = vi.fn(); - const onCancel = vi.fn(); - - const { lastFrame } = render( - - - , - ); - - const output = lastFrame(); - expect(output).toContain('Resume Session'); - expect(output).toContain('Loading sessions...'); - }); - }); - - describe('Empty State', () => { - it('should show "No sessions found" when there are no sessions', async () => { - mockSessionService = { - listSessions: vi.fn().mockResolvedValue({ - items: [], - hasMore: false, - nextCursor: undefined, - } as ListSessionsResult), - }; - - const onSelect = vi.fn(); - const onCancel = vi.fn(); - - const { lastFrame } = render( - - - , - ); - - await wait(100); - - const output = lastFrame(); - expect(output).toContain('No sessions found'); - }); - }); - - describe('Session Display', () => { - it('should display sessions after loading', async () => { - const sessions = [ - createMockSession({ - sessionId: 'session-1', - prompt: 'First session prompt', - messageCount: 10, - }), - createMockSession({ - sessionId: 'session-2', - prompt: 'Second session prompt', - messageCount: 5, - }), - ]; - - mockSessionService = { - listSessions: vi.fn().mockResolvedValue({ - items: sessions, - hasMore: false, - nextCursor: undefined, - } as ListSessionsResult), - }; - - const onSelect = vi.fn(); - const onCancel = vi.fn(); - - const { lastFrame } = render( - - - , - ); - - await wait(100); - - const output = lastFrame(); - expect(output).toContain('First session prompt'); - }); - - it('should filter out empty sessions', async () => { - const sessions = [ - createMockSession({ - sessionId: 'empty-session', - prompt: '', - messageCount: 0, - }), - createMockSession({ - sessionId: 'valid-session', - prompt: 'Valid prompt', - messageCount: 5, - }), - ]; - - mockSessionService = { - listSessions: vi.fn().mockResolvedValue({ - items: sessions, - hasMore: false, - nextCursor: undefined, - } as ListSessionsResult), - }; - - const onSelect = vi.fn(); - const onCancel = vi.fn(); - - const { lastFrame } = render( - - - , - ); - - await wait(100); - - const output = lastFrame(); - expect(output).toContain('Valid prompt'); - // Empty session should be filtered out - expect(output).not.toContain('empty-session'); - }); - }); - - describe('Footer', () => { - it('should show navigation instructions in footer', async () => { - mockSessionService = { - listSessions: vi.fn().mockResolvedValue({ - items: [createMockSession()], - hasMore: false, - nextCursor: undefined, - } as ListSessionsResult), - }; - - const onSelect = vi.fn(); - const onCancel = vi.fn(); - - const { lastFrame } = render( - - - , - ); - - await wait(100); - - const output = lastFrame(); - expect(output).toContain('to navigate'); - expect(output).toContain('Enter to select'); - expect(output).toContain('Esc to cancel'); - }); - - it('should show branch toggle hint when currentBranch is available', async () => { - mockSessionService = { - listSessions: vi.fn().mockResolvedValue({ - items: [createMockSession()], - hasMore: false, - nextCursor: undefined, - } as ListSessionsResult), - }; - - const onSelect = vi.fn(); - const onCancel = vi.fn(); - - const { lastFrame } = render( - - - , - ); - - await wait(100); - - const output = lastFrame(); - // Should show B key hint since getGitBranch is mocked to return 'main' - expect(output).toContain('B'); - expect(output).toContain('toggle branch'); - }); - }); - - describe('Terminal Height', () => { - it('should accept availableTerminalHeight prop', async () => { - mockSessionService = { - listSessions: vi.fn().mockResolvedValue({ - items: [createMockSession()], - hasMore: false, - nextCursor: undefined, - } as ListSessionsResult), - }; - - const onSelect = vi.fn(); - const onCancel = vi.fn(); - - // Should not throw with availableTerminalHeight prop - const { lastFrame } = render( - - - , - ); - - await wait(100); - - const output = lastFrame(); - expect(output).toContain('Resume Session'); - }); - }); -}); diff --git a/packages/cli/src/ui/components/ResumeSessionDialog.tsx b/packages/cli/src/ui/components/ResumeSessionDialog.tsx deleted file mode 100644 index a52f89d0..00000000 --- a/packages/cli/src/ui/components/ResumeSessionDialog.tsx +++ /dev/null @@ -1,147 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Code - * SPDX-License-Identifier: Apache-2.0 - */ - -import { useState, useEffect, useRef } from 'react'; -import { Box, Text } from 'ink'; -import { SessionService, getGitBranch } from '@qwen-code/qwen-code-core'; -import { theme } from '../semantic-colors.js'; -import { useDialogSessionPicker } from '../hooks/useDialogSessionPicker.js'; -import { SessionListItemView } from './SessionListItem.js'; -import { t } from '../../i18n/index.js'; - -export interface ResumeSessionDialogProps { - cwd: string; - onSelect: (sessionId: string) => void; - onCancel: () => void; - availableTerminalHeight?: number; -} - -export function ResumeSessionDialog({ - cwd, - onSelect, - onCancel, - availableTerminalHeight, -}: ResumeSessionDialogProps): React.JSX.Element { - const sessionServiceRef = useRef(null); - const [currentBranch, setCurrentBranch] = useState(); - const [isReady, setIsReady] = useState(false); - - // Initialize session service - useEffect(() => { - sessionServiceRef.current = new SessionService(cwd); - setCurrentBranch(getGitBranch(cwd)); - setIsReady(true); - }, [cwd]); - - // Calculate visible items based on terminal height - const maxVisibleItems = availableTerminalHeight - ? Math.max(3, Math.floor((availableTerminalHeight - 6) / 3)) - : 5; - - const picker = useDialogSessionPicker({ - sessionService: sessionServiceRef.current, - currentBranch, - onSelect, - onCancel, - maxVisibleItems, - centerSelection: false, - isActive: isReady, - }); - - if (!isReady || picker.isLoading) { - return ( - - - {t('Resume Session')} - - - {t('Loading sessions...')} - - - ); - } - - return ( - - {/* Header */} - - - {t('Resume Session')} - - {picker.filterByBranch && currentBranch && ( - - {' '} - {t('(branch: {{branch}})', { branch: currentBranch })} - - )} - - - {/* Session List */} - - {picker.filteredSessions.length === 0 ? ( - - - {picker.filterByBranch - ? t('No sessions found for branch "{{branch}}"', { - branch: currentBranch ?? '', - }) - : t('No sessions found')} - - - ) : ( - picker.visibleSessions.map((session, visibleIndex) => { - const actualIndex = picker.scrollOffset + visibleIndex; - return ( - - ); - }) - )} - - - {/* Footer */} - - - {currentBranch && ( - <> - - B - - {t(' to toggle branch') + ' · '} - - )} - {t('to navigate · Enter to select · Esc to cancel')} - - - - ); -} diff --git a/packages/cli/src/ui/components/SessionListItem.tsx b/packages/cli/src/ui/components/SessionListItem.tsx deleted file mode 100644 index 7e577b4c..00000000 --- a/packages/cli/src/ui/components/SessionListItem.tsx +++ /dev/null @@ -1,108 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Code - * SPDX-License-Identifier: Apache-2.0 - */ - -import { Box, Text } from 'ink'; -import type { SessionListItem as SessionData } from '@qwen-code/qwen-code-core'; -import { theme } from '../semantic-colors.js'; -import { formatRelativeTime } from '../utils/formatters.js'; -import { - truncateText, - formatMessageCount, -} from '../utils/sessionPickerUtils.js'; - -export interface SessionListItemViewProps { - session: SessionData; - isSelected: boolean; - isFirst: boolean; - isLast: boolean; - showScrollUp: boolean; - showScrollDown: boolean; - maxPromptWidth: number; - /** - * Prefix characters for selection indicator and scroll hints. - * Dialog style uses '> ', '^ ', 'v ' (ASCII). - * Standalone style uses special Unicode characters. - */ - prefixChars?: { - selected: string; - scrollUp: string; - scrollDown: string; - normal: string; - }; - /** - * Whether to bold the prefix when selected. - */ - boldSelectedPrefix?: boolean; -} - -const DEFAULT_PREFIX_CHARS = { - selected: '> ', - scrollUp: '^ ', - scrollDown: 'v ', - normal: ' ', -}; - -export function SessionListItemView({ - session, - isSelected, - isFirst, - isLast, - showScrollUp, - showScrollDown, - maxPromptWidth, - prefixChars = DEFAULT_PREFIX_CHARS, - boldSelectedPrefix = true, -}: SessionListItemViewProps): React.JSX.Element { - const timeAgo = formatRelativeTime(session.mtime); - const messageText = formatMessageCount(session.messageCount); - - const showUpIndicator = isFirst && showScrollUp; - const showDownIndicator = isLast && showScrollDown; - - const prefix = isSelected - ? prefixChars.selected - : showUpIndicator - ? prefixChars.scrollUp - : showDownIndicator - ? prefixChars.scrollDown - : prefixChars.normal; - - const promptText = session.prompt || '(empty prompt)'; - const truncatedPrompt = truncateText(promptText, maxPromptWidth); - - return ( - - {/* First line: prefix + prompt text */} - - - {prefix} - - - {truncatedPrompt} - - - {/* Second line: metadata */} - - - {timeAgo} · {messageText} - {session.gitBranch && ` · ${session.gitBranch}`} - - - - ); -} diff --git a/packages/cli/src/ui/components/SessionPicker.tsx b/packages/cli/src/ui/components/SessionPicker.tsx new file mode 100644 index 00000000..767a1353 --- /dev/null +++ b/packages/cli/src/ui/components/SessionPicker.tsx @@ -0,0 +1,275 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Text } from 'ink'; +import { useEffect, useState } from 'react'; +import type { + SessionListItem as SessionData, + SessionService, +} from '@qwen-code/qwen-code-core'; +import { theme } from '../semantic-colors.js'; +import { useSessionPicker } from '../hooks/useSessionPicker.js'; +import { formatRelativeTime } from '../utils/formatters.js'; +import { + formatMessageCount, + truncateText, +} from '../utils/sessionPickerUtils.js'; +import { t } from '../../i18n/index.js'; + +export interface SessionPickerProps { + sessionService: SessionService | null; + onSelect: (sessionId: string) => void; + onCancel: () => void; + currentBranch?: string; + + /** + * Scroll mode. When true, keep selection centered (fullscreen-style). + * Defaults to true so dialog + standalone behave identically. + */ + centerSelection?: boolean; +} + +const PREFIX_CHARS = { + selected: '› ', + scrollUp: '↑ ', + scrollDown: '↓ ', + normal: ' ', +}; + +interface SessionListItemViewProps { + session: SessionData; + isSelected: boolean; + isFirst: boolean; + isLast: boolean; + showScrollUp: boolean; + showScrollDown: boolean; + maxPromptWidth: number; + prefixChars?: { + selected: string; + scrollUp: string; + scrollDown: string; + normal: string; + }; + boldSelectedPrefix?: boolean; +} + +function SessionListItemView({ + session, + isSelected, + isFirst, + isLast, + showScrollUp, + showScrollDown, + maxPromptWidth, + prefixChars = PREFIX_CHARS, + boldSelectedPrefix = true, +}: SessionListItemViewProps): React.JSX.Element { + const timeAgo = formatRelativeTime(session.mtime); + const messageText = formatMessageCount(session.messageCount); + + const showUpIndicator = isFirst && showScrollUp; + const showDownIndicator = isLast && showScrollDown; + + const prefix = isSelected + ? prefixChars.selected + : showUpIndicator + ? prefixChars.scrollUp + : showDownIndicator + ? prefixChars.scrollDown + : prefixChars.normal; + + const promptText = session.prompt || '(empty prompt)'; + const truncatedPrompt = truncateText(promptText, maxPromptWidth); + + return ( + + + + {prefix} + + + {truncatedPrompt} + + + + + {timeAgo} · {messageText} + {session.gitBranch && ` · ${session.gitBranch}`} + + + + ); +} + +export function SessionPicker(props: SessionPickerProps) { + const { + sessionService, + onSelect, + onCancel, + currentBranch, + centerSelection = true, + } = props; + + const [terminalSize, setTerminalSize] = useState({ + width: process.stdout.columns || 80, + height: process.stdout.rows || 24, + }); + + // Keep fullscreen picker responsive to terminal resize. + useEffect(() => { + const handleResize = () => { + setTerminalSize({ + width: process.stdout.columns || 80, + height: process.stdout.rows || 24, + }); + }; + + // `stdout` emits "resize" when TTY size changes. + process.stdout.on('resize', handleResize); + return () => { + process.stdout.off('resize', handleResize); + }; + }, []); + + // Calculate visible items (same heuristic as before) + // Reserved space: header (1), footer (1), separators (2), borders (2) + const reservedLines = 6; + // Each item takes 2 lines (prompt + metadata) + 1 line margin between items + const itemHeight = 3; + const maxVisibleItems = Math.max( + 1, + Math.floor((terminalSize.height - reservedLines) / itemHeight), + ); + + const picker = useSessionPicker({ + sessionService, + currentBranch, + onSelect, + onCancel, + maxVisibleItems, + centerSelection, + isActive: true, + }); + + const width = terminalSize.width; + const height = terminalSize.height; + + // Calculate content width (terminal width minus border padding) + const contentWidth = width - 4; + const promptMaxWidth = contentWidth - 4; + + return ( + + + {/* Header row */} + + + {t('Resume Session')} + + {picker.filterByBranch && currentBranch && ( + + {' '} + {t('(branch: {{branch}})', { branch: currentBranch })} + + )} + + + {/* Separator */} + + {'─'.repeat(width - 2)} + + + {/* Session list */} + + {!sessionService || picker.isLoading ? ( + + + {t('Loading sessions...')} + + + ) : picker.filteredSessions.length === 0 ? ( + + + {picker.filterByBranch + ? t('No sessions found for branch "{{branch}}"', { + branch: currentBranch ?? '', + }) + : t('No sessions found')} + + + ) : ( + picker.visibleSessions.map((session, visibleIndex) => { + const actualIndex = picker.scrollOffset + visibleIndex; + return ( + + ); + }) + )} + + + {/* Separator */} + + {'─'.repeat(width - 2)} + + + {/* Footer */} + + + {currentBranch && ( + + + B + + {t(' to toggle branch')} · + + )} + + {t('↑↓ to navigate · Esc to cancel')} + + + + + + ); +} diff --git a/packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx b/packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx index c6841f2f..9a7c7b19 100644 --- a/packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx +++ b/packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx @@ -6,12 +6,21 @@ import { render } from 'ink-testing-library'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { SessionPicker } from './StandaloneSessionPicker.js'; +import { KeypressProvider } from '../contexts/KeypressContext.js'; +import { SessionPicker } from './SessionPicker.js'; import type { SessionListItem, ListSessionsResult, } from '@qwen-code/qwen-code-core'; +vi.mock('@qwen-code/qwen-code-core', async () => { + const actual = await vi.importActual('@qwen-code/qwen-code-core'); + return { + ...actual, + getGitBranch: vi.fn().mockReturnValue('main'), + }; +}); + // Mock terminal size const mockTerminalSize = { columns: 80, rows: 24 }; @@ -68,8 +77,8 @@ describe('SessionPicker', () => { vi.clearAllMocks(); }); - describe('Empty Sessions Filtering', () => { - it('should filter out sessions with 0 messages', async () => { + describe('Empty Sessions', () => { + it('should show sessions with 0 messages', async () => { const sessions = [ createMockSession({ sessionId: 'empty-1', @@ -92,24 +101,24 @@ describe('SessionPicker', () => { const onCancel = vi.fn(); const { lastFrame } = render( - , + + + , ); await wait(100); const output = lastFrame(); - // Should show the session with messages expect(output).toContain('Hello'); - // Should NOT show empty sessions - expect(output).not.toContain('empty-1'); - expect(output).not.toContain('empty-2'); + // Should show empty sessions too (rendered as "(empty prompt)" + "0 messages") + expect(output).toContain('0 messages'); }); - it('should show "No sessions found" when all sessions are empty', async () => { + it('should show sessions even when all sessions are empty', async () => { const sessions = [ createMockSession({ sessionId: 'empty-1', messageCount: 0 }), createMockSession({ sessionId: 'empty-2', messageCount: 0 }), @@ -119,17 +128,19 @@ describe('SessionPicker', () => { const onCancel = vi.fn(); const { lastFrame } = render( - , + + + , ); await wait(100); const output = lastFrame(); - expect(output).toContain('No sessions found'); + expect(output).toContain('0 messages'); }); it('should show sessions with 1 or more messages', async () => { @@ -150,11 +161,13 @@ describe('SessionPicker', () => { const onCancel = vi.fn(); const { lastFrame } = render( - , + + + , ); await wait(100); @@ -194,12 +207,14 @@ describe('SessionPicker', () => { const onCancel = vi.fn(); const { lastFrame, stdin } = render( - , + + + , ); await wait(100); @@ -246,12 +261,14 @@ describe('SessionPicker', () => { const onCancel = vi.fn(); const { lastFrame, stdin } = render( - , + + + , ); await wait(100); @@ -261,9 +278,9 @@ describe('SessionPicker', () => { await wait(50); const output = lastFrame(); - // Should only show non-empty sessions from main branch + // Should only show sessions from main branch (including 0-message sessions) expect(output).toContain('Valid main'); - expect(output).not.toContain('Empty main'); + expect(output).toContain('Empty main'); expect(output).not.toContain('Valid feature'); }); }); @@ -292,11 +309,13 @@ describe('SessionPicker', () => { const onCancel = vi.fn(); const { lastFrame, stdin } = render( - , + + + , ); await wait(100); @@ -332,11 +351,13 @@ describe('SessionPicker', () => { const onCancel = vi.fn(); const { stdin, unmount } = render( - , + + + , ); await wait(100); @@ -365,11 +386,13 @@ describe('SessionPicker', () => { const onCancel = vi.fn(); const { stdin } = render( - , + + + , ); await wait(100); @@ -390,11 +413,13 @@ describe('SessionPicker', () => { const onCancel = vi.fn(); const { stdin } = render( - , + + + , ); await wait(100); @@ -423,11 +448,13 @@ describe('SessionPicker', () => { const onCancel = vi.fn(); const { lastFrame } = render( - , + + + , ); await wait(100); @@ -445,18 +472,20 @@ describe('SessionPicker', () => { const onCancel = vi.fn(); const { lastFrame } = render( - , + + + , ); await wait(100); const output = lastFrame(); expect(output).toContain('Resume Session'); - expect(output).toContain('to navigate'); + expect(output).toContain('↑↓ to navigate'); expect(output).toContain('Esc to cancel'); }); @@ -467,12 +496,14 @@ describe('SessionPicker', () => { const onCancel = vi.fn(); const { lastFrame } = render( - , + + + , ); await wait(100); @@ -492,11 +523,13 @@ describe('SessionPicker', () => { const onCancel = vi.fn(); const { lastFrame } = render( - , + + + , ); await wait(100); @@ -515,11 +548,13 @@ describe('SessionPicker', () => { const onCancel = vi.fn(); const { lastFrame } = render( - , + + + , ); await wait(100); @@ -569,11 +604,13 @@ describe('SessionPicker', () => { const onCancel = vi.fn(); const { unmount } = render( - , + + + , ); await wait(200); diff --git a/packages/cli/src/ui/components/StandaloneSessionPicker.tsx b/packages/cli/src/ui/components/StandaloneSessionPicker.tsx index 2f13f75c..bac7f23d 100644 --- a/packages/cli/src/ui/components/StandaloneSessionPicker.tsx +++ b/packages/cli/src/ui/components/StandaloneSessionPicker.tsx @@ -4,182 +4,51 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useEffect } from 'react'; -import { render, Box, Text, useApp } from 'ink'; -import { SessionService, getGitBranch } from '@qwen-code/qwen-code-core'; -import { theme } from '../semantic-colors.js'; -import { useSessionPicker } from '../hooks/useStandaloneSessionPicker.js'; -import { SessionListItemView } from './SessionListItem.js'; -import { t } from '../../i18n/index.js'; +import { useState } from 'react'; +import { render, Box, useApp } from 'ink'; +import { getGitBranch, SessionService } from '@qwen-code/qwen-code-core'; +import { KeypressProvider } from '../contexts/KeypressContext.js'; +import { SessionPicker } from './SessionPicker.js'; -// Exported for testing -export interface SessionPickerProps { +interface StandalonePickerScreenProps { sessionService: SessionService; - currentBranch?: string; onSelect: (sessionId: string) => void; onCancel: () => void; + currentBranch?: string; } -// Prefix characters for standalone fullscreen picker -const STANDALONE_PREFIX_CHARS = { - selected: '› ', - scrollUp: '↑ ', - scrollDown: '↓ ', - normal: ' ', -}; - -// Exported for testing -export function SessionPicker({ +function StandalonePickerScreen({ sessionService, - currentBranch, onSelect, onCancel, -}: SessionPickerProps): React.JSX.Element { + currentBranch, +}: StandalonePickerScreenProps): React.JSX.Element { const { exit } = useApp(); const [isExiting, setIsExiting] = useState(false); - const [terminalSize, setTerminalSize] = useState({ - width: process.stdout.columns || 80, - height: process.stdout.rows || 24, - }); - - // Update terminal size on resize - useEffect(() => { - const handleResize = () => { - setTerminalSize({ - width: process.stdout.columns || 80, - height: process.stdout.rows || 24, - }); - }; - process.stdout.on('resize', handleResize); - return () => { - process.stdout.off('resize', handleResize); - }; - }, []); - - // Calculate visible items - // Reserved space: header (1), footer (1), separators (2), borders (2) - const reservedLines = 6; - // Each item takes 2 lines (prompt + metadata) + 1 line margin between items - const itemHeight = 3; - const maxVisibleItems = Math.max( - 1, - Math.floor((terminalSize.height - reservedLines) / itemHeight), - ); - const handleExit = () => { setIsExiting(true); exit(); }; - const picker = useSessionPicker({ - sessionService, - currentBranch, - onSelect, - onCancel, - maxVisibleItems, - centerSelection: true, - onExit: handleExit, - isActive: !isExiting, - }); - - // Calculate content width (terminal width minus border padding) - const contentWidth = terminalSize.width - 4; - const promptMaxWidth = contentWidth - 4; - // Return empty while exiting to prevent visual glitches if (isExiting) { return ; } return ( - - {/* Main container with single border */} - - {/* Header row */} - - - {t('Resume Session')} - - - - {/* Separator line */} - - - {'─'.repeat(terminalSize.width - 2)} - - - - {/* Session list with auto-scrolling */} - - {picker.filteredSessions.length === 0 ? ( - - - {picker.filterByBranch - ? t('No sessions found for branch "{{branch}}"', { - branch: currentBranch ?? '', - }) - : t('No sessions found')} - - - ) : ( - picker.visibleSessions.map((session, visibleIndex) => { - const actualIndex = picker.scrollOffset + visibleIndex; - return ( - - ); - }) - )} - - - {/* Separator line */} - - - {'─'.repeat(terminalSize.width - 2)} - - - - {/* Footer with keyboard shortcuts */} - - - {currentBranch && ( - <> - - B - - {t(' to toggle branch') + ' · '} - - )} - {t('to navigate · Esc to cancel')} - - - - + { + onSelect(id); + handleExit(); + }} + onCancel={() => { + onCancel(); + handleExit(); + }} + currentBranch={currentBranch} + centerSelection={true} + /> ); } @@ -205,8 +74,6 @@ export async function showResumeSessionPicker( return undefined; } - const currentBranch = getGitBranch(cwd); - // Clear the screen before showing the picker for a clean fullscreen experience clearScreen(); @@ -220,16 +87,18 @@ export async function showResumeSessionPicker( let selectedId: string | undefined; const { unmount, waitUntilExit } = render( - { - selectedId = id; - }} - onCancel={() => { - selectedId = undefined; - }} - />, + + { + selectedId = id; + }} + onCancel={() => { + selectedId = undefined; + }} + currentBranch={getGitBranch(cwd)} + /> + , { exitOnCtrlC: false, }, diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index ff7b5909..ac762904 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -56,6 +56,7 @@ const SLASH_COMMANDS_SKIP_RECORDING = new Set([ 'clear', 'reset', 'new', + 'resume', ]); interface SlashCommandProcessorActions { diff --git a/packages/cli/src/ui/hooks/useDialogSessionPicker.ts b/packages/cli/src/ui/hooks/useSessionPicker.ts similarity index 73% rename from packages/cli/src/ui/hooks/useDialogSessionPicker.ts rename to packages/cli/src/ui/hooks/useSessionPicker.ts index 0292f829..7d451466 100644 --- a/packages/cli/src/ui/hooks/useDialogSessionPicker.ts +++ b/packages/cli/src/ui/hooks/useSessionPicker.ts @@ -5,25 +5,28 @@ */ /** - * Session picker hook for dialog mode (within main app). - * Uses useKeypress (KeypressContext) instead of useInput (ink). - * For standalone mode, use useSessionPicker instead. + * Unified session picker hook for both dialog and standalone modes. + * + * IMPORTANT: + * - Uses KeypressContext (`useKeypress`) so it behaves correctly inside the main app. + * - Standalone mode should wrap the picker in `` when rendered + * outside the main app. */ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { - SessionService, - SessionListItem, ListSessionsResult, + SessionListItem, + SessionService, } from '@qwen-code/qwen-code-core'; import { - SESSION_PAGE_SIZE, filterSessions, + SESSION_PAGE_SIZE, type SessionState, } from '../utils/sessionPickerUtils.js'; import { useKeypress } from './useKeypress.js'; -export interface UseDialogSessionPickerOptions { +export interface UseSessionPickerOptions { sessionService: SessionService | null; currentBranch?: string; onSelect: (sessionId: string) => void; @@ -40,8 +43,7 @@ export interface UseDialogSessionPickerOptions { isActive?: boolean; } -export interface UseDialogSessionPickerResult { - // State +export interface UseSessionPickerResult { selectedIndex: number; sessionState: SessionState; filteredSessions: SessionListItem[]; @@ -51,12 +53,10 @@ export interface UseDialogSessionPickerResult { visibleSessions: SessionListItem[]; showScrollUp: boolean; showScrollDown: boolean; - - // Actions loadMoreSessions: () => Promise; } -export function useDialogSessionPicker({ +export function useSessionPicker({ sessionService, currentBranch, onSelect, @@ -64,7 +64,7 @@ export function useDialogSessionPicker({ maxVisibleItems, centerSelection = false, isActive = true, -}: UseDialogSessionPickerOptions): UseDialogSessionPickerResult { +}: UseSessionPickerOptions): UseSessionPickerResult { const [selectedIndex, setSelectedIndex] = useState(0); const [sessionState, setSessionState] = useState({ sessions: [], @@ -73,43 +73,47 @@ export function useDialogSessionPicker({ }); const [filterByBranch, setFilterByBranch] = useState(false); const [isLoading, setIsLoading] = useState(true); + // For follow mode (non-centered) const [followScrollOffset, setFollowScrollOffset] = useState(0); const isLoadingMoreRef = useRef(false); - // Filter sessions - const filteredSessions = filterSessions( - sessionState.sessions, - filterByBranch, - currentBranch, + const filteredSessions = useMemo( + () => filterSessions(sessionState.sessions, filterByBranch, currentBranch), + [sessionState.sessions, filterByBranch, currentBranch], ); - // Calculate scroll offset based on mode - const scrollOffset = centerSelection - ? (() => { - if (filteredSessions.length <= maxVisibleItems) { - return 0; - } - const halfVisible = Math.floor(maxVisibleItems / 2); - let offset = selectedIndex - halfVisible; - offset = Math.max(0, offset); - offset = Math.min(filteredSessions.length - maxVisibleItems, offset); - return offset; - })() - : followScrollOffset; + const scrollOffset = useMemo(() => { + if (centerSelection) { + if (filteredSessions.length <= maxVisibleItems) { + return 0; + } + const halfVisible = Math.floor(maxVisibleItems / 2); + let offset = selectedIndex - halfVisible; + offset = Math.max(0, offset); + offset = Math.min(filteredSessions.length - maxVisibleItems, offset); + return offset; + } + return followScrollOffset; + }, [ + centerSelection, + filteredSessions.length, + followScrollOffset, + maxVisibleItems, + selectedIndex, + ]); - const visibleSessions = filteredSessions.slice( - scrollOffset, - scrollOffset + maxVisibleItems, + const visibleSessions = useMemo( + () => filteredSessions.slice(scrollOffset, scrollOffset + maxVisibleItems), + [filteredSessions, maxVisibleItems, scrollOffset], ); const showScrollUp = scrollOffset > 0; const showScrollDown = scrollOffset + maxVisibleItems < filteredSessions.length; - // Load initial sessions + // Initial load useEffect(() => { - // Guard: don't load if sessionService is not ready if (!sessionService) { return; } @@ -128,10 +132,10 @@ export function useDialogSessionPicker({ setIsLoading(false); } }; - loadInitialSessions(); + + void loadInitialSessions(); }, [sessionService]); - // Load more sessions const loadMoreSessions = useCallback(async () => { if (!sessionService || !sessionState.hasMore || isLoadingMoreRef.current) { return; @@ -169,9 +173,8 @@ export function useDialogSessionPicker({ } }, [filteredSessions.length, selectedIndex]); - // Auto-load more when list is empty or near end (for centered mode) + // Auto-load more when centered mode hits the sentinel or list is empty. useEffect(() => { - // Don't auto-load during initial load or if not in centered mode if ( isLoading || !sessionState.hasMore || @@ -182,7 +185,6 @@ export function useDialogSessionPicker({ } const sentinelVisible = - sessionState.hasMore && scrollOffset + maxVisibleItems >= filteredSessions.length; const shouldLoadMore = filteredSessions.length === 0 || sentinelVisible; @@ -190,27 +192,25 @@ export function useDialogSessionPicker({ void loadMoreSessions(); } }, [ - isLoading, - filteredSessions.length, - loadMoreSessions, - sessionState.hasMore, - scrollOffset, - maxVisibleItems, centerSelection, + filteredSessions.length, + isLoading, + loadMoreSessions, + maxVisibleItems, + scrollOffset, + sessionState.hasMore, ]); - // Handle keyboard input using useKeypress (KeypressContext) + // Key handling (KeypressContext) useKeypress( (key) => { const { name, sequence, ctrl } = key; - // Escape or Ctrl+C to cancel if (name === 'escape' || (ctrl && name === 'c')) { onCancel(); return; } - // Enter to select if (name === 'return') { const session = filteredSessions[selectedIndex]; if (session) { @@ -219,11 +219,9 @@ export function useDialogSessionPicker({ return; } - // Navigation up if (name === 'up' || name === 'k') { setSelectedIndex((prev) => { const newIndex = Math.max(0, prev - 1); - // Adjust scroll offset if needed (for follow mode) if (!centerSelection && newIndex < followScrollOffset) { setFollowScrollOffset(newIndex); } @@ -232,7 +230,6 @@ export function useDialogSessionPicker({ return; } - // Navigation down if (name === 'down' || name === 'j') { if (filteredSessions.length === 0) { return; @@ -240,28 +237,28 @@ export function useDialogSessionPicker({ setSelectedIndex((prev) => { const newIndex = Math.min(filteredSessions.length - 1, prev + 1); - // Adjust scroll offset if needed (for follow mode) + if ( !centerSelection && newIndex >= followScrollOffset + maxVisibleItems ) { setFollowScrollOffset(newIndex - maxVisibleItems + 1); } - // Load more if near the end - if (newIndex >= filteredSessions.length - 3 && sessionState.hasMore) { - loadMoreSessions(); + + // Follow mode: load more when near the end. + if (!centerSelection && newIndex >= filteredSessions.length - 3) { + void loadMoreSessions(); } + return newIndex; }); return; } - // Toggle branch filter if (sequence === 'b' || sequence === 'B') { if (currentBranch) { setFilterByBranch((prev) => !prev); } - return; } }, { isActive }, diff --git a/packages/cli/src/ui/hooks/useSessionSelect.test.ts b/packages/cli/src/ui/hooks/useSessionSelect.test.ts new file mode 100644 index 00000000..780636ac --- /dev/null +++ b/packages/cli/src/ui/hooks/useSessionSelect.test.ts @@ -0,0 +1,97 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { act, renderHook } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { useSessionSelect } from './useSessionSelect.js'; + +vi.mock('../utils/resumeHistoryUtils.js', () => ({ + buildResumedHistoryItems: vi.fn(() => [{ id: 1, type: 'user', text: 'hi' }]), +})); + +vi.mock('@qwen-code/qwen-code-core', () => { + class SessionService { + constructor(_cwd: string) {} + async loadSession(_sessionId: string) { + return { conversation: [{ role: 'user', parts: [{ text: 'hello' }] }] }; + } + } + + return { + SessionService, + buildApiHistoryFromConversation: vi.fn(() => [{ role: 'user', parts: [] }]), + replayUiTelemetryFromConversation: vi.fn(), + uiTelemetryService: { reset: vi.fn() }, + }; +}); + +describe('useSessionSelect', () => { + it('no-ops when config is null', async () => { + const closeResumeDialog = vi.fn(); + const historyManager = { clearItems: vi.fn(), loadHistory: vi.fn() }; + const startNewSession = vi.fn(); + + const { result } = renderHook(() => + useSessionSelect({ + config: null, + closeResumeDialog, + historyManager, + startNewSession, + }), + ); + + await act(async () => { + await result.current('session-1'); + }); + + expect(closeResumeDialog).not.toHaveBeenCalled(); + expect(startNewSession).not.toHaveBeenCalled(); + expect(historyManager.clearItems).not.toHaveBeenCalled(); + expect(historyManager.loadHistory).not.toHaveBeenCalled(); + }); + + it('closes the dialog immediately and restores session state', async () => { + const closeResumeDialog = vi.fn(); + const historyManager = { clearItems: vi.fn(), loadHistory: vi.fn() }; + const startNewSession = vi.fn(); + const geminiClient = { + initialize: vi.fn(), + }; + + const config = { + getTargetDir: () => '/tmp', + getGeminiClient: () => geminiClient, + startNewSession: vi.fn(), + } as unknown as import('@qwen-code/qwen-code-core').Config; + + const { result } = renderHook(() => + useSessionSelect({ + config, + closeResumeDialog, + historyManager, + startNewSession, + }), + ); + + const resumePromise = act(async () => { + await result.current('session-2'); + }); + + expect(closeResumeDialog).toHaveBeenCalledTimes(1); + await resumePromise; + + expect(config.startNewSession).toHaveBeenCalledWith( + 'session-2', + expect.objectContaining({ + conversation: expect.anything(), + }), + ); + expect(startNewSession).toHaveBeenCalledWith('session-2'); + expect(geminiClient.initialize).toHaveBeenCalledTimes(1); + expect(historyManager.clearItems).toHaveBeenCalledTimes(1); + expect(historyManager.loadHistory).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/cli/src/ui/hooks/useSessionSelect.ts b/packages/cli/src/ui/hooks/useSessionSelect.ts new file mode 100644 index 00000000..17ef6879 --- /dev/null +++ b/packages/cli/src/ui/hooks/useSessionSelect.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useCallback } from 'react'; +import { SessionService, type Config } from '@qwen-code/qwen-code-core'; +import { buildResumedHistoryItems } from '../utils/resumeHistoryUtils.js'; +import type { UseHistoryManagerReturn } from './useHistoryManager.js'; + +export interface UseSessionSelectOptions { + config: Config | null; + historyManager: Pick; + closeResumeDialog: () => void; + startNewSession: (sessionId: string) => void; + remount?: () => void; +} + +/** + * Returns a stable callback to resume a saved session and restore UI + client state. + */ +export function useSessionSelect({ + config, + closeResumeDialog, + historyManager, + startNewSession, + remount, +}: UseSessionSelectOptions): (sessionId: string) => void { + return useCallback( + async (sessionId: string) => { + if (!config) { + return; + } + + // Close dialog immediately to prevent input capture during async operations. + closeResumeDialog(); + + const cwd = config.getTargetDir(); + const sessionService = new SessionService(cwd); + const sessionData = await sessionService.loadSession(sessionId); + + if (!sessionData) { + return; + } + + // Start new session in UI context. + startNewSession(sessionId); + + // Reset UI history. + const uiHistoryItems = buildResumedHistoryItems(sessionData, config); + historyManager.clearItems(); + historyManager.loadHistory(uiHistoryItems); + + // Update session history core. + config.startNewSession(sessionId, sessionData); + await config.getGeminiClient()?.initialize?.(); + + // Refresh terminal UI. + remount?.(); + }, + [closeResumeDialog, config, historyManager, startNewSession, remount], + ); +} diff --git a/packages/cli/src/ui/hooks/useStandaloneSessionPicker.ts b/packages/cli/src/ui/hooks/useStandaloneSessionPicker.ts deleted file mode 100644 index 601f49ed..00000000 --- a/packages/cli/src/ui/hooks/useStandaloneSessionPicker.ts +++ /dev/null @@ -1,287 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Code - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * Session picker hook for standalone mode (fullscreen CLI picker). - * Uses useInput (ink) instead of useKeypress (KeypressContext). - * For dialog mode within the main app, use useDialogSessionPicker instead. - */ - -import { useState, useEffect, useCallback, useRef } from 'react'; -import { useInput } from 'ink'; -import type { - SessionService, - SessionListItem, - ListSessionsResult, -} from '@qwen-code/qwen-code-core'; -import { - SESSION_PAGE_SIZE, - filterSessions, - type SessionState, -} from '../utils/sessionPickerUtils.js'; - -export interface UseSessionPickerOptions { - sessionService: SessionService | null; - currentBranch?: string; - onSelect: (sessionId: string) => void; - onCancel: () => void; - maxVisibleItems: number; - /** - * If true, computes centered scroll offset (keeps selection near middle). - * If false, uses follow mode (scrolls when selection reaches edge). - */ - centerSelection?: boolean; - /** - * Optional callback when exiting (for standalone mode). - */ - onExit?: () => void; - /** - * Enable/disable input handling. - */ - isActive?: boolean; -} - -export interface UseSessionPickerResult { - // State - selectedIndex: number; - sessionState: SessionState; - filteredSessions: SessionListItem[]; - filterByBranch: boolean; - isLoading: boolean; - scrollOffset: number; - visibleSessions: SessionListItem[]; - showScrollUp: boolean; - showScrollDown: boolean; - - // Actions - loadMoreSessions: () => Promise; -} - -export function useSessionPicker({ - sessionService, - currentBranch, - onSelect, - onCancel, - maxVisibleItems, - centerSelection = false, - onExit, - isActive = true, -}: UseSessionPickerOptions): UseSessionPickerResult { - const [selectedIndex, setSelectedIndex] = useState(0); - const [sessionState, setSessionState] = useState({ - sessions: [], - hasMore: true, - nextCursor: undefined, - }); - const [filterByBranch, setFilterByBranch] = useState(false); - const [isLoading, setIsLoading] = useState(true); - // For follow mode (non-centered) - const [followScrollOffset, setFollowScrollOffset] = useState(0); - - const isLoadingMoreRef = useRef(false); - - // Filter sessions - const filteredSessions = filterSessions( - sessionState.sessions, - filterByBranch, - currentBranch, - ); - - // Calculate scroll offset based on mode - const scrollOffset = centerSelection - ? (() => { - if (filteredSessions.length <= maxVisibleItems) { - return 0; - } - const halfVisible = Math.floor(maxVisibleItems / 2); - let offset = selectedIndex - halfVisible; - offset = Math.max(0, offset); - offset = Math.min(filteredSessions.length - maxVisibleItems, offset); - return offset; - })() - : followScrollOffset; - - const visibleSessions = filteredSessions.slice( - scrollOffset, - scrollOffset + maxVisibleItems, - ); - const showScrollUp = scrollOffset > 0; - const showScrollDown = - scrollOffset + maxVisibleItems < filteredSessions.length; - - // Load initial sessions - useEffect(() => { - // Guard: don't load if sessionService is not ready - if (!sessionService) { - return; - } - - const loadInitialSessions = async () => { - try { - const result: ListSessionsResult = await sessionService.listSessions({ - size: SESSION_PAGE_SIZE, - }); - setSessionState({ - sessions: result.items, - hasMore: result.hasMore, - nextCursor: result.nextCursor, - }); - } finally { - setIsLoading(false); - } - }; - loadInitialSessions(); - }, [sessionService]); - - // Load more sessions - const loadMoreSessions = useCallback(async () => { - if (!sessionService || !sessionState.hasMore || isLoadingMoreRef.current) { - return; - } - - isLoadingMoreRef.current = true; - try { - const result: ListSessionsResult = await sessionService.listSessions({ - size: SESSION_PAGE_SIZE, - cursor: sessionState.nextCursor, - }); - setSessionState((prev) => ({ - sessions: [...prev.sessions, ...result.items], - hasMore: result.hasMore && result.nextCursor !== undefined, - nextCursor: result.nextCursor, - })); - } finally { - isLoadingMoreRef.current = false; - } - }, [sessionService, sessionState.hasMore, sessionState.nextCursor]); - - // Reset selection when filter changes - useEffect(() => { - setSelectedIndex(0); - setFollowScrollOffset(0); - }, [filterByBranch]); - - // Ensure selectedIndex is valid when filtered sessions change - useEffect(() => { - if ( - selectedIndex >= filteredSessions.length && - filteredSessions.length > 0 - ) { - setSelectedIndex(filteredSessions.length - 1); - } - }, [filteredSessions.length, selectedIndex]); - - // Auto-load more when list is empty or near end (for centered mode) - useEffect(() => { - // Don't auto-load during initial load or if not in centered mode - if ( - isLoading || - !sessionState.hasMore || - isLoadingMoreRef.current || - !centerSelection - ) { - return; - } - - const sentinelVisible = - sessionState.hasMore && - scrollOffset + maxVisibleItems >= filteredSessions.length; - const shouldLoadMore = filteredSessions.length === 0 || sentinelVisible; - - if (shouldLoadMore) { - void loadMoreSessions(); - } - }, [ - isLoading, - filteredSessions.length, - loadMoreSessions, - sessionState.hasMore, - scrollOffset, - maxVisibleItems, - centerSelection, - ]); - - // Handle keyboard input - useInput( - (input, key) => { - // Escape or Ctrl+C to cancel - if (key.escape || (key.ctrl && input === 'c')) { - onCancel(); - onExit?.(); - return; - } - - // Enter to select - if (key.return) { - const session = filteredSessions[selectedIndex]; - if (session) { - onSelect(session.sessionId); - onExit?.(); - } - return; - } - - // Navigation up - if (key.upArrow || input === 'k') { - setSelectedIndex((prev) => { - const newIndex = Math.max(0, prev - 1); - // Adjust scroll offset if needed (for follow mode) - if (!centerSelection && newIndex < followScrollOffset) { - setFollowScrollOffset(newIndex); - } - return newIndex; - }); - return; - } - - // Navigation down - if (key.downArrow || input === 'j') { - if (filteredSessions.length === 0) { - return; - } - - setSelectedIndex((prev) => { - const newIndex = Math.min(filteredSessions.length - 1, prev + 1); - // Adjust scroll offset if needed (for follow mode) - if ( - !centerSelection && - newIndex >= followScrollOffset + maxVisibleItems - ) { - setFollowScrollOffset(newIndex - maxVisibleItems + 1); - } - // Load more if near the end - if (newIndex >= filteredSessions.length - 3 && sessionState.hasMore) { - loadMoreSessions(); - } - return newIndex; - }); - return; - } - - // Toggle branch filter - if (input === 'b' || input === 'B') { - if (currentBranch) { - setFilterByBranch((prev) => !prev); - } - return; - } - }, - { isActive }, - ); - - return { - selectedIndex, - sessionState, - filteredSessions, - filterByBranch, - isLoading, - scrollOffset, - visibleSessions, - showScrollUp, - showScrollDown, - loadMoreSessions, - }; -} diff --git a/packages/cli/src/ui/utils/sessionPickerUtils.test.ts b/packages/cli/src/ui/utils/sessionPickerUtils.test.ts new file mode 100644 index 00000000..e561199e --- /dev/null +++ b/packages/cli/src/ui/utils/sessionPickerUtils.test.ts @@ -0,0 +1,45 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { truncateText } from './sessionPickerUtils.js'; + +describe('sessionPickerUtils', () => { + describe('truncateText', () => { + it('returns the original text when it fits and has no newline', () => { + expect(truncateText('hello', 10)).toBe('hello'); + }); + + it('truncates long text with ellipsis', () => { + expect(truncateText('hello world', 5)).toBe('he...'); + }); + + it('truncates without ellipsis when maxWidth <= 3', () => { + expect(truncateText('hello', 3)).toBe('hel'); + expect(truncateText('hello', 2)).toBe('he'); + }); + + it('breaks at newline and returns only the first line', () => { + expect(truncateText('hello\nworld', 20)).toBe('hello'); + expect(truncateText('hello\r\nworld', 20)).toBe('hello'); + }); + + it('breaks at newline and still truncates the first line when needed', () => { + expect(truncateText('hello\nworld', 2)).toBe('he'); + expect(truncateText('hello\nworld', 3)).toBe('hel'); + expect(truncateText('hello\nworld', 4)).toBe('h...'); + }); + + it('does not add ellipsis when the string ends at a newline', () => { + expect(truncateText('hello\n', 20)).toBe('hello'); + expect(truncateText('hello\r\n', 20)).toBe('hello'); + }); + + it('returns only the first line even if there are multiple line breaks', () => { + expect(truncateText('hello\n\nworld', 20)).toBe('hello'); + }); + }); +}); diff --git a/packages/cli/src/ui/utils/sessionPickerUtils.ts b/packages/cli/src/ui/utils/sessionPickerUtils.ts index 89942fd8..3b8ab118 100644 --- a/packages/cli/src/ui/utils/sessionPickerUtils.ts +++ b/packages/cli/src/ui/utils/sessionPickerUtils.ts @@ -24,13 +24,14 @@ export const SESSION_PAGE_SIZE = 20; * Truncates text to fit within a given width, adding ellipsis if needed. */ export function truncateText(text: string, maxWidth: number): string { - if (text.length <= maxWidth) { - return text; + const firstLine = text.split(/\r?\n/, 1)[0]; + if (firstLine.length <= maxWidth) { + return firstLine; } if (maxWidth <= 3) { - return text.slice(0, maxWidth); + return firstLine.slice(0, maxWidth); } - return text.slice(0, maxWidth - 3) + '...'; + return firstLine.slice(0, maxWidth - 3) + '...'; } /** @@ -42,10 +43,6 @@ export function filterSessions( currentBranch?: string, ): SessionListItem[] { return sessions.filter((session) => { - // Always exclude sessions with no messages - if (session.messageCount === 0) { - return false; - } // Apply branch filter if enabled if (filterByBranch && currentBranch) { return session.gitBranch === currentBranch; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index d5b7f4be..1cb79905 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -741,9 +741,12 @@ export class Config { /** * Starts a new session and resets session-scoped services. */ - startNewSession(sessionId?: string): string { + startNewSession( + sessionId?: string, + sessionData?: ResumedSessionData, + ): string { this.sessionId = sessionId ?? randomUUID(); - this.sessionData = undefined; + this.sessionData = sessionData; this.chatRecordingService = this.chatRecordingEnabled ? new ChatRecordingService(this) : undefined; From fb8412a96a04e0bb742c8af6c0ea032ae2d0e20c Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Tue, 16 Dec 2025 20:03:49 +0800 Subject: [PATCH 33/40] code refactor --- packages/cli/src/ui/AppContainer.tsx | 16 +-- .../cli/src/ui/components/DialogManager.tsx | 2 +- .../cli/src/ui/contexts/UIActionsContext.tsx | 2 +- .../cli/src/ui/hooks/useResumeCommand.test.ts | 133 +++++++++++++++++- packages/cli/src/ui/hooks/useResumeCommand.ts | 59 +++++++- .../cli/src/ui/hooks/useSessionSelect.test.ts | 97 ------------- packages/cli/src/ui/hooks/useSessionSelect.ts | 64 --------- 7 files changed, 200 insertions(+), 173 deletions(-) delete mode 100644 packages/cli/src/ui/hooks/useSessionSelect.test.ts delete mode 100644 packages/cli/src/ui/hooks/useSessionSelect.ts diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 5da98f0a..e70c0446 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -99,7 +99,6 @@ import { processVisionSwitchOutcome } from './hooks/useVisionAutoSwitch.js'; import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js'; import { useAgentsManagerDialog } from './hooks/useAgentsManagerDialog.js'; import { useAttentionNotifications } from './hooks/useAttentionNotifications.js'; -import { useSessionSelect } from './hooks/useSessionSelect.js'; const CTRL_EXIT_PROMPT_DURATION_MS = 1000; @@ -437,13 +436,14 @@ export const AppContainer = (props: AppContainerProps) => { const { isModelDialogOpen, openModelDialog, closeModelDialog } = useModelCommand(); - const { isResumeDialogOpen, openResumeDialog, closeResumeDialog } = - useResumeCommand(); - - const handleResumeSessionSelect = useSessionSelect({ + const { + isResumeDialogOpen, + openResumeDialog, + closeResumeDialog, + handleResume, + } = useResumeCommand({ config, historyManager, - closeResumeDialog, startNewSession, remount: refreshStatic, }); @@ -1442,7 +1442,7 @@ export const AppContainer = (props: AppContainerProps) => { // Resume session dialog openResumeDialog, closeResumeDialog, - handleResumeSessionSelect, + handleResume, }), [ handleThemeSelect, @@ -1478,7 +1478,7 @@ export const AppContainer = (props: AppContainerProps) => { // Resume session dialog openResumeDialog, closeResumeDialog, - handleResumeSessionSelect, + handleResume, ], ); diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index d79014e8..ce2c93bb 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -296,7 +296,7 @@ export const DialogManager = ({ ); diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index f8456430..2e396335 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -67,7 +67,7 @@ export interface UIActions { // Resume session dialog openResumeDialog: () => void; closeResumeDialog: () => void; - handleResumeSessionSelect: (sessionId: string) => void; + handleResume: (sessionId: string) => void; } export const UIActionsContext = createContext(null); diff --git a/packages/cli/src/ui/hooks/useResumeCommand.test.ts b/packages/cli/src/ui/hooks/useResumeCommand.test.ts index 3303b644..a0441cd5 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.test.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.test.ts @@ -5,9 +5,59 @@ */ import { act, renderHook } from '@testing-library/react'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { useResumeCommand } from './useResumeCommand.js'; +const resumeMocks = vi.hoisted(() => { + let resolveLoadSession: + | ((value: { conversation: unknown } | undefined) => void) + | undefined; + let pendingLoadSession: + | Promise<{ conversation: unknown } | undefined> + | undefined; + + return { + createPendingLoadSession() { + pendingLoadSession = new Promise((resolve) => { + resolveLoadSession = resolve; + }); + return pendingLoadSession; + }, + resolvePendingLoadSession(value: { conversation: unknown } | undefined) { + resolveLoadSession?.(value); + }, + getPendingLoadSession() { + return pendingLoadSession; + }, + reset() { + resolveLoadSession = undefined; + pendingLoadSession = undefined; + }, + }; +}); + +vi.mock('../utils/resumeHistoryUtils.js', () => ({ + buildResumedHistoryItems: vi.fn(() => [{ id: 1, type: 'user', text: 'hi' }]), +})); + +vi.mock('@qwen-code/qwen-code-core', () => { + class SessionService { + constructor(_cwd: string) {} + async loadSession(_sessionId: string) { + return ( + resumeMocks.getPendingLoadSession() ?? + Promise.resolve({ + conversation: [{ role: 'user', parts: [{ text: 'hello' }] }], + }) + ); + } + } + + return { + SessionService, + }; +}); + describe('useResumeCommand', () => { it('should initialize with dialog closed', () => { const { result } = renderHook(() => useResumeCommand()); @@ -48,10 +98,91 @@ describe('useResumeCommand', () => { const initialOpenFn = result.current.openResumeDialog; const initialCloseFn = result.current.closeResumeDialog; + const initialHandleResume = result.current.handleResume; rerender(); expect(result.current.openResumeDialog).toBe(initialOpenFn); expect(result.current.closeResumeDialog).toBe(initialCloseFn); + expect(result.current.handleResume).toBe(initialHandleResume); + }); + + it('handleResume no-ops when config is null', async () => { + const historyManager = { clearItems: vi.fn(), loadHistory: vi.fn() }; + const startNewSession = vi.fn(); + + const { result } = renderHook(() => + useResumeCommand({ + config: null, + historyManager, + startNewSession, + }), + ); + + await act(async () => { + await result.current.handleResume('session-1'); + }); + + expect(startNewSession).not.toHaveBeenCalled(); + expect(historyManager.clearItems).not.toHaveBeenCalled(); + expect(historyManager.loadHistory).not.toHaveBeenCalled(); + }); + + it('handleResume closes the dialog immediately and restores session state', async () => { + resumeMocks.reset(); + resumeMocks.createPendingLoadSession(); + + const historyManager = { clearItems: vi.fn(), loadHistory: vi.fn() }; + const startNewSession = vi.fn(); + const geminiClient = { + initialize: vi.fn(), + }; + + const config = { + getTargetDir: () => '/tmp', + getGeminiClient: () => geminiClient, + startNewSession: vi.fn(), + } as unknown as import('@qwen-code/qwen-code-core').Config; + + const { result } = renderHook(() => + useResumeCommand({ + config, + historyManager, + startNewSession, + }), + ); + + // Open first so we can verify the dialog closes immediately. + act(() => { + result.current.openResumeDialog(); + }); + expect(result.current.isResumeDialogOpen).toBe(true); + + const resumePromise = act(async () => { + // Intentionally do not resolve loadSession yet. + await result.current.handleResume('session-2'); + }); + + // After the first flush, the dialog should already be closed even though + // the session load is still pending. + await act(async () => {}); + expect(result.current.isResumeDialogOpen).toBe(false); + + // Now finish the async load and let the handler complete. + resumeMocks.resolvePendingLoadSession({ + conversation: [{ role: 'user', parts: [{ text: 'hello' }] }], + }); + await resumePromise; + + expect(config.startNewSession).toHaveBeenCalledWith( + 'session-2', + expect.objectContaining({ + conversation: expect.anything(), + }), + ); + expect(startNewSession).toHaveBeenCalledWith('session-2'); + expect(geminiClient.initialize).toHaveBeenCalledTimes(1); + expect(historyManager.clearItems).toHaveBeenCalledTimes(1); + expect(historyManager.loadHistory).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/cli/src/ui/hooks/useResumeCommand.ts b/packages/cli/src/ui/hooks/useResumeCommand.ts index a0f683bf..8fc3d4dd 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.ts @@ -5,8 +5,27 @@ */ import { useState, useCallback } from 'react'; +import { SessionService, type Config } from '@qwen-code/qwen-code-core'; +import { buildResumedHistoryItems } from '../utils/resumeHistoryUtils.js'; +import type { UseHistoryManagerReturn } from './useHistoryManager.js'; -export function useResumeCommand() { +export interface UseResumeCommandOptions { + config: Config | null; + historyManager: Pick; + startNewSession: (sessionId: string) => void; + remount?: () => void; +} + +export interface UseResumeCommandResult { + isResumeDialogOpen: boolean; + openResumeDialog: () => void; + closeResumeDialog: () => void; + handleResume: (sessionId: string) => void; +} + +export function useResumeCommand( + options?: UseResumeCommandOptions, +): UseResumeCommandResult { const [isResumeDialogOpen, setIsResumeDialogOpen] = useState(false); const openResumeDialog = useCallback(() => { @@ -17,9 +36,47 @@ export function useResumeCommand() { setIsResumeDialogOpen(false); }, []); + const { config, historyManager, startNewSession, remount } = options ?? {}; + + const handleResume = useCallback( + async (sessionId: string) => { + if (!config || !historyManager || !startNewSession) { + return; + } + + // Close dialog immediately to prevent input capture during async operations. + closeResumeDialog(); + + const cwd = config.getTargetDir(); + const sessionService = new SessionService(cwd); + const sessionData = await sessionService.loadSession(sessionId); + + if (!sessionData) { + return; + } + + // Start new session in UI context. + startNewSession(sessionId); + + // Reset UI history. + const uiHistoryItems = buildResumedHistoryItems(sessionData, config); + historyManager.clearItems(); + historyManager.loadHistory(uiHistoryItems); + + // Update session history core. + config.startNewSession(sessionId, sessionData); + await config.getGeminiClient()?.initialize?.(); + + // Refresh terminal UI. + remount?.(); + }, + [closeResumeDialog, config, historyManager, startNewSession, remount], + ); + return { isResumeDialogOpen, openResumeDialog, closeResumeDialog, + handleResume, }; } diff --git a/packages/cli/src/ui/hooks/useSessionSelect.test.ts b/packages/cli/src/ui/hooks/useSessionSelect.test.ts deleted file mode 100644 index 780636ac..00000000 --- a/packages/cli/src/ui/hooks/useSessionSelect.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Code - * SPDX-License-Identifier: Apache-2.0 - */ - -import { act, renderHook } from '@testing-library/react'; -import { describe, it, expect, vi } from 'vitest'; -import { useSessionSelect } from './useSessionSelect.js'; - -vi.mock('../utils/resumeHistoryUtils.js', () => ({ - buildResumedHistoryItems: vi.fn(() => [{ id: 1, type: 'user', text: 'hi' }]), -})); - -vi.mock('@qwen-code/qwen-code-core', () => { - class SessionService { - constructor(_cwd: string) {} - async loadSession(_sessionId: string) { - return { conversation: [{ role: 'user', parts: [{ text: 'hello' }] }] }; - } - } - - return { - SessionService, - buildApiHistoryFromConversation: vi.fn(() => [{ role: 'user', parts: [] }]), - replayUiTelemetryFromConversation: vi.fn(), - uiTelemetryService: { reset: vi.fn() }, - }; -}); - -describe('useSessionSelect', () => { - it('no-ops when config is null', async () => { - const closeResumeDialog = vi.fn(); - const historyManager = { clearItems: vi.fn(), loadHistory: vi.fn() }; - const startNewSession = vi.fn(); - - const { result } = renderHook(() => - useSessionSelect({ - config: null, - closeResumeDialog, - historyManager, - startNewSession, - }), - ); - - await act(async () => { - await result.current('session-1'); - }); - - expect(closeResumeDialog).not.toHaveBeenCalled(); - expect(startNewSession).not.toHaveBeenCalled(); - expect(historyManager.clearItems).not.toHaveBeenCalled(); - expect(historyManager.loadHistory).not.toHaveBeenCalled(); - }); - - it('closes the dialog immediately and restores session state', async () => { - const closeResumeDialog = vi.fn(); - const historyManager = { clearItems: vi.fn(), loadHistory: vi.fn() }; - const startNewSession = vi.fn(); - const geminiClient = { - initialize: vi.fn(), - }; - - const config = { - getTargetDir: () => '/tmp', - getGeminiClient: () => geminiClient, - startNewSession: vi.fn(), - } as unknown as import('@qwen-code/qwen-code-core').Config; - - const { result } = renderHook(() => - useSessionSelect({ - config, - closeResumeDialog, - historyManager, - startNewSession, - }), - ); - - const resumePromise = act(async () => { - await result.current('session-2'); - }); - - expect(closeResumeDialog).toHaveBeenCalledTimes(1); - await resumePromise; - - expect(config.startNewSession).toHaveBeenCalledWith( - 'session-2', - expect.objectContaining({ - conversation: expect.anything(), - }), - ); - expect(startNewSession).toHaveBeenCalledWith('session-2'); - expect(geminiClient.initialize).toHaveBeenCalledTimes(1); - expect(historyManager.clearItems).toHaveBeenCalledTimes(1); - expect(historyManager.loadHistory).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/cli/src/ui/hooks/useSessionSelect.ts b/packages/cli/src/ui/hooks/useSessionSelect.ts deleted file mode 100644 index 17ef6879..00000000 --- a/packages/cli/src/ui/hooks/useSessionSelect.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Code - * SPDX-License-Identifier: Apache-2.0 - */ - -import { useCallback } from 'react'; -import { SessionService, type Config } from '@qwen-code/qwen-code-core'; -import { buildResumedHistoryItems } from '../utils/resumeHistoryUtils.js'; -import type { UseHistoryManagerReturn } from './useHistoryManager.js'; - -export interface UseSessionSelectOptions { - config: Config | null; - historyManager: Pick; - closeResumeDialog: () => void; - startNewSession: (sessionId: string) => void; - remount?: () => void; -} - -/** - * Returns a stable callback to resume a saved session and restore UI + client state. - */ -export function useSessionSelect({ - config, - closeResumeDialog, - historyManager, - startNewSession, - remount, -}: UseSessionSelectOptions): (sessionId: string) => void { - return useCallback( - async (sessionId: string) => { - if (!config) { - return; - } - - // Close dialog immediately to prevent input capture during async operations. - closeResumeDialog(); - - const cwd = config.getTargetDir(); - const sessionService = new SessionService(cwd); - const sessionData = await sessionService.loadSession(sessionId); - - if (!sessionData) { - return; - } - - // Start new session in UI context. - startNewSession(sessionId); - - // Reset UI history. - const uiHistoryItems = buildResumedHistoryItems(sessionData, config); - historyManager.clearItems(); - historyManager.loadHistory(uiHistoryItems); - - // Update session history core. - config.startNewSession(sessionId, sessionData); - await config.getGeminiClient()?.initialize?.(); - - // Refresh terminal UI. - remount?.(); - }, - [closeResumeDialog, config, historyManager, startNewSession, remount], - ); -} From 9267677d383f23e593be305bc3057cf4152decb8 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Tue, 16 Dec 2025 20:08:43 +0800 Subject: [PATCH 34/40] fix failed test --- .../cli/src/ui/hooks/useResumeCommand.test.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/ui/hooks/useResumeCommand.test.ts b/packages/cli/src/ui/hooks/useResumeCommand.test.ts index a0441cd5..daaedfcc 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.test.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.test.ts @@ -158,21 +158,23 @@ describe('useResumeCommand', () => { }); expect(result.current.isResumeDialogOpen).toBe(true); - const resumePromise = act(async () => { - // Intentionally do not resolve loadSession yet. - await result.current.handleResume('session-2'); + let resumePromise: Promise | undefined; + act(() => { + // Start resume but do not await it yet — we want to assert the dialog + // closes immediately before the async session load completes. + resumePromise = result.current.handleResume('session-2') as unknown as + | Promise + | undefined; }); - - // After the first flush, the dialog should already be closed even though - // the session load is still pending. - await act(async () => {}); expect(result.current.isResumeDialogOpen).toBe(false); // Now finish the async load and let the handler complete. resumeMocks.resolvePendingLoadSession({ conversation: [{ role: 'user', parts: [{ text: 'hello' }] }], }); - await resumePromise; + await act(async () => { + await resumePromise; + }); expect(config.startNewSession).toHaveBeenCalledWith( 'session-2', From bf52c6db0f845479286e9930cb6e8110b14a4c58 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Tue, 16 Dec 2025 20:36:24 +0800 Subject: [PATCH 35/40] fix review comments --- .../cli/src/ui/components/DialogManager.tsx | 4 +- .../cli/src/ui/components/SessionPicker.tsx | 40 ++++--------------- .../cli/src/ui/utils/sessionPickerUtils.ts | 2 +- 3 files changed, 11 insertions(+), 35 deletions(-) diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index ce2c93bb..c00c065e 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -28,7 +28,7 @@ import { useConfig } from '../contexts/ConfigContext.js'; import { useSettings } from '../contexts/SettingsContext.js'; import { SettingScope } from '../../config/settings.js'; import { AuthState } from '../types.js'; -import { AuthType, getGitBranch } from '@qwen-code/qwen-code-core'; +import { AuthType } from '@qwen-code/qwen-code-core'; import process from 'node:process'; import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js'; @@ -295,7 +295,7 @@ export const DialogManager = ({ return ( diff --git a/packages/cli/src/ui/components/SessionPicker.tsx b/packages/cli/src/ui/components/SessionPicker.tsx index 767a1353..9729d4c6 100644 --- a/packages/cli/src/ui/components/SessionPicker.tsx +++ b/packages/cli/src/ui/components/SessionPicker.tsx @@ -5,7 +5,6 @@ */ import { Box, Text } from 'ink'; -import { useEffect, useState } from 'react'; import type { SessionListItem as SessionData, SessionService, @@ -17,6 +16,7 @@ import { formatMessageCount, truncateText, } from '../utils/sessionPickerUtils.js'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; import { t } from '../../i18n/index.js'; export interface SessionPickerProps { @@ -125,27 +125,10 @@ export function SessionPicker(props: SessionPickerProps) { centerSelection = true, } = props; - const [terminalSize, setTerminalSize] = useState({ - width: process.stdout.columns || 80, - height: process.stdout.rows || 24, - }); - - // Keep fullscreen picker responsive to terminal resize. - useEffect(() => { - const handleResize = () => { - setTerminalSize({ - width: process.stdout.columns || 80, - height: process.stdout.rows || 24, - }); - }; - - // `stdout` emits "resize" when TTY size changes. - process.stdout.on('resize', handleResize); - return () => { - process.stdout.off('resize', handleResize); - }; - }, []); + const { columns: width, rows: height } = useTerminalSize(); + // Calculate box width (width + 6 for border padding) + const boxWidth = width + 6; // Calculate visible items (same heuristic as before) // Reserved space: header (1), footer (1), separators (2), borders (2) const reservedLines = 6; @@ -153,7 +136,7 @@ export function SessionPicker(props: SessionPickerProps) { const itemHeight = 3; const maxVisibleItems = Math.max( 1, - Math.floor((terminalSize.height - reservedLines) / itemHeight), + Math.floor((height - reservedLines) / itemHeight), ); const picker = useSessionPicker({ @@ -166,17 +149,10 @@ export function SessionPicker(props: SessionPickerProps) { isActive: true, }); - const width = terminalSize.width; - const height = terminalSize.height; - - // Calculate content width (terminal width minus border padding) - const contentWidth = width - 4; - const promptMaxWidth = contentWidth - 4; - return ( @@ -184,7 +160,7 @@ export function SessionPicker(props: SessionPickerProps) { flexDirection="column" borderStyle="round" borderColor={theme.border.default} - width={width} + width={boxWidth} height={height - 1} overflow="hidden" > @@ -236,7 +212,7 @@ export function SessionPicker(props: SessionPickerProps) { isLast={visibleIndex === picker.visibleSessions.length - 1} showScrollUp={picker.showScrollUp} showScrollDown={picker.showScrollDown} - maxPromptWidth={promptMaxWidth} + maxPromptWidth={width} prefixChars={PREFIX_CHARS} boldSelectedPrefix={false} /> diff --git a/packages/cli/src/ui/utils/sessionPickerUtils.ts b/packages/cli/src/ui/utils/sessionPickerUtils.ts index 3b8ab118..74560c5b 100644 --- a/packages/cli/src/ui/utils/sessionPickerUtils.ts +++ b/packages/cli/src/ui/utils/sessionPickerUtils.ts @@ -35,7 +35,7 @@ export function truncateText(text: string, maxWidth: number): string { } /** - * Filters sessions to exclude empty ones (0 messages) and optionally by branch. + * Filters sessions optionally by branch. */ export function filterSessions( sessions: SessionListItem[], From 8fd7490d8f8178190ab70b689cc5fabb547f6004 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Wed, 17 Dec 2025 09:27:25 +0800 Subject: [PATCH 36/40] remove one flaky integration test --- integration-tests/utf-bom-encoding.test.ts | 21 ++------------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/integration-tests/utf-bom-encoding.test.ts b/integration-tests/utf-bom-encoding.test.ts index 4429b700..bb682de1 100644 --- a/integration-tests/utf-bom-encoding.test.ts +++ b/integration-tests/utf-bom-encoding.test.ts @@ -5,8 +5,8 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { writeFileSync, readFileSync } from 'node:fs'; -import { join, resolve } from 'node:path'; +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; import { TestRig } from './test-helper.js'; // Windows skip (Option A: avoid infra scope) @@ -121,21 +121,4 @@ d('BOM end-to-end integration', () => { 'BOM_OK UTF-32BE', ); }); - - it('Can describe a PNG file', async () => { - const imagePath = resolve( - process.cwd(), - 'docs/assets/gemini-screenshot.png', - ); - const imageContent = readFileSync(imagePath); - const filename = 'gemini-screenshot.png'; - writeFileSync(join(dir, filename), imageContent); - const prompt = `What is shown in the image ${filename}?`; - const output = await rig.run(prompt); - await rig.waitForToolCall('read_file'); - const lower = output.toLowerCase(); - // The response is non-deterministic, so we just check for some - // keywords that are very likely to be in the response. - expect(lower.includes('gemini')).toBeTruthy(); - }); }); From a4e3d764d305f278e4b3589f1a3197327c6717d9 Mon Sep 17 00:00:00 2001 From: joeytoday Date: Wed, 17 Dec 2025 11:10:31 +0800 Subject: [PATCH 37/40] docs: updated all links, click and open in vscode, new showcase video in overview --- docs/users/common-workflow.md | 6 +- docs/users/configuration/qwen-ignore.md | 2 +- docs/users/configuration/settings.md | 30 ++++---- docs/users/configuration/themes.md | 4 +- docs/users/configuration/trusted-folders.md | 2 +- docs/users/features/approval-mode.md | 2 + docs/users/features/headless.md | 10 +-- docs/users/features/mcp.md | 5 +- docs/users/features/sandbox.md | 6 +- docs/users/ide-integration/ide-integration.md | 2 +- docs/users/integration-github-action.md | 73 ++++--------------- docs/users/overview.md | 10 +-- docs/users/quickstart.md | 2 +- docs/users/support/tos-privacy.md | 6 +- docs/users/support/troubleshooting.md | 4 +- 15 files changed, 64 insertions(+), 100 deletions(-) diff --git a/docs/users/common-workflow.md b/docs/users/common-workflow.md index 632a2127..078447cf 100644 --- a/docs/users/common-workflow.md +++ b/docs/users/common-workflow.md @@ -189,8 +189,8 @@ Then select "create" and follow the prompts to define: > - Create project-specific subagents in `.qwen/agents/` for team sharing > - Use descriptive `description` fields to enable automatic delegation > - Limit tool access to what each subagent actually needs -> - Know more about [Sub Agents](../users/features/sub-agents) -> - Know more about [Approval Mode](../users/features/approval-mode) +> - Know more about [Sub Agents](./features/sub-agents) +> - Know more about [Approval Mode](./features/approval-mode) ## Work with tests @@ -318,7 +318,7 @@ This provides a directory listing with file information. Show me the data from @github: repos/owner/repo/issues ``` -This fetches data from connected MCP servers using the format @server: resource. See [MCP](../users/features/mcp) for details. +This fetches data from connected MCP servers using the format @server: resource. See [MCP](./features/mcp) for details. > [!tip] > diff --git a/docs/users/configuration/qwen-ignore.md b/docs/users/configuration/qwen-ignore.md index 25087657..ad7cac78 100644 --- a/docs/users/configuration/qwen-ignore.md +++ b/docs/users/configuration/qwen-ignore.md @@ -6,7 +6,7 @@ Qwen Code includes the ability to automatically ignore files, similar to `.gitig ## How it works -When you add a path to your `.qwenignore` file, tools that respect this file will exclude matching files and directories from their operations. For example, when you use the [`read_many_files`](/developers/tools/multi-file) command, any paths in your `.qwenignore` file will be automatically excluded. +When you add a path to your `.qwenignore` file, tools that respect this file will exclude matching files and directories from their operations. For example, when you use the [`read_many_files`](../../developers/tools/multi-file) command, any paths in your `.qwenignore` file will be automatically excluded. For the most part, `.qwenignore` follows the conventions of `.gitignore` files: diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 7f97b8f0..6f68cf60 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -2,7 +2,7 @@ > [!tip] > -> **Authentication / API keys:** Authentication (Qwen OAuth vs OpenAI-compatible API) and auth-related environment variables (like `OPENAI_API_KEY`) are documented in **[Authentication](../users/configuration/auth)**. +> **Authentication / API keys:** Authentication (Qwen OAuth vs OpenAI-compatible API) and auth-related environment variables (like `OPENAI_API_KEY`) are documented in **[Authentication](../configuration/auth)**. > [!note] > @@ -42,7 +42,7 @@ Qwen Code uses JSON settings files for persistent configuration. There are four In addition to a project settings file, a project's `.qwen` directory can contain other project-specific files related to Qwen Code's operation, such as: -- [Custom sandbox profiles](../users/features/sandbox) (e.g. `.qwen/sandbox-macos-custom.sb`, `.qwen/sandbox.Dockerfile`). +- [Custom sandbox profiles](../features/sandbox) (e.g. `.qwen/sandbox-macos-custom.sb`, `.qwen/sandbox.Dockerfile`). ### Available settings in `settings.json` @@ -68,7 +68,7 @@ Settings are organized into categories. All settings should be placed within the | Setting | Type | Description | Default | | ---------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | -| `ui.theme` | string | The color theme for the UI. See [Themes](../users/configuration/themes) for available options. | `undefined` | +| `ui.theme` | string | The color theme for the UI. See [Themes](../configuration/themes) for available options. | `undefined` | | `ui.customThemes` | object | Custom theme definitions. | `{}` | | `ui.hideWindowTitle` | boolean | Hide the window title bar. | `false` | | `ui.hideTips` | boolean | Hide helpful tips in the UI. | `false` | @@ -325,7 +325,7 @@ The CLI keeps a history of shell commands you run. To avoid conflicts between di Environment variables are a common way to configure applications, especially for sensitive information (like tokens) or for settings that might change between environments. Qwen Code can automatically load environment variables from `.env` files. -For authentication-related variables (like `OPENAI_*`) and the recommended `.qwen/.env` approach, see **[Authentication](../users/configuration/auth)**. +For authentication-related variables (like `OPENAI_*`) and the recommended `.qwen/.env` approach, see **[Authentication](../configuration/auth)**. > [!tip] > @@ -361,9 +361,9 @@ Arguments passed directly when running the CLI can override other configurations | `--model` | `-m` | Specifies the Qwen model to use for this session. | Model name | Example: `npm start -- --model qwen3-coder-plus` | | `--prompt` | `-p` | Used to pass a prompt directly to the command. This invokes Qwen Code in a non-interactive mode. | Your prompt text | For scripting examples, use the `--output-format json` flag to get structured output. | | `--prompt-interactive` | `-i` | Starts an interactive session with the provided prompt as the initial input. | Your prompt text | The prompt is processed within the interactive session, not before it. Cannot be used when piping input from stdin. Example: `qwen -i "explain this code"` | -| `--output-format` | `-o` | Specifies the format of the CLI output for non-interactive mode. | `text`, `json`, `stream-json` | `text`: (Default) The standard human-readable output. `json`: A machine-readable JSON output emitted at the end of execution. `stream-json`: Streaming JSON messages emitted as they occur during execution. For structured output and scripting, use the `--output-format json` or `--output-format stream-json` flag. See [Headless Mode](../users/features/headless) for detailed information. | -| `--input-format` | | Specifies the format consumed from standard input. | `text`, `stream-json` | `text`: (Default) Standard text input from stdin or command-line arguments. `stream-json`: JSON message protocol via stdin for bidirectional communication. Requirement: `--input-format stream-json` requires `--output-format stream-json` to be set. When using `stream-json`, stdin is reserved for protocol messages. See [Headless Mode](../users/features/headless) for detailed information. | -| `--include-partial-messages` | | Include partial assistant messages when using `stream-json` output format. When enabled, emits stream events (message_start, content_block_delta, etc.) as they occur during streaming. | | Default: `false`. Requirement: Requires `--output-format stream-json` to be set. See [Headless Mode](../users/features/headless) for detailed information about stream events. | +| `--output-format` | `-o` | Specifies the format of the CLI output for non-interactive mode. | `text`, `json`, `stream-json` | `text`: (Default) The standard human-readable output. `json`: A machine-readable JSON output emitted at the end of execution. `stream-json`: Streaming JSON messages emitted as they occur during execution. For structured output and scripting, use the `--output-format json` or `--output-format stream-json` flag. See [Headless Mode](../features/headless) for detailed information. | +| `--input-format` | | Specifies the format consumed from standard input. | `text`, `stream-json` | `text`: (Default) Standard text input from stdin or command-line arguments. `stream-json`: JSON message protocol via stdin for bidirectional communication. Requirement: `--input-format stream-json` requires `--output-format stream-json` to be set. When using `stream-json`, stdin is reserved for protocol messages. See [Headless Mode](../features/headless) for detailed information. | +| `--include-partial-messages` | | Include partial assistant messages when using `stream-json` output format. When enabled, emits stream events (message_start, content_block_delta, etc.) as they occur during streaming. | | Default: `false`. Requirement: Requires `--output-format stream-json` to be set. See [Headless Mode](../features/headless) for detailed information about stream events. | | `--sandbox` | `-s` | Enables sandbox mode for this session. | | | | `--sandbox-image` | | Sets the sandbox image URI. | | | | `--debug` | `-d` | Enables debug mode for this session, providing more verbose output. | | | @@ -371,14 +371,14 @@ Arguments passed directly when running the CLI can override other configurations | `--help` | `-h` | Displays help information about command-line arguments. | | | | `--show-memory-usage` | | Displays the current memory usage. | | | | `--yolo` | | Enables YOLO mode, which automatically approves all tool calls. | | | -| `--approval-mode` | | Sets the approval mode for tool calls. | `plan`, `default`, `auto-edit`, `yolo` | Supported modes: `plan`: Analyze only—do not modify files or execute commands. `default`: Require approval for file edits or shell commands (default behavior). `auto-edit`: Automatically approve edit tools (edit, write_file) while prompting for others. `yolo`: Automatically approve all tool calls (equivalent to `--yolo`). Cannot be used together with `--yolo`. Use `--approval-mode=yolo` instead of `--yolo` for the new unified approach. Example: `qwen --approval-mode auto-edit`
See more about [Approval Mode](/users/features/approval-mode). | +| `--approval-mode` | | Sets the approval mode for tool calls. | `plan`, `default`, `auto-edit`, `yolo` | Supported modes: `plan`: Analyze only—do not modify files or execute commands. `default`: Require approval for file edits or shell commands (default behavior). `auto-edit`: Automatically approve edit tools (edit, write_file) while prompting for others. `yolo`: Automatically approve all tool calls (equivalent to `--yolo`). Cannot be used together with `--yolo`. Use `--approval-mode=yolo` instead of `--yolo` for the new unified approach. Example: `qwen --approval-mode auto-edit`
See more about [Approval Mode](../features/approval-mode). | | `--allowed-tools` | | A comma-separated list of tool names that will bypass the confirmation dialog. | Tool names | Example: `qwen --allowed-tools "Shell(git status)"` | | `--telemetry` | | Enables [telemetry](/developers/development/telemetry). | | | | `--telemetry-target` | | Sets the telemetry target. | | See [telemetry](/developers/development/telemetry) for more information. | -| `--telemetry-otlp-endpoint` | | Sets the OTLP endpoint for telemetry. | | See [telemetry](/developers/development/telemetry) for more information. | -| `--telemetry-otlp-protocol` | | Sets the OTLP protocol for telemetry (`grpc` or `http`). | | Defaults to `grpc`. See [telemetry](/developers/development/telemetry) for more information. | -| `--telemetry-log-prompts` | | Enables logging of prompts for telemetry. | | See [telemetry](/developers/development/telemetry) for more information. | -| `--checkpointing` | | Enables [checkpointing](../users/features/checkpointing). | | | +| `--telemetry-otlp-endpoint` | | Sets the OTLP endpoint for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. | +| `--telemetry-otlp-protocol` | | Sets the OTLP protocol for telemetry (`grpc` or `http`). | | Defaults to `grpc`. See [telemetry](../../developers/development/telemetry) for more information. | +| `--telemetry-log-prompts` | | Enables logging of prompts for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. | +| `--checkpointing` | | Enables [checkpointing](../features/checkpointing). | | | | `--extensions` | `-e` | Specifies a list of extensions to use for the session. | Extension names | If not provided, all available extensions are used. Use the special term `qwen -e none` to disable all extensions. Example: `qwen -e my-extension -e my-other-extension` | | `--list-extensions` | `-l` | Lists all available extensions and exits. | | | | `--proxy` | | Sets the proxy for the CLI. | Proxy URL | Example: `--proxy http://localhost:7890`. | @@ -437,11 +437,11 @@ This example demonstrates how you can provide general project context, specific - Location: The CLI also scans for the configured context file in subdirectories _below_ the current working directory (respecting common ignore patterns like `node_modules`, `.git`, etc.). The breadth of this search is limited to 200 directories by default, but can be configured with the `context.discoveryMaxDirs` setting in your `settings.json` file. - Scope: Allows for highly specific instructions relevant to a particular component, module, or subsection of your project. - **Concatenation & UI Indication:** The contents of all found context files are concatenated (with separators indicating their origin and path) and provided as part of the system prompt. The CLI footer displays the count of loaded context files, giving you a quick visual cue about the active instructional context. -- **Importing Content:** You can modularize your context files by importing other Markdown files using the `@path/to/file.md` syntax. For more details, see the [Memory Import Processor documentation](../users/configuration/memory). +- **Importing Content:** You can modularize your context files by importing other Markdown files using the `@path/to/file.md` syntax. For more details, see the [Memory Import Processor documentation](../configuration/memory). - **Commands for Memory Management:** - Use `/memory refresh` to force a re-scan and reload of all context files from all configured locations. This updates the AI's instructional context. - Use `/memory show` to display the combined instructional context currently loaded, allowing you to verify the hierarchy and content being used by the AI. - - See the [Commands documentation](../users/reference/cli-reference) for full details on the `/memory` command and its sub-commands (`show` and `refresh`). + - See the [Commands documentation](../features/commands) for full details on the `/memory` command and its sub-commands (`show` and `refresh`). By understanding and utilizing these configuration layers and the hierarchical nature of context files, you can effectively manage the AI's memory and tailor Qwen Code's responses to your specific needs and projects. @@ -449,7 +449,7 @@ By understanding and utilizing these configuration layers and the hierarchical n Qwen Code can execute potentially unsafe operations (like shell commands and file modifications) within a sandboxed environment to protect your system. -[Sandbox](../users/features/sandbox) is disabled by default, but you can enable it in a few ways: +[Sandbox](../features/sandbox) is disabled by default, but you can enable it in a few ways: - Using `--sandbox` or `-s` flag. - Setting `GEMINI_SANDBOX` environment variable. diff --git a/docs/users/configuration/themes.md b/docs/users/configuration/themes.md index d17498ea..40b51535 100644 --- a/docs/users/configuration/themes.md +++ b/docs/users/configuration/themes.md @@ -32,7 +32,7 @@ Qwen Code comes with a selection of pre-defined themes, which you can list using ### Theme Persistence -Selected themes are saved in Qwen Code's [configuration](./configuration.md) so your preference is remembered across sessions. +Selected themes are saved in Qwen Code's [configuration](../configuration/settings) so your preference is remembered across sessions. --- @@ -148,7 +148,7 @@ The theme file must be a valid JSON file that follows the same structure as a cu - Select your custom theme using the `/theme` command in Qwen Code. Your custom theme will appear in the theme selection dialog. - Or, set it as the default by adding `"theme": "MyCustomTheme"` to the `ui` object in your `settings.json`. -- Custom themes can be set at the user, project, or system level, and follow the same [configuration precedence](./configuration.md) as other settings. +- Custom themes can be set at the user, project, or system level, and follow the same [configuration precedence](../configuration/settings) as other settings. diff --git a/docs/users/configuration/trusted-folders.md b/docs/users/configuration/trusted-folders.md index ad3f8f55..7aa16d84 100644 --- a/docs/users/configuration/trusted-folders.md +++ b/docs/users/configuration/trusted-folders.md @@ -56,6 +56,6 @@ If you need to change a decision or see all your settings, you have a couple of For advanced users, it's helpful to know the exact order of operations for how trust is determined: -1. **IDE Trust Signal**: If you are using the [IDE Integration](../users/ide-integration/ide-integration), the CLI first asks the IDE if the workspace is trusted. The IDE's response takes highest priority. +1. **IDE Trust Signal**: If you are using the [IDE Integration](../ide-integration/ide-integration), the CLI first asks the IDE if the workspace is trusted. The IDE's response takes highest priority. 2. **Local Trust File**: If the IDE is not connected, the CLI checks the central `~/.qwen/trustedFolders.json` file. diff --git a/docs/users/features/approval-mode.md b/docs/users/features/approval-mode.md index 0749140e..e072f237 100644 --- a/docs/users/features/approval-mode.md +++ b/docs/users/features/approval-mode.md @@ -1,3 +1,5 @@ +# Approval Mode + Qwen Code offers three distinct permission modes that allow you to flexibly control how AI interacts with your code and system based on task complexity and risk level. ## Permission Modes Comparison diff --git a/docs/users/features/headless.md b/docs/users/features/headless.md index d42b370d..203e08a2 100644 --- a/docs/users/features/headless.md +++ b/docs/users/features/headless.md @@ -203,7 +203,7 @@ Key command-line options for headless usage: | `--continue` | Resume the most recent session for this project | `qwen --continue -p "Pick up where we left off"` | | `--resume [sessionId]` | Resume a specific session (or choose interactively) | `qwen --resume 123e... -p "Finish the refactor"` | -For complete details on all available configuration options, settings files, and environment variables, see the [Configuration Guide](../users/configuration/settings). +For complete details on all available configuration options, settings files, and environment variables, see the [Configuration Guide](../configuration/settings). ## Examples @@ -276,7 +276,7 @@ tail -5 usage.log ## Resources -- [CLI Configuration](../users/configuration/settings#command-line-arguments) - Complete configuration guide -- [Authentication](../users/configuration/settings#environment-variables-for-api-access) - Setup authentication -- [Commands](../users/reference/cli-reference) - Interactive commands reference -- [Tutorials](../users/quickstart) - Step-by-step automation guides +- [CLI Configuration](../configuration/settings#command-line-arguments) - Complete configuration guide +- [Authentication](../configuration/settings#environment-variables-for-api-access) - Setup authentication +- [Commands](../features/commands) - Interactive commands reference +- [Tutorials](../quickstart) - Step-by-step automation guides diff --git a/docs/users/features/mcp.md b/docs/users/features/mcp.md index 77fcea45..2b123c12 100644 --- a/docs/users/features/mcp.md +++ b/docs/users/features/mcp.md @@ -12,6 +12,7 @@ With MCP servers connected, you can ask Qwen Code to: - Automate workflows (repeatable tasks exposed as tools/prompts) > [!tip] +> > If you’re looking for the “one command to get started”, jump to [Quick start](#quick-start). ## Quick start @@ -51,7 +52,8 @@ qwen mcp add --scope user --transport http my-server http://localhost:3000/mcp ``` > [!tip] -> For advanced configuration layers (system defaults/system settings and precedence rules), see [Settings](/users/configuration/settings). +> +> For advanced configuration layers (system defaults/system settings and precedence rules), see [Settings](../configuration/settings). ## Configure servers @@ -64,6 +66,7 @@ qwen mcp add --scope user --transport http my-server http://localhost:3000/mcp | `stdio` | Local process (scripts, CLIs, Docker) on your machine | `command`, `args` (+ optional `cwd`, `env`) | > [!note] +> > If a server supports both, prefer **HTTP** over **SSE**. ### Configure via `settings.json` vs `qwen mcp add` diff --git a/docs/users/features/sandbox.md b/docs/users/features/sandbox.md index ee0b0e9a..dbe598bc 100644 --- a/docs/users/features/sandbox.md +++ b/docs/users/features/sandbox.md @@ -220,6 +220,6 @@ qwen -s -p "run shell command: mount | grep workspace" ## Related documentation -- [Configuration](../users/configuration/settings): Full configuration options. -- [Commands](../users/reference/cli-reference): Available commands. -- [Troubleshooting](../users/support/troubleshooting): General troubleshooting. +- [Configuration](../configuration/settings): Full configuration options. +- [Commands](../features/commands): Available commands. +- [Troubleshooting](../support/troubleshooting): General troubleshooting. diff --git a/docs/users/ide-integration/ide-integration.md b/docs/users/ide-integration/ide-integration.md index 5b282fc1..b0cdf922 100644 --- a/docs/users/ide-integration/ide-integration.md +++ b/docs/users/ide-integration/ide-integration.md @@ -2,7 +2,7 @@ Qwen Code can integrate with your IDE to provide a more seamless and context-aware experience. This integration allows the CLI to understand your workspace better and enables powerful features like native in-editor diffing. -Currently, the only supported IDE is [Visual Studio Code](https://code.visualstudio.com/) and other editors that support VS Code extensions. To build support for other editors, see the [IDE Companion Extension Spec](../users/ide-integration/ide-companion-spec). +Currently, the only supported IDE is [Visual Studio Code](https://code.visualstudio.com/) and other editors that support VS Code extensions. To build support for other editors, see the [IDE Companion Extension Spec](../ide-integration/ide-companion-spec). ## Features diff --git a/docs/users/integration-github-action.md b/docs/users/integration-github-action.md index b9b348a1..281967dd 100644 --- a/docs/users/integration-github-action.md +++ b/docs/users/integration-github-action.md @@ -6,41 +6,14 @@ Use it to perform GitHub pull request reviews, triage issues, perform code analysis and modification, and more using [Qwen Code] conversationally (e.g., `@qwencoder fix this issue`) directly inside your GitHub repositories. -- [qwen-code-action](#qwen-code-action) - - [Overview](#overview) - - [Features](#features) - - [Quick Start](#quick-start) - - [1. Get a Qwen API Key](#1-get-a-qwen-api-key) - - [2. Add it as a GitHub Secret](#2-add-it-as-a-github-secret) - - [3. Update your .gitignore](#3-update-your-gitignore) - - [4. Choose a Workflow](#4-choose-a-workflow) - - [5. Try it out](#5-try-it-out) - - [Workflows](#workflows) - - [Qwen Code Dispatch](#qwen-code-dispatch) - - [Issue Triage](#issue-triage) - - [Pull Request Review](#pull-request-review) - - [Qwen Code CLI Assistant](#qwen-code-cli-assistant) - - [Configuration](#configuration) - - [Inputs](#inputs) - - [Outputs](#outputs) - - [Repository Variables](#repository-variables) - - [Secrets](#secrets) - - [Authentication](#authentication) - - [GitHub Authentication](#github-authentication) - - [Extensions](#extensions) - - [Best Practices](#best-practices) - - [Customization](#customization) - - [Contributing](#contributing) - ## Features - **Automation**: Trigger workflows based on events (e.g. issue opening) or schedules (e.g. nightly). - **On-demand Collaboration**: Trigger workflows in issue and pull request - comments by mentioning the [Qwen Code CLI] (e.g., `@qwencoder /review`). -- **Extensible with Tools**: Leverage [Qwen Code] models' tool-calling capabilities to - interact with other CLIs like the [GitHub CLI] (`gh`). + comments by mentioning the [Qwen Code CLI](./features/commands) (e.g., `@qwencoder /review`). +- **Extensible with Tools**: Leverage [Qwen Code](../developers/tools/introduction.md) models' tool-calling capabilities to interact with other CLIs like the [GitHub CLI] (`gh`). - **Customizable**: Use a `QWEN.md` file in your repository to provide - project-specific instructions and context to [Qwen Code CLI]. + project-specific instructions and context to [Qwen Code CLI](./features/commands). ## Quick Start @@ -48,7 +21,7 @@ Get started with Qwen Code CLI in your repository in just a few minutes: ### 1. Get a Qwen API Key -Obtain your API key from [DashScope] (Alibaba Cloud's AI platform) +Obtain your API key from [DashScope](https://help.aliyun.com/zh/model-studio/qwen-code) (Alibaba Cloud's AI platform) ### 2. Add it as a GitHub Secret @@ -90,7 +63,7 @@ You have two options to set up a workflow: **Option B: Manually copy workflows** -1. Copy the pre-built workflows from the [`examples/workflows`](./examples/workflows) directory to your repository's `.github/workflows` directory. Note: the `qwen-dispatch.yml` workflow must also be copied, which triggers the workflows to run. +1. Copy the pre-built workflows from the [`examples/workflows`](./common-workflow) directory to your repository's `.github/workflows` directory. Note: the `qwen-dispatch.yml` workflow must also be copied, which triggers the workflows to run. ### 5. Try it out @@ -119,30 +92,19 @@ This action provides several pre-built workflows for different use cases. Each w ### Qwen Code Dispatch -This workflow acts as a central dispatcher for Qwen Code CLI, routing requests to -the appropriate workflow based on the triggering event and the command provided -in the comment. For a detailed guide on how to set up the dispatch workflow, go -to the -[Qwen Code Dispatch workflow documentation](./examples/workflows/qwen-dispatch). +This workflow acts as a central dispatcher for Qwen Code CLI, routing requests to the appropriate workflow based on the triggering event and the command provided in the comment. For a detailed guide on how to set up the dispatch workflow, go to the [Qwen Code Dispatch workflow documentation](./common-workflow). ### Issue Triage -This action can be used to triage GitHub Issues automatically or on a schedule. -For a detailed guide on how to set up the issue triage system, go to the -[GitHub Issue Triage workflow documentation](./examples/workflows/issue-triage). +This action can be used to triage GitHub Issues automatically or on a schedule. For a detailed guide on how to set up the issue triage system, go to the [GitHub Issue Triage workflow documentation](./examples/workflows/issue-triage). ### Pull Request Review -This action can be used to automatically review pull requests when they are -opened. For a detailed guide on how to set up the pull request review system, -go to the [GitHub PR Review workflow documentation](./examples/workflows/pr-review). +This action can be used to automatically review pull requests when they are opened. For a detailed guide on how to set up the pull request review system, go to the [GitHub PR Review workflow documentation](./common-workflow). ### Qwen Code CLI Assistant -This type of action can be used to invoke a general-purpose, conversational Qwen Code -AI assistant within the pull requests and issues to perform a wide range of -tasks. For a detailed guide on how to set up the general-purpose Qwen Code CLI workflow, -go to the [Qwen Code Assistant workflow documentation](./examples/workflows/qwen-assistant). +This type of action can be used to invoke a general-purpose, conversational Qwen Code AI assistant within the pull requests and issues to perform a wide range of tasks. For a detailed guide on how to set up the general-purpose Qwen Code CLI workflow, go to the [Qwen Code Assistant workflow documentation](./common-workflow). ## Configuration @@ -222,8 +184,7 @@ To add a secret: 2. Enter the secret name and value. 3. Save. -For more information, refer to the -[official GitHub documentation on creating and using encrypted secrets][secrets]. +For more information, refer to the [official GitHub documentation on creating and using encrypted secrets][secrets]. ## Authentication @@ -239,7 +200,7 @@ You can authenticate with GitHub in two ways: authentication, we recommend creating a custom GitHub App. For detailed setup instructions for both Qwen and GitHub authentication, go to the -[**Authentication documentation**](./docs/authentication.md). +[**Authentication documentation**](./configuration/auth). ## Extensions @@ -247,7 +208,7 @@ The Qwen Code CLI can be extended with additional functionality through extensio These extensions are installed from source from their GitHub repositories. For detailed instructions on how to set up and configure extensions, go to the -[Extensions documentation](./docs/extensions.md). +[Extensions documentation](../developers/extensions/extension). ## Best Practices @@ -258,20 +219,18 @@ Key recommendations include: - **Securing Your Repository:** Implementing branch and tag protection, and restricting pull request approvers. - **Monitoring and Auditing:** Regularly reviewing action logs and enabling OpenTelemetry for deeper insights into performance and behavior. -For a comprehensive guide on securing your repository and workflows, please refer to our [**Best Practices documentation**](./docs/best-practices.md). +For a comprehensive guide on securing your repository and workflows, please refer to our [**Best Practices documentation**](./common-workflow). ## Customization -Create a [QWEN.md] file in the root of your repository to provide -project-specific context and instructions to [Qwen Code CLI]. This is useful for defining +Create a QWEN.md file in the root of your repository to provide +project-specific context and instructions to [Qwen Code CLI](./common-workflow). This is useful for defining coding conventions, architectural patterns, or other guidelines the model should follow for a given repository. ## Contributing -Contributions are welcome! Check out the Qwen Code CLI -[**Contributing Guide**](./CONTRIBUTING.md) for more details on how to get -started. +Contributions are welcome! Check out the Qwen Code CLI **Contributing Guide** for more details on how to get started. [secrets]: https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions [Qwen Code]: https://github.com/QwenLM/qwen-code diff --git a/docs/users/overview.md b/docs/users/overview.md index 50062864..31a16040 100644 --- a/docs/users/overview.md +++ b/docs/users/overview.md @@ -36,13 +36,13 @@ Select **Qwen OAuth (Free)** authentication and follow the prompts to log in. Th what does this project do? ``` -![](https://gw.alicdn.com/imgextra/i2/O1CN01XoPbZm1CrsZzvMQ6m_!!6000000000135-1-tps-772-646.gif) +![](https://cloud.video.taobao.com/vod/j7-QtQScn8UEAaEdiv619fSkk5p-t17orpDbSqKVL5A.mp4) -You'll be prompted to log in on first use. That's it! [Continue with Quickstart (5 mins) →](../users/quickstart) +You'll be prompted to log in on first use. That's it! [Continue with Quickstart (5 mins) →](./quickstart) > [!tip] > -> See [troubleshooting](../users/support/troubleshooting) if you hit issues. +> See [troubleshooting](./support/troubleshooting) if you hit issues. > [!note] > @@ -52,11 +52,11 @@ You'll be prompted to log in on first use. That's it! [Continue with Quickstart - **Build features from descriptions**: Tell Qwen Code what you want to build in plain language. It will make a plan, write the code, and ensure it works. - **Debug and fix issues**: Describe a bug or paste an error message. Qwen Code will analyze your codebase, identify the problem, and implement a fix. -- **Navigate any codebase**: Ask anything about your team's codebase, and get a thoughtful answer back. Qwen Code maintains awareness of your entire project structure, can find up-to-date information from the web, and with [MCP](../users/features/mcp) can pull from external datasources like Google Drive, Figma, and Slack. +- **Navigate any codebase**: Ask anything about your team's codebase, and get a thoughtful answer back. Qwen Code maintains awareness of your entire project structure, can find up-to-date information from the web, and with [MCP](./features/mcp) can pull from external datasources like Google Drive, Figma, and Slack. - **Automate tedious tasks**: Fix fiddly lint issues, resolve merge conflicts, and write release notes. Do all this in a single command from your developer machines, or automatically in CI. ## Why developers love Qwen Code - **Works in your terminal**: Not another chat window. Not another IDE. Qwen Code meets you where you already work, with the tools you already love. -- **Takes action**: Qwen Code can directly edit files, run commands, and create commits. Need more? [MCP](../users/features/mcp) lets Qwen Code read your design docs in Google Drive, update your tickets in Jira, or use _your_ custom developer tooling. +- **Takes action**: Qwen Code can directly edit files, run commands, and create commits. Need more? [MCP](./features/mcp) lets Qwen Code read your design docs in Google Drive, update your tickets in Jira, or use _your_ custom developer tooling. - **Unix philosophy**: Qwen Code is composable and scriptable. `tail -f app.log | qwen -p "Slack me if you see any anomalies appear in this log stream"` _works_. Your CI can run `qwen -p "If there are new text strings, translate them into French and raise a PR for @lang-fr-team to review"`. diff --git a/docs/users/quickstart.md b/docs/users/quickstart.md index 8fc6a2c8..1fa249ab 100644 --- a/docs/users/quickstart.md +++ b/docs/users/quickstart.md @@ -206,7 +206,7 @@ Here are the most important commands for daily use: | → `output [language]` | Set LLM output language | `/language output Chinese` | | `/quit` | Exit Qwen Code immediately | `/quit` or `/exit` | -See the [CLI reference](../users/reference/cli-reference) for a complete list of commands. +See the [CLI reference](./features/commands) for a complete list of commands. ## Pro tips for beginners diff --git a/docs/users/support/tos-privacy.md b/docs/users/support/tos-privacy.md index d5b9a83b..aa0d5c47 100644 --- a/docs/users/support/tos-privacy.md +++ b/docs/users/support/tos-privacy.md @@ -23,7 +23,7 @@ When you authenticate using your qwen.ai account, these Terms of Service and Pri - **Terms of Service:** Your use is governed by the [Qwen Terms of Service](https://qwen.ai/termsservice). - **Privacy Notice:** The collection and use of your data is described in the [Qwen Privacy Policy](https://qwen.ai/privacypolicy). -For details about authentication setup, quotas, and supported features, see [Authentication Setup](../users/configuration/settings). +For details about authentication setup, quotas, and supported features, see [Authentication Setup](../configuration/settings). ## 2. If you are using OpenAI-Compatible API Authentication @@ -37,7 +37,7 @@ Qwen Code supports various OpenAI-compatible providers. Please refer to your spe ## Usage Statistics and Telemetry -Qwen Code may collect anonymous usage statistics and [telemetry](/developers/development/telemetry) data to improve the user experience and product quality. This data collection is optional and can be controlled through configuration settings. +Qwen Code may collect anonymous usage statistics and [telemetry](../../developers/development/telemetry) data to improve the user experience and product quality. This data collection is optional and can be controlled through configuration settings. ### What Data is Collected @@ -91,4 +91,4 @@ You can switch between Qwen OAuth and OpenAI-compatible API authentication at an 2. **Within the CLI**: Use the `/auth` command to reconfigure your authentication method 3. **Environment variables**: Set up `.env` files for automatic OpenAI-compatible API authentication -For detailed instructions, see the [Authentication Setup](../users/configuration/settings#environment-variables-for-api-access) documentation. +For detailed instructions, see the [Authentication Setup](../configuration/settings#environment-variables-for-api-access) documentation. diff --git a/docs/users/support/troubleshooting.md b/docs/users/support/troubleshooting.md index 633d6394..84976d6f 100644 --- a/docs/users/support/troubleshooting.md +++ b/docs/users/support/troubleshooting.md @@ -31,7 +31,7 @@ This guide provides solutions to common issues and debugging tips, including top 1. In your home directory: `~/.qwen/settings.json`. 2. In your project's root directory: `./.qwen/settings.json`. - Refer to [Qwen Code Configuration](../users/configuration/settings) for more details. + Refer to [Qwen Code Configuration](../configuration/settings) for more details. - **Q: Why don't I see cached token counts in my stats output?** - A: Cached token information is only displayed when cached tokens are being used. This feature is available for API key users (Qwen API key or Google Cloud Vertex AI) but not for OAuth users (such as Google Personal/Enterprise accounts like Google Gmail or Google Workspace, respectively). This is because the Qwen Code Assist API does not support cached content creation. You can still view your total token usage using the `/stats` command. @@ -59,7 +59,7 @@ This guide provides solutions to common issues and debugging tips, including top - **Error: "Operation not permitted", "Permission denied", or similar.** - **Cause:** When sandboxing is enabled, Qwen Code may attempt operations that are restricted by your sandbox configuration, such as writing outside the project directory or system temp directory. - - **Solution:** Refer to the [Configuration: Sandboxing](../users/features/sandbox) documentation for more information, including how to customize your sandbox configuration. + - **Solution:** Refer to the [Configuration: Sandboxing](../features/sandbox) documentation for more information, including how to customize your sandbox configuration. - **Qwen Code is not running in interactive mode in "CI" environments** - **Issue:** Qwen Code does not enter interactive mode (no prompt appears) if an environment variable starting with `CI_` (e.g. `CI_TOKEN`) is set. This is because the `is-in-ci` package, used by the underlying UI framework, detects these variables and assumes a non-interactive CI environment. From f824004f9953a822ab96adc4b48d9cdceb9060e7 Mon Sep 17 00:00:00 2001 From: joeytoday Date: Wed, 17 Dec 2025 15:03:23 +0800 Subject: [PATCH 38/40] docs: updated links in index.md --- docs/index.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/index.md b/docs/index.md index 839e5545..73a33775 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,7 +4,7 @@ Welcome to the Qwen Code documentation. Qwen Code is an agentic coding tool that ## Documentation Sections -### [User Guide](../users/overview) +### [User Guide](./users/overview) Learn how to use Qwen Code as an end user. This section covers: - Basic installation and setup @@ -13,7 +13,7 @@ Learn how to use Qwen Code as an end user. This section covers: - Configuration options - Troubleshooting -### [Developer Guide](./developers/contributing) +### [Developer Guide](./developers/architecture) Learn how to contribute to and develop Qwen Code. This section covers: From f9a1ee2442a7ac6f89c9c4e950567c6a69990195 Mon Sep 17 00:00:00 2001 From: joeytoday Date: Wed, 17 Dec 2025 16:47:37 +0800 Subject: [PATCH 39/40] docs: updated vscode showcase video --- docs/users/integration-vscode.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/users/integration-vscode.md b/docs/users/integration-vscode.md index e827df26..b12de785 100644 --- a/docs/users/integration-vscode.md +++ b/docs/users/integration-vscode.md @@ -4,7 +4,7 @@
-