Compare commits

...

49 Commits

Author SHA1 Message Date
tanzhenxin
4cbb57a793 Merge pull request #1249 from QwenLM/feat/vscode-ide-companion-package-script
fix(vscode-ide-companion): improve cross-platform compatibility in prepackage script
2025-12-14 00:02:05 +08:00
yiliang114
9280739a85 fix(vscode-ide-companion): improve cross-platform compatibility in prepackage script
- Enable shell option conditionally for Windows platform
- Use 'node' command directly and template literals for path handling
2025-12-14 00:00:06 +08:00
tanzhenxin
b6128ef2d2 Merge pull request #1248 from QwenLM/feat/vscode-ide-companion-borading
refactor(vscode-ide-companion): optimize CLI detection and version management
2025-12-13 23:37:06 +08:00
tanzhenxin
f7ef720e3b test 2025-12-13 23:18:49 +08:00
tanzhenxin
0e6ebe85e4 test 2025-12-13 23:04:00 +08:00
yiliang114
3138aa1fe3 chore(vscode-ide-companion): wip 2025-12-13 22:30:10 +08:00
yiliang114
0f2f1faee5 chore(vscode-ide-companion): wip 2025-12-13 22:19:54 +08:00
yiliang114
641dd03689 chore(vscode-ide-companion): wip 2025-12-13 22:03:16 +08:00
yiliang114
44fef93399 chore(vscode-ide-companion): wip 2025-12-13 21:39:41 +08:00
yiliang114
b073b2db79 Merge branch 'main' of https://github.com/QwenLM/qwen-code into feat/vscode-ide-companion-borading 2025-12-13 21:37:18 +08:00
yiliang114
ccc6192164 chore(vscode-ide-companion): wip 2025-12-13 21:37:10 +08:00
tanzhenxin
a5f8c66c35 Merge pull request #1246 from QwenLM/feat/bundle-cli-in-vscode
Bundle CLI into VSCode release package
2025-12-13 21:25:58 +08:00
yiliang114
c8d18591b0 Merge branch 'feat/bundle-cli-in-vscode' of https://github.com/QwenLM/qwen-code into feat/vscode-ide-companion-borading 2025-12-13 21:21:53 +08:00
tanzhenxin
e9036daa8d fix failed unit tests 2025-12-13 21:14:05 +08:00
tanzhenxin
8e29cc88f9 bundle cli in vscode 2025-12-13 20:56:18 +08:00
yiliang114
61ce586117 refactor(vscode-ide-companion/cli): consolidate CLI detection and version management
- Replace separate CliDetector, CliVersionChecker, and CliVersionManager classes with unified CliManager
- Remove redundant code and simplify CLI detection logic
- Maintain all existing functionality while improving code organization
- Update imports in dependent files to use CliManager

This change reduces complexity by consolidating CLI-related functionality into a single manager class.
2025-12-13 20:42:59 +08:00
yiliang114
90fc4c33f0 fix(vscode-ide-companion/session): improve timeout configuration for different methods
Extend timeout duration to 2 minutes for both session_prompt and initialize methods
to prevent timeouts during longer operations. Default timeout remains at 60 seconds
for other methods.

This change improves reliability of session management by providing adequate
time for initialization and prompt operations to complete.
2025-12-13 20:06:02 +08:00
yiliang114
389d8dd9c4 Remove CLI version checker and status bar display, revert to original notification approach
This change removes the CliVersionChecker class and all related status bar functionality, reverting to the original approach that uses vscode.window.showInformationMessage for version-related notifications, as was implemented in the main branch.

The changes include:
1. Removing CliVersionChecker import
2. Removing status bar item creation and update logic
3. Removing CLI version check on activation
4. Removing showCliVersionInfo command

This addresses the issue where version detection notifications should use vscode.window.showInformationMessage instead of status bar display.
2025-12-13 19:49:04 +08:00
yiliang114
4590138a1e fix(vscode-ide-companion/cli): improve Windows compatibility for shell commands
fix(vscode-ide-companion/session): improve timeout configuration for different methods
2025-12-13 18:30:14 +08:00
yiliang114
0ac191e2db chore(vscode-ide-companion): wip 2025-12-13 17:50:15 +08:00
yiliang114
9e392b3035 Merge branch 'main' of https://github.com/QwenLM/qwen-code into feat/vscode-ide-companion-borading 2025-12-13 17:40:16 +08:00
易良
65796e2799 Fix/vscode ide companion completion menu content (#1243)
* refactor(vscode-ide-companion/types): move ApprovalModeValue type to dedicated file

feat(vscode-ide-companion/file-context): improve file context handling and search

Enhance file context hook to better handle search queries and reduce redundant requests.
Track last query to optimize when to refetch full file list.
Improve logging for debugging purposes.

* feat(vscode-ide-companion/completion): enhance completion menu performance and refresh logic

Implement item comparison to prevent unnecessary re-renders when completion items
haven't actually changed. Optimize refresh logic to only trigger when workspace
files content changes. Improve completion menu stability and responsiveness.

refactor(vscode-ide-companion/handlers): remove SettingsMessageHandler and consolidate functionality

Move setApprovalMode functionality from SettingsMessageHandler to SessionMessageHandler
to reduce code duplication and simplify message handling architecture. Remove unused
settings-related imports and clean up message router configuration.

chore(vscode-ide-companion/ui): minor UI improvements and code cleanup

Consolidate imports in SessionSelector component.
Remove debug console log statement from FileMessageHandler.
Move getTimeAgo utility function to sessionGrouping file and remove obsolete timeUtils file.
Clean up completion menu CSS classes.

* fix(vscode-ide-companion): resolve all ESLint errors

Fixed unused variable errors in SessionMessageHandler.ts:
- Commented out unused conversation and messages variables

Also includes previous commits:
1. feat(vscode-ide-companion): add upgrade button to CLI version warning
2. fix(vscode-ide-companion): resolve ESLint errors in InputForm component

When the Qwen Code CLI version is below the minimum required version,
the warning message now includes an "Upgrade Now" button that opens
a terminal and runs the npm install command to upgrade the CLI.

Added tests to verify the functionality works correctly.
2025-12-13 17:37:45 +08:00
yiliang114
8fdcd53f53 Merge branch 'main' of https://github.com/QwenLM/qwen-code into feat/vscode-ide-companion-borading 2025-12-13 17:05:40 +08:00
pomelo
693a58d517 Merge pull request #1194 from afarber/add-audio-notification-bell
feat: add terminal bell setting to enable/disable audio notifications
2025-12-13 17:03:35 +08:00
pomelo
c7f4c462f4 Merge pull request #1219 from afarber/1181-resume-session-id
feat: show session resume command on exit
2025-12-13 17:02:16 +08:00
yiliang114
c94708e448 Implement authenticate/update message handling for Qwen OAuth authentication
- Added authenticate_update method to ACP schema constants
- Added AuthenticateUpdateNotification type definitions
- Updated ACP message handler to process authenticate/update notifications
- Added VS Code notification handler with 'Open in Browser' and 'Copy Link' options
- Integrated authenticate update handling in QwenAgentManager

This implementation allows users to easily authenticate with Qwen OAuth when automatic browser launch fails by providing a notification with direct link and copy functionality.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-13 16:59:30 +08:00
tanzhenxin
0b854e494f Merge pull request #1245 from QwenLM/feat/remove-corgi-mode
Remove obsolete “corgi mode”
2025-12-13 16:57:10 +08:00
yiliang114
f6f4b24356 Optimize CLI version warning to avoid repetitive notifications
- Added tracking mechanism to prevent showing the same version warning multiple times
- This resolves the issue where users were getting frequent warnings when opening new sessions
- Kept the existing cache mechanism for version detection performance

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-13 16:49:43 +08:00
yiliang114
bc5dd87eb4 Merge branch 'main' of https://github.com/QwenLM/qwen-code into feat/vscode-ide-companion-borading 2025-12-13 16:44:29 +08:00
tanzhenxin
f8aeb06823 remove obsolete corgi mode 2025-12-13 16:41:03 +08:00
yiliang114
bca288e742 wip(vscode-ide-companion): OnboardingPage 2025-12-13 16:28:58 +08:00
yiliang114
5841370b1a wip(vscode-ide-companion): OnboardingPage 2025-12-13 15:51:34 +08:00
tanzhenxin
0d90d5c118 Merge pull request #1240 from QwenLM/feat/acp-authenticate-update
Add ACP authenticate update message
2025-12-13 14:41:59 +08:00
pomelo
cc0d688c8b Merge pull request #1235 from afarber/501-remove-duplicated-if-check
fix: remove redundant if-check and add tests for OpenAI converter
2025-12-13 12:07:24 +08:00
tanzhenxin
4eb7aa5448 add acp authenticate update message 2025-12-13 12:01:46 +08:00
pomelo
9978fe107b Merge pull request #1236 from afarber/1102-add-slash-language
test(cli): add tests for /language command and fix LLM output language parsing
2025-12-13 12:00:18 +08:00
pomelo
b95bb2cd95 Merge pull request #1238 from fazilus/feat/russian
feat(i18n): add Russian language support
2025-12-13 11:44:02 +08:00
yiliang114
e895c49f5c fix(vscode-ide-companion): resolve all ESLint errors
Fixed unused variable errors in SessionMessageHandler.ts:
- Commented out unused conversation and messages variables

Also includes previous commits:
1. feat(vscode-ide-companion): add upgrade button to CLI version warning
2. fix(vscode-ide-companion): resolve ESLint errors in InputForm component

When the Qwen Code CLI version is below the minimum required version,
the warning message now includes an "Upgrade Now" button that opens
a terminal and runs the npm install command to upgrade the CLI.

Added tests to verify the functionality works correctly.
2025-12-13 09:56:18 +08:00
yiliang114
3191cf73b3 feat(vscode-ide-companion/completion): enhance completion menu performance and refresh logic
Implement item comparison to prevent unnecessary re-renders when completion items
haven't actually changed. Optimize refresh logic to only trigger when workspace
files content changes. Improve completion menu stability and responsiveness.

refactor(vscode-ide-companion/handlers): remove SettingsMessageHandler and consolidate functionality

Move setApprovalMode functionality from SettingsMessageHandler to SessionMessageHandler
to reduce code duplication and simplify message handling architecture. Remove unused
settings-related imports and clean up message router configuration.

chore(vscode-ide-companion/ui): minor UI improvements and code cleanup

Consolidate imports in SessionSelector component.
Remove debug console log statement from FileMessageHandler.
Move getTimeAgo utility function to sessionGrouping file and remove obsolete timeUtils file.
Clean up completion menu CSS classes.
2025-12-13 09:19:18 +08:00
yiliang114
f5306339f6 refactor(vscode-ide-companion/types): move ApprovalModeValue type to dedicated file
feat(vscode-ide-companion/file-context): improve file context handling and search

Enhance file context hook to better handle search queries and reduce redundant requests.
Track last query to optimize when to refetch full file list.
Improve logging for debugging purposes.
2025-12-13 09:19:09 +08:00
Fazil
026fd468b1 feat(i18n): add Russian language support 2025-12-12 21:12:37 +03:00
Alexander Farber
d25af87eaf Fix the copyright 2025-12-12 15:46:37 +01:00
Alexander Farber
a5039d15bf Revert to SupportedLanguage 2025-12-12 15:36:50 +01:00
Alexander Farber
3ff916a5f1 Add tests for /language command and fix regex parsing 2025-12-12 15:28:43 +01:00
Alexander Farber
7bb9bc1e5e Remove redundant if-check and add tests for tool conversion in converter.ts 2025-12-12 14:29:37 +01:00
Alexander Farber
ba3b576906 Hide resume message when session has no messages 2025-12-11 13:10:01 +01:00
Alexander Farber
b67ee32481 Update snapshots 2025-12-11 12:53:30 +01:00
Alexander Farber
5b8ce440ea Show session resume command on exit 2025-12-11 12:22:48 +01:00
Alexander Farber
5f78909040 Add terminal bell setting to enable/disable audio notifications 2025-12-09 20:27:20 +01:00
85 changed files with 3803 additions and 2197 deletions

2
.vscode/launch.json vendored
View File

@@ -27,7 +27,7 @@
"outFiles": [
"${workspaceFolder}/packages/vscode-ide-companion/dist/**/*.js"
],
"preLaunchTask": "npm: build: vscode-ide-companion"
"preLaunchTask": "launch: vscode-ide-companion (copy+build)"
},
{
"name": "Attach",

16
.vscode/tasks.json vendored
View File

@@ -20,6 +20,22 @@
"problemMatcher": [],
"label": "npm: build: vscode-ide-companion",
"detail": "npm run build -w packages/vscode-ide-companion"
},
{
"label": "copy: bundled-cli (dev)",
"type": "shell",
"command": "node",
"args": ["packages/vscode-ide-companion/scripts/copy-bundled-cli.js"],
"problemMatcher": []
},
{
"label": "launch: vscode-ide-companion (copy+build)",
"dependsOrder": "sequence",
"dependsOn": [
"copy: bundled-cli (dev)",
"npm: build: vscode-ide-companion"
],
"problemMatcher": []
}
]
}

View File

@@ -10,19 +10,21 @@ The `/language` command allows you to customize the language settings for both t
To change the UI language of Qwen Code, use the `ui` subcommand:
```
/language ui [zh-CN|en-US]
/language ui [zh-CN|en-US|ru-RU]
```
### Available UI Languages
- **zh-CN**: Simplified Chinese (简体中文)
- **en-US**: English
- **ru-RU**: Russian (Русский)
### Examples
```
/language ui zh-CN # Set UI language to Simplified Chinese
/language ui en-US # Set UI language to English
/language ui ru-RU # Set UI language to Russian
```
### UI Language Subcommands
@@ -31,6 +33,7 @@ You can also use direct subcommands for convenience:
- `/language ui zh-CN` or `/language ui zh` or `/language ui 中文`
- `/language ui en-US` or `/language ui en` or `/language ui english`
- `/language ui ru-RU` or `/language ui ru` or `/language ui русский`
## LLM Output Language Settings

View File

@@ -88,6 +88,16 @@ export class AgentSideConnection implements Client {
);
}
/**
* Streams authentication updates (e.g. Qwen OAuth authUri) to the client.
*/
async authenticateUpdate(params: schema.AuthenticateUpdate): Promise<void> {
return await this.#connection.sendNotification(
schema.CLIENT_METHODS.authenticate_update,
params,
);
}
/**
* Request permission before running a tool
*
@@ -241,9 +251,11 @@ class Connection {
).toResult();
}
let errorName;
let details;
if (error instanceof Error) {
errorName = error.name;
details = error.message;
} else if (
typeof error === 'object' &&
@@ -254,6 +266,10 @@ class Connection {
details = error.message;
}
if (errorName === 'TokenManagerError') {
return RequestError.authRequired(details).toResult();
}
return RequestError.internalError(details).toResult();
}
}
@@ -357,6 +373,7 @@ export interface Client {
params: schema.RequestPermissionRequest,
): Promise<schema.RequestPermissionResponse>;
sessionUpdate(params: schema.SessionNotification): Promise<void>;
authenticateUpdate(params: schema.AuthenticateUpdate): Promise<void>;
writeTextFile(
params: schema.WriteTextFileRequest,
): Promise<schema.WriteTextFileResponse>;

View File

@@ -6,15 +6,19 @@
import type { ReadableStream, WritableStream } from 'node:stream/web';
import type { Config, ConversationRecord } from '@qwen-code/qwen-code-core';
import {
APPROVAL_MODE_INFO,
APPROVAL_MODES,
AuthType,
clearCachedCredentialFile,
QwenOAuth2Event,
qwenOAuth2Events,
MCPServerConfig,
SessionService,
buildApiHistoryFromConversation,
type Config,
type ConversationRecord,
type DeviceAuthorizationData,
} from '@qwen-code/qwen-code-core';
import type { ApprovalModeValue } from './schema.js';
import * as acp from './acp.js';
@@ -123,13 +127,33 @@ class GeminiAgent {
async authenticate({ methodId }: acp.AuthenticateRequest): Promise<void> {
const method = z.nativeEnum(AuthType).parse(methodId);
let authUri: string | undefined;
const authUriHandler = (deviceAuth: DeviceAuthorizationData) => {
authUri = deviceAuth.verification_uri_complete;
// Send the auth URL to ACP client as soon as it's available (refreshAuth is blocking).
void this.client.authenticateUpdate({ _meta: { authUri } });
};
if (method === AuthType.QWEN_OAUTH) {
qwenOAuth2Events.once(QwenOAuth2Event.AuthUri, authUriHandler);
}
await clearCachedCredentialFile();
await this.config.refreshAuth(method);
this.settings.setValue(
SettingScope.User,
'security.auth.selectedType',
method,
);
try {
await this.config.refreshAuth(method);
this.settings.setValue(
SettingScope.User,
'security.auth.selectedType',
method,
);
} finally {
// Ensure we don't leak listeners if auth fails early.
if (method === AuthType.QWEN_OAUTH) {
qwenOAuth2Events.off(QwenOAuth2Event.AuthUri, authUriHandler);
}
}
return;
}
async newSession({
@@ -268,14 +292,17 @@ class GeminiAgent {
private async ensureAuthenticated(config: Config): Promise<void> {
const selectedType = this.settings.merged.security?.auth?.selectedType;
if (!selectedType) {
throw acp.RequestError.authRequired();
throw acp.RequestError.authRequired('No Selected Type');
}
try {
await config.refreshAuth(selectedType);
// Use true for the second argument to ensure only cached credentials are used
await config.refreshAuth(selectedType, true);
} catch (e) {
console.error(`Authentication failed: ${e}`);
throw acp.RequestError.authRequired();
throw acp.RequestError.authRequired(
'Authentication failed: ' + (e as Error).message,
);
}
}

View File

@@ -20,6 +20,7 @@ export const AGENT_METHODS = {
export const CLIENT_METHODS = {
fs_read_text_file: 'fs/read_text_file',
fs_write_text_file: 'fs/write_text_file',
authenticate_update: 'authenticate/update',
session_request_permission: 'session/request_permission',
session_update: 'session/update',
};
@@ -57,8 +58,6 @@ export type CancelNotification = z.infer<typeof cancelNotificationSchema>;
export type AuthenticateRequest = z.infer<typeof authenticateRequestSchema>;
export type AuthenticateResponse = z.infer<typeof authenticateResponseSchema>;
export type NewSessionResponse = z.infer<typeof newSessionResponseSchema>;
export type LoadSessionResponse = z.infer<typeof loadSessionResponseSchema>;
@@ -247,7 +246,13 @@ export const authenticateRequestSchema = z.object({
methodId: z.string(),
});
export const authenticateResponseSchema = z.null();
export const authenticateUpdateSchema = z.object({
_meta: z.object({
authUri: z.string(),
}),
});
export type AuthenticateUpdate = z.infer<typeof authenticateUpdateSchema>;
export const newSessionResponseSchema = z.object({
sessionId: z.string(),
@@ -555,7 +560,6 @@ export const sessionUpdateSchema = z.union([
export const agentResponseSchema = z.union([
initializeResponseSchema,
authenticateResponseSchema,
newSessionResponseSchema,
loadSessionResponseSchema,
promptResponseSchema,

View File

@@ -191,8 +191,19 @@ const SETTINGS_SCHEMA = {
{ value: 'auto', label: 'Auto (detect from system)' },
{ value: 'en', label: 'English' },
{ value: 'zh', label: '中文 (Chinese)' },
{ value: 'ru', label: 'Русский (Russian)' },
],
},
terminalBell: {
type: 'boolean',
label: 'Terminal Bell',
category: 'General',
requiresRestart: false,
default: true,
description:
'Play terminal bell sound when response completes or needs approval.',
showInDialog: true,
},
},
},
output: {

View File

@@ -9,7 +9,7 @@ import * as path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { homedir } from 'node:os';
export type SupportedLanguage = 'en' | 'zh' | string; // Allow custom language codes
export type SupportedLanguage = 'en' | 'zh' | 'ru' | string; // Allow custom language codes
// State
let currentLanguage: SupportedLanguage = 'en';
@@ -51,10 +51,12 @@ export function detectSystemLanguage(): SupportedLanguage {
const envLang = process.env['QWEN_CODE_LANG'] || process.env['LANG'];
if (envLang?.startsWith('zh')) return 'zh';
if (envLang?.startsWith('en')) return 'en';
if (envLang?.startsWith('ru')) return 'ru';
try {
const locale = Intl.DateTimeFormat().resolvedOptions().locale;
if (locale.startsWith('zh')) return 'zh';
if (locale.startsWith('ru')) return 'ru';
} catch {
// Fallback to default
}

View File

@@ -867,6 +867,7 @@ export default {
// Exit Screen / Stats
// ============================================================================
'Agent powering down. Goodbye!': 'Agent powering down. Goodbye!',
'To continue this session, run': 'To continue this session, run',
'Interaction Summary': 'Interaction Summary',
'Session ID:': 'Session ID:',
'Tool Calls:': 'Tool Calls:',

File diff suppressed because it is too large Load Diff

View File

@@ -820,6 +820,7 @@ export default {
// Exit Screen / Stats
// ============================================================================
'Agent powering down. Goodbye!': 'Qwen Code 正在关闭,再见!',
'To continue this session, run': '要继续此会话,请运行',
'Interaction Summary': '交互摘要',
'Session ID:': '会话 ID',
'Tool Calls:': '工具调用:',

View File

@@ -58,7 +58,6 @@ vi.mock('../ui/commands/authCommand.js', () => ({ authCommand: {} }));
vi.mock('../ui/commands/bugCommand.js', () => ({ bugCommand: {} }));
vi.mock('../ui/commands/clearCommand.js', () => ({ clearCommand: {} }));
vi.mock('../ui/commands/compressCommand.js', () => ({ compressCommand: {} }));
vi.mock('../ui/commands/corgiCommand.js', () => ({ corgiCommand: {} }));
vi.mock('../ui/commands/docsCommand.js', () => ({ docsCommand: {} }));
vi.mock('../ui/commands/editorCommand.js', () => ({ editorCommand: {} }));
vi.mock('../ui/commands/extensionsCommand.js', () => ({

View File

@@ -15,7 +15,6 @@ import { bugCommand } from '../ui/commands/bugCommand.js';
import { clearCommand } from '../ui/commands/clearCommand.js';
import { compressCommand } from '../ui/commands/compressCommand.js';
import { copyCommand } from '../ui/commands/copyCommand.js';
import { corgiCommand } from '../ui/commands/corgiCommand.js';
import { docsCommand } from '../ui/commands/docsCommand.js';
import { directoryCommand } from '../ui/commands/directoryCommand.js';
import { editorCommand } from '../ui/commands/editorCommand.js';
@@ -63,7 +62,6 @@ export class BuiltinCommandLoader implements ICommandLoader {
clearCommand,
compressCommand,
copyCommand,
corgiCommand,
docsCommand,
directoryCommand,
editorCommand,

View File

@@ -56,10 +56,10 @@ export const createMockCommandContext = (
pendingItem: null,
setPendingItem: vi.fn(),
loadHistory: vi.fn(),
toggleCorgiMode: vi.fn(),
toggleVimEnabled: vi.fn(),
extensionsUpdateState: new Map(),
setExtensionsUpdateState: vi.fn(),
reloadCommands: vi.fn(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
session: {

View File

@@ -136,7 +136,6 @@ export const AppContainer = (props: AppContainerProps) => {
const { settings, config, initializationResult } = props;
const historyManager = useHistory();
useMemoryMonitor(historyManager);
const [corgiMode, setCorgiMode] = useState(false);
const [debugMessage, setDebugMessage] = useState<string>('');
const [quittingMessages, setQuittingMessages] = useState<
HistoryItem[] | null
@@ -485,7 +484,6 @@ export const AppContainer = (props: AppContainerProps) => {
}, 100);
},
setDebugMessage,
toggleCorgiMode: () => setCorgiMode((prev) => !prev),
dispatchExtensionStateUpdate,
addConfirmUpdateExtensionRequest,
openSubagentCreateDialog,
@@ -498,7 +496,6 @@ export const AppContainer = (props: AppContainerProps) => {
openSettingsDialog,
openModelDialog,
setDebugMessage,
setCorgiMode,
dispatchExtensionStateUpdate,
openPermissionsDialog,
openApprovalModeDialog,
@@ -945,6 +942,7 @@ export const AppContainer = (props: AppContainerProps) => {
isFocused,
streamingState,
elapsedTime,
settings,
});
// Dialog close functionality
@@ -1218,7 +1216,6 @@ export const AppContainer = (props: AppContainerProps) => {
qwenAuthState,
editorError,
isEditorDialogOpen,
corgiMode,
debugMessage,
quittingMessages,
isSettingsDialogOpen,
@@ -1309,7 +1306,6 @@ export const AppContainer = (props: AppContainerProps) => {
qwenAuthState,
editorError,
isEditorDialogOpen,
corgiMode,
debugMessage,
quittingMessages,
isSettingsDialogOpen,

View File

@@ -1,34 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { corgiCommand } from './corgiCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
describe('corgiCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
mockContext = createMockCommandContext();
vi.spyOn(mockContext.ui, 'toggleCorgiMode');
});
it('should call the toggleCorgiMode function on the UI context', async () => {
if (!corgiCommand.action) {
throw new Error('The corgi command must have an action.');
}
await corgiCommand.action(mockContext, '');
expect(mockContext.ui.toggleCorgiMode).toHaveBeenCalledTimes(1);
});
it('should have the correct name and description', () => {
expect(corgiCommand.name).toBe('corgi');
expect(corgiCommand.description).toBe('Toggles corgi mode.');
});
});

View File

@@ -1,17 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandKind, type SlashCommand } from './types.js';
export const corgiCommand: SlashCommand = {
name: 'corgi',
description: 'Toggles corgi mode.',
hidden: true,
kind: CommandKind.BUILT_IN,
action: (context, _args) => {
context.ui.toggleCorgiMode();
},
};

View File

@@ -0,0 +1,587 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs';
import { type CommandContext, CommandKind } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
// Mock i18n module
vi.mock('../../i18n/index.js', () => ({
setLanguageAsync: vi.fn().mockResolvedValue(undefined),
getCurrentLanguage: vi.fn().mockReturnValue('en'),
t: vi.fn((key: string) => key),
}));
// Mock settings module to avoid Storage side effect
vi.mock('../../config/settings.js', () => ({
SettingScope: {
User: 'user',
Workspace: 'workspace',
Default: 'default',
},
}));
// Mock fs module
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return {
...actual,
existsSync: vi.fn(),
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
default: {
...actual,
existsSync: vi.fn(),
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
},
};
});
// Mock Storage from core
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual = await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
Storage: {
getGlobalQwenDir: vi.fn().mockReturnValue('/mock/.qwen'),
getGlobalSettingsPath: vi.fn().mockReturnValue('/mock/.qwen/settings.json'),
},
};
});
// Import modules after mocking
import * as i18n from '../../i18n/index.js';
import { languageCommand } from './languageCommand.js';
describe('languageCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
vi.clearAllMocks();
mockContext = createMockCommandContext({
services: {
config: {
getModel: vi.fn().mockReturnValue('test-model'),
},
settings: {
merged: {},
setValue: vi.fn(),
},
},
});
// Reset i18n mocks
vi.mocked(i18n.getCurrentLanguage).mockReturnValue('en');
vi.mocked(i18n.t).mockImplementation((key: string) => key);
// Reset fs mocks
vi.mocked(fs.existsSync).mockReturnValue(false);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('command metadata', () => {
it('should have the correct name', () => {
expect(languageCommand.name).toBe('language');
});
it('should have a description', () => {
expect(languageCommand.description).toBeDefined();
expect(typeof languageCommand.description).toBe('string');
});
it('should be a built-in command', () => {
expect(languageCommand.kind).toBe(CommandKind.BUILT_IN);
});
it('should have subcommands', () => {
expect(languageCommand.subCommands).toBeDefined();
expect(languageCommand.subCommands?.length).toBe(2);
});
it('should have ui and output subcommands', () => {
const subCommandNames = languageCommand.subCommands?.map((c) => c.name);
expect(subCommandNames).toContain('ui');
expect(subCommandNames).toContain('output');
});
});
describe('main command action - no arguments', () => {
it('should show current language settings when no arguments provided', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Current UI language:'),
});
});
it('should show available subcommands in help', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('/language ui'),
});
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('/language output'),
});
});
it('should show LLM output language when set', async () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
'# CRITICAL: Chinese Output Language Rule - HIGHEST PRIORITY',
);
// Make t() function handle interpolation for this test
vi.mocked(i18n.t).mockImplementation(
(key: string, params?: Record<string, string>) => {
if (params && key.includes('{{lang}}')) {
return key.replace('{{lang}}', params['lang'] || '');
}
return key;
},
);
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Current UI language:'),
});
// Verify it correctly parses "Chinese" from the template format
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Chinese'),
});
});
});
describe('main command action - config not available', () => {
it('should return error when config is null', async () => {
mockContext.services.config = null;
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: expect.stringContaining('Configuration not available'),
});
});
});
describe('/language ui subcommand', () => {
it('should show help when no language argument provided', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Usage: /language ui'),
});
});
it('should set English with "en"', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui en');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
expect(mockContext.services.settings.setValue).toHaveBeenCalled();
expect(mockContext.ui.reloadCommands).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should set English with "en-US"', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui en-US');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should set English with "english"', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui english');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should set Chinese with "zh"', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui zh');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should set Chinese with "zh-CN"', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui zh-CN');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should set Chinese with "chinese"', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui chinese');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should return error for invalid language', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui invalid');
expect(i18n.setLanguageAsync).not.toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: expect.stringContaining('Invalid language'),
});
});
it('should persist setting to user scope', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
await languageCommand.action(mockContext, 'ui en');
expect(mockContext.services.settings.setValue).toHaveBeenCalledWith(
expect.anything(), // SettingScope.User
'general.language',
'en',
);
});
});
describe('/language output subcommand', () => {
it('should show help when no language argument provided', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'output');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Usage: /language output'),
});
});
it('should create LLM output language rule file', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'output Chinese');
expect(fs.mkdirSync).toHaveBeenCalled();
expect(fs.writeFileSync).toHaveBeenCalledWith(
expect.stringContaining('output-language.md'),
expect.stringContaining('Chinese'),
'utf-8',
);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('LLM output language rule file generated'),
});
});
it('should include restart notice in success message', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'output Japanese');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('restart'),
});
});
it('should handle file write errors gracefully', async () => {
vi.mocked(fs.writeFileSync).mockImplementation(() => {
throw new Error('Permission denied');
});
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'output German');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: expect.stringContaining('Failed to generate'),
});
});
});
describe('backward compatibility - direct language arguments', () => {
it('should set Chinese with direct "zh" argument', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'zh');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should set English with direct "en" argument', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'en');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should return error for unknown direct argument', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'unknown');
expect(i18n.setLanguageAsync).not.toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: expect.stringContaining('Invalid command'),
});
});
});
describe('ui subcommand object', () => {
const uiSubcommand = languageCommand.subCommands?.find(
(c) => c.name === 'ui',
);
it('should have correct metadata', () => {
expect(uiSubcommand).toBeDefined();
expect(uiSubcommand?.name).toBe('ui');
expect(uiSubcommand?.kind).toBe(CommandKind.BUILT_IN);
});
it('should have nested language subcommands', () => {
const nestedNames = uiSubcommand?.subCommands?.map((c) => c.name);
expect(nestedNames).toContain('zh-CN');
expect(nestedNames).toContain('en-US');
});
it('should have action that sets language', async () => {
if (!uiSubcommand?.action) {
throw new Error('UI subcommand must have an action.');
}
const result = await uiSubcommand.action(mockContext, 'en');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
});
describe('output subcommand object', () => {
const outputSubcommand = languageCommand.subCommands?.find(
(c) => c.name === 'output',
);
it('should have correct metadata', () => {
expect(outputSubcommand).toBeDefined();
expect(outputSubcommand?.name).toBe('output');
expect(outputSubcommand?.kind).toBe(CommandKind.BUILT_IN);
});
it('should have action that generates rule file', async () => {
if (!outputSubcommand?.action) {
throw new Error('Output subcommand must have an action.');
}
// Ensure mocks are properly set for this test
vi.mocked(fs.mkdirSync).mockImplementation(() => undefined);
vi.mocked(fs.writeFileSync).mockImplementation(() => undefined);
const result = await outputSubcommand.action(mockContext, 'French');
expect(fs.writeFileSync).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('LLM output language rule file generated'),
});
});
});
describe('nested ui language subcommands', () => {
const uiSubcommand = languageCommand.subCommands?.find(
(c) => c.name === 'ui',
);
const zhCNSubcommand = uiSubcommand?.subCommands?.find(
(c) => c.name === 'zh-CN',
);
const enUSSubcommand = uiSubcommand?.subCommands?.find(
(c) => c.name === 'en-US',
);
it('zh-CN should have aliases', () => {
expect(zhCNSubcommand?.altNames).toContain('zh');
expect(zhCNSubcommand?.altNames).toContain('chinese');
});
it('en-US should have aliases', () => {
expect(enUSSubcommand?.altNames).toContain('en');
expect(enUSSubcommand?.altNames).toContain('english');
});
it('zh-CN action should set Chinese', async () => {
if (!zhCNSubcommand?.action) {
throw new Error('zh-CN subcommand must have an action.');
}
const result = await zhCNSubcommand.action(mockContext, '');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('en-US action should set English', async () => {
if (!enUSSubcommand?.action) {
throw new Error('en-US subcommand must have an action.');
}
const result = await enUSSubcommand.action(mockContext, '');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should reject extra arguments', async () => {
if (!zhCNSubcommand?.action) {
throw new Error('zh-CN subcommand must have an action.');
}
const result = await zhCNSubcommand.action(mockContext, 'extra args');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: expect.stringContaining('do not accept additional arguments'),
});
});
});
});

View File

@@ -81,8 +81,9 @@ function getCurrentLlmOutputLanguage(): string | null {
if (fs.existsSync(filePath)) {
try {
const content = fs.readFileSync(filePath, 'utf-8');
// Extract language name from the first line (e.g., "# Chinese Response Rules" -> "Chinese")
const match = content.match(/^#\s+(.+?)\s+Response Rules/i);
// Extract language name from the first line
// Template format: "# CRITICAL: Chinese Output Language Rule - HIGHEST PRIORITY"
const match = content.match(/^#.*?(\w+)\s+Output Language Rule/i);
if (match) {
return match[1];
}
@@ -127,16 +128,17 @@ async function setUiLanguage(
context.ui.reloadCommands();
// Map language codes to friendly display names
const langDisplayNames: Record<SupportedLanguage, string> = {
const langDisplayNames: Partial<Record<SupportedLanguage, string>> = {
zh: '中文zh-CN',
en: 'Englishen-US',
ru: 'Русский (ru-RU)',
};
return {
type: 'message',
messageType: 'info',
content: t('UI language changed to {{lang}}', {
lang: langDisplayNames[lang],
lang: langDisplayNames[lang] || lang,
}),
};
}
@@ -216,7 +218,7 @@ export const languageCommand: SlashCommand = {
: t('LLM output language not set'),
'',
t('Available subcommands:'),
` /language ui [zh-CN|en-US] - ${t('Set UI language')}`,
` /language ui [zh-CN|en-US|ru-RU] - ${t('Set UI language')}`,
` /language output <language> - ${t('Set LLM output language')}`,
].join('\n');
@@ -232,7 +234,7 @@ export const languageCommand: SlashCommand = {
const subcommand = parts[0].toLowerCase();
if (subcommand === 'ui') {
// Handle /language ui [zh-CN|en-US]
// Handle /language ui [zh-CN|en-US|ru-RU]
if (parts.length === 1) {
// Show UI language subcommand help
return {
@@ -241,11 +243,12 @@ export const languageCommand: SlashCommand = {
content: [
t('Set UI language'),
'',
t('Usage: /language ui [zh-CN|en-US]'),
t('Usage: /language ui [zh-CN|en-US|ru-RU]'),
'',
t('Available options:'),
t(' - zh-CN: Simplified Chinese'),
t(' - en-US: English'),
t(' - ru-RU: Russian'),
'',
t(
'To request additional UI language packs, please open an issue on GitHub.',
@@ -266,11 +269,18 @@ export const languageCommand: SlashCommand = {
langArg === 'zh-cn'
) {
targetLang = 'zh';
} else if (
langArg === 'ru' ||
langArg === 'ru-RU' ||
langArg === 'russian' ||
langArg === 'русский'
) {
targetLang = 'ru';
} else {
return {
type: 'message',
messageType: 'error',
content: t('Invalid language. Available: en-US, zh-CN'),
content: t('Invalid language. Available: en-US, zh-CN, ru-RU'),
};
}
@@ -307,13 +317,20 @@ export const languageCommand: SlashCommand = {
langArg === 'zh-cn'
) {
targetLang = 'zh';
} else if (
langArg === 'ru' ||
langArg === 'ru-RU' ||
langArg === 'russian' ||
langArg === 'русский'
) {
targetLang = 'ru';
} else {
return {
type: 'message',
messageType: 'error',
content: [
t('Invalid command. Available subcommands:'),
' - /language ui [zh-CN|en-US] - ' + t('Set UI language'),
' - /language ui [zh-CN|en-US|ru-RU] - ' + t('Set UI language'),
' - /language output <language> - ' + t('Set LLM output language'),
].join('\n'),
};
@@ -423,6 +440,29 @@ export const languageCommand: SlashCommand = {
return setUiLanguage(context, 'en');
},
},
{
name: 'ru-RU',
altNames: ['ru', 'russian', 'русский'],
get description() {
return t('Set UI language to Russian (ru-RU)');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
if (args.trim().length > 0) {
return {
type: 'message',
messageType: 'error',
content: t(
'Language subcommands do not accept additional arguments.',
),
};
}
return setUiLanguage(context, 'ru');
},
},
],
},
{

View File

@@ -64,8 +64,6 @@ export interface CommandContext {
* @param history The array of history items to load.
*/
loadHistory: UseHistoryManagerReturn['loadHistory'];
/** Toggles a special display mode. */
toggleCorgiMode: () => void;
toggleVimEnabled: () => Promise<boolean>;
setGeminiMdFileCount: (count: number) => void;
reloadCommands: () => void;

View File

@@ -120,7 +120,6 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
},
branchName: 'main',
debugMessage: '',
corgiMode: false,
errorCount: 0,
nightly: false,
isTrustedFolder: true,
@@ -183,6 +182,7 @@ describe('Composer', () => {
const { lastFrame } = renderComposer(uiState, settings);
// Smoke check that the Footer renders when enabled.
expect(lastFrame()).toContain('Footer');
});
@@ -200,7 +200,6 @@ describe('Composer', () => {
it('passes correct props to Footer including vim mode when enabled', async () => {
const uiState = createMockUIState({
branchName: 'feature-branch',
corgiMode: true,
errorCount: 2,
sessionStats: {
sessionId: 'test-session',

View File

@@ -33,7 +33,6 @@ export const Footer: React.FC = () => {
debugMode,
branchName,
debugMessage,
corgiMode,
errorCount,
showErrorDetails,
promptTokenCount,
@@ -45,7 +44,6 @@ export const Footer: React.FC = () => {
debugMode: config.getDebugMode(),
branchName: uiState.branchName,
debugMessage: uiState.debugMessage,
corgiMode: uiState.corgiMode,
errorCount: uiState.errorCount,
showErrorDetails: uiState.showErrorDetails,
promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
@@ -153,16 +151,6 @@ export const Footer: React.FC = () => {
{showMemoryUsage && <MemoryUsageDisplay />}
</Box>
<Box alignItems="center" paddingLeft={2}>
{corgiMode && (
<Text>
<Text color={theme.ui.symbol}>| </Text>
<Text color={theme.status.error}></Text>
<Text color={theme.text.primary}>(´</Text>
<Text color={theme.status.error}></Text>
<Text color={theme.text.primary}>`)</Text>
<Text color={theme.status.error}>▼ </Text>
</Text>
)}
{!showErrorDetails && errorCount > 0 && (
<Box>
<Text color={theme.ui.symbol}>| </Text>

View File

@@ -20,16 +20,21 @@ vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
const renderWithMockedStats = (metrics: SessionMetrics) => {
const renderWithMockedStats = (
metrics: SessionMetrics,
sessionId: string = 'test-session-id-12345',
promptCount: number = 5,
) => {
useSessionStatsMock.mockReturnValue({
stats: {
sessionId,
sessionStartTime: new Date(),
metrics,
lastPromptTokenCount: 0,
promptCount: 5,
promptCount,
},
getPromptCount: () => 5,
getPromptCount: () => promptCount,
startNewPrompt: vi.fn(),
});
@@ -70,6 +75,38 @@ describe('<SessionSummaryDisplay />', () => {
const output = lastFrame();
expect(output).toContain('Agent powering down. Goodbye!');
expect(output).toContain('To continue this session, run');
expect(output).toContain('qwen --resume test-session-id-12345');
expect(output).toMatchSnapshot();
});
it('does not show resume message when there are no messages', () => {
const metrics: SessionMetrics = {
models: {},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
};
// Pass promptCount = 0 to simulate no messages
const { lastFrame } = renderWithMockedStats(
metrics,
'test-session-id-12345',
0,
);
const output = lastFrame();
expect(output).toContain('Agent powering down. Goodbye!');
expect(output).not.toContain('To continue this session, run');
expect(output).not.toContain('qwen --resume');
});
});

View File

@@ -5,7 +5,10 @@
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { StatsDisplay } from './StatsDisplay.js';
import { useSessionStats } from '../contexts/SessionContext.js';
import { theme } from '../semantic-colors.js';
import { t } from '../../i18n/index.js';
interface SessionSummaryDisplayProps {
@@ -14,9 +17,28 @@ interface SessionSummaryDisplayProps {
export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
duration,
}) => (
<StatsDisplay
title={t('Agent powering down. Goodbye!')}
duration={duration}
/>
);
}) => {
const { stats } = useSessionStats();
// Only show the resume message if there were messages in the session
const hasMessages = stats.promptCount > 0;
return (
<>
<StatsDisplay
title={t('Agent powering down. Goodbye!')}
duration={duration}
/>
{hasMessages && (
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('To continue this session, run')}{' '}
<Text color={theme.text.accent}>
qwen --resume {stats.sessionId}
</Text>
</Text>
</Box>
)}
</>
);
};

View File

@@ -1461,7 +1461,7 @@ describe('SettingsDialog', () => {
context: {
fileFiltering: {
respectGitIgnore: false,
respectQwemIgnore: true,
respectQwenIgnore: true,
enableRecursiveFileSearch: false,
disableFuzzySearch: true,
},
@@ -1535,7 +1535,7 @@ describe('SettingsDialog', () => {
loadMemoryFromIncludeDirectories: false,
fileFiltering: {
respectGitIgnore: false,
respectQwemIgnore: false,
respectQwenIgnore: false,
enableRecursiveFileSearch: false,
disableFuzzySearch: false,
},

View File

@@ -6,7 +6,7 @@ exports[`<SessionSummaryDisplay /> > renders the summary display with a title 1`
│ Agent powering down. Goodbye! │
│ │
│ Interaction Summary │
│ Session ID:
│ Session ID: test-session-id-12345
│ Tool Calls: 0 ( ✓ 0 x 0 ) │
│ Success Rate: 0.0% │
│ Code Changes: +42 -15 │
@@ -26,5 +26,7 @@ exports[`<SessionSummaryDisplay /> > renders the summary display with a title 1`
│ │
│ » Tip: For a full token breakdown, run \`/stats model\`. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
To continue this session, run qwen --resume test-session-id-12345"
`;

View File

@@ -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* │
│ │
│ ▼ │
│ │
│ │

View File

@@ -54,7 +54,6 @@ export interface UIState {
qwenAuthState: QwenAuthState;
editorError: string | null;
isEditorDialogOpen: boolean;
corgiMode: boolean;
debugMessage: string;
quittingMessages: HistoryItem[] | null;
isSettingsDialogOpen: boolean;

View File

@@ -153,7 +153,6 @@ describe('useSlashCommandProcessor', () => {
openModelDialog: mockOpenModelDialog,
quit: mockSetQuittingMessages,
setDebugMessage: vi.fn(),
toggleCorgiMode: vi.fn(),
},
),
);
@@ -909,7 +908,6 @@ describe('useSlashCommandProcessor', () => {
vi.fn(), // openThemeDialog
mockOpenAuthDialog,
vi.fn(), // openEditorDialog
vi.fn(), // toggleCorgiMode
mockSetQuittingMessages,
vi.fn(), // openSettingsDialog
vi.fn(), // openModelSelectionDialog

View File

@@ -68,7 +68,6 @@ interface SlashCommandProcessorActions {
openApprovalModeDialog: () => void;
quit: (messages: HistoryItem[]) => void;
setDebugMessage: (message: string) => void;
toggleCorgiMode: () => void;
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
openSubagentCreateDialog: () => void;
@@ -206,7 +205,6 @@ export const useSlashCommandProcessor = (
setDebugMessage: actions.setDebugMessage,
pendingItem,
setPendingItem,
toggleCorgiMode: actions.toggleCorgiMode,
toggleVimEnabled,
setGeminiMdFileCount,
reloadCommands,

View File

@@ -15,6 +15,23 @@ import {
LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS,
useAttentionNotifications,
} from './useAttentionNotifications.js';
import type { LoadedSettings } from '../../config/settings.js';
const mockSettings: LoadedSettings = {
merged: {
general: {
terminalBell: true,
},
},
} as LoadedSettings;
const mockSettingsDisabled: LoadedSettings = {
merged: {
general: {
terminalBell: false,
},
},
} as LoadedSettings;
vi.mock('../../utils/attentionNotification.js', () => ({
notifyTerminalAttention: vi.fn(),
@@ -40,6 +57,7 @@ describe('useAttentionNotifications', () => {
isFocused: true,
streamingState: StreamingState.Idle,
elapsedTime: 0,
settings: mockSettings,
...props,
},
},
@@ -53,11 +71,13 @@ describe('useAttentionNotifications', () => {
isFocused: false,
streamingState: StreamingState.WaitingForConfirmation,
elapsedTime: 0,
settings: mockSettings,
},
});
expect(mockedNotify).toHaveBeenCalledWith(
AttentionNotificationReason.ToolApproval,
{ enabled: true },
);
});
@@ -72,6 +92,7 @@ describe('useAttentionNotifications', () => {
isFocused: false,
streamingState: StreamingState.WaitingForConfirmation,
elapsedTime: 0,
settings: mockSettings,
},
});
@@ -86,6 +107,7 @@ describe('useAttentionNotifications', () => {
isFocused: false,
streamingState: StreamingState.Responding,
elapsedTime: LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS + 5,
settings: mockSettings,
},
});
@@ -94,11 +116,13 @@ describe('useAttentionNotifications', () => {
isFocused: false,
streamingState: StreamingState.Idle,
elapsedTime: 0,
settings: mockSettings,
},
});
expect(mockedNotify).toHaveBeenCalledWith(
AttentionNotificationReason.LongTaskComplete,
{ enabled: true },
);
});
@@ -110,6 +134,7 @@ describe('useAttentionNotifications', () => {
isFocused: true,
streamingState: StreamingState.Responding,
elapsedTime: LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS + 2,
settings: mockSettings,
},
});
@@ -118,6 +143,7 @@ describe('useAttentionNotifications', () => {
isFocused: true,
streamingState: StreamingState.Idle,
elapsedTime: 0,
settings: mockSettings,
},
});
@@ -135,6 +161,7 @@ describe('useAttentionNotifications', () => {
isFocused: false,
streamingState: StreamingState.Responding,
elapsedTime: 5,
settings: mockSettings,
},
});
@@ -143,9 +170,30 @@ describe('useAttentionNotifications', () => {
isFocused: false,
streamingState: StreamingState.Idle,
elapsedTime: 0,
settings: mockSettings,
},
});
expect(mockedNotify).not.toHaveBeenCalled();
});
it('does not notify when terminalBell setting is disabled', () => {
const { rerender } = render({
settings: mockSettingsDisabled,
});
rerender({
hookProps: {
isFocused: false,
streamingState: StreamingState.WaitingForConfirmation,
elapsedTime: 0,
settings: mockSettingsDisabled,
},
});
expect(mockedNotify).toHaveBeenCalledWith(
AttentionNotificationReason.ToolApproval,
{ enabled: false },
);
});
});

View File

@@ -10,6 +10,7 @@ import {
notifyTerminalAttention,
AttentionNotificationReason,
} from '../../utils/attentionNotification.js';
import type { LoadedSettings } from '../../config/settings.js';
export const LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS = 20;
@@ -17,13 +18,16 @@ interface UseAttentionNotificationsOptions {
isFocused: boolean;
streamingState: StreamingState;
elapsedTime: number;
settings: LoadedSettings;
}
export const useAttentionNotifications = ({
isFocused,
streamingState,
elapsedTime,
settings,
}: UseAttentionNotificationsOptions) => {
const terminalBellEnabled = settings?.merged?.general?.terminalBell ?? true;
const awaitingNotificationSentRef = useRef(false);
const respondingElapsedRef = useRef(0);
@@ -33,14 +37,16 @@ export const useAttentionNotifications = ({
!isFocused &&
!awaitingNotificationSentRef.current
) {
notifyTerminalAttention(AttentionNotificationReason.ToolApproval);
notifyTerminalAttention(AttentionNotificationReason.ToolApproval, {
enabled: terminalBellEnabled,
});
awaitingNotificationSentRef.current = true;
}
if (streamingState !== StreamingState.WaitingForConfirmation || isFocused) {
awaitingNotificationSentRef.current = false;
}
}, [isFocused, streamingState]);
}, [isFocused, streamingState, terminalBellEnabled]);
useEffect(() => {
if (streamingState === StreamingState.Responding) {
@@ -53,11 +59,13 @@ export const useAttentionNotifications = ({
respondingElapsedRef.current >=
LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS;
if (wasLongTask && !isFocused) {
notifyTerminalAttention(AttentionNotificationReason.LongTaskComplete);
notifyTerminalAttention(AttentionNotificationReason.LongTaskComplete, {
enabled: terminalBellEnabled,
});
}
// Reset tracking for next task
respondingElapsedRef.current = 0;
return;
}
}, [streamingState, elapsedTime, isFocused]);
}, [streamingState, elapsedTime, isFocused, terminalBellEnabled]);
};

View File

@@ -20,7 +20,6 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
loadHistory: (_newHistory) => {},
pendingItem: null,
setPendingItem: (_item) => {},
toggleCorgiMode: () => {},
toggleVimEnabled: async () => false,
setGeminiMdFileCount: (_count) => {},
reloadCommands: () => {},

View File

@@ -13,6 +13,7 @@ export enum AttentionNotificationReason {
export interface TerminalNotificationOptions {
stream?: Pick<NodeJS.WriteStream, 'write' | 'isTTY'>;
enabled?: boolean;
}
const TERMINAL_BELL = '\u0007';
@@ -28,6 +29,11 @@ export function notifyTerminalAttention(
_reason: AttentionNotificationReason,
options: TerminalNotificationOptions = {},
): boolean {
// Check if terminal bell is enabled (default true for backwards compatibility)
if (options.enabled === false) {
return false;
}
const stream = options.stream ?? process.stdout;
if (!stream?.write || stream.isTTY === false) {
return false;

View File

@@ -38,7 +38,6 @@
"src/ui/commands/clearCommand.test.ts",
"src/ui/commands/compressCommand.test.ts",
"src/ui/commands/copyCommand.test.ts",
"src/ui/commands/corgiCommand.test.ts",
"src/ui/commands/docsCommand.test.ts",
"src/ui/commands/editorCommand.test.ts",
"src/ui/commands/extensionsCommand.test.ts",

View File

@@ -7,7 +7,13 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { OpenAIContentConverter } from './converter.js';
import type { StreamingToolCallParser } from './streamingToolCallParser.js';
import type { GenerateContentParameters, Content } from '@google/genai';
import {
Type,
type GenerateContentParameters,
type Content,
type Tool,
type CallableTool,
} from '@google/genai';
import type OpenAI from 'openai';
describe('OpenAIContentConverter', () => {
@@ -202,4 +208,338 @@ describe('OpenAIContentConverter', () => {
);
});
});
describe('convertGeminiToolsToOpenAI', () => {
it('should convert Gemini tools with parameters field', async () => {
const geminiTools = [
{
functionDeclarations: [
{
name: 'get_weather',
description: 'Get weather for a location',
parameters: {
type: Type.OBJECT,
properties: {
location: { type: Type.STRING },
},
required: ['location'],
},
},
],
},
] as Tool[];
const result = await converter.convertGeminiToolsToOpenAI(geminiTools);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: 'function',
function: {
name: 'get_weather',
description: 'Get weather for a location',
parameters: {
type: 'object',
properties: {
location: { type: 'string' },
},
required: ['location'],
},
},
});
});
it('should convert MCP tools with parametersJsonSchema field', async () => {
// MCP tools use parametersJsonSchema which contains plain JSON schema (not Gemini types)
const mcpTools = [
{
functionDeclarations: [
{
name: 'read_file',
description: 'Read a file from disk',
parametersJsonSchema: {
type: 'object',
properties: {
path: { type: 'string' },
},
required: ['path'],
},
},
],
},
] as Tool[];
const result = await converter.convertGeminiToolsToOpenAI(mcpTools);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: 'function',
function: {
name: 'read_file',
description: 'Read a file from disk',
parameters: {
type: 'object',
properties: {
path: { type: 'string' },
},
required: ['path'],
},
},
});
});
it('should handle CallableTool by resolving tool function', async () => {
const callableTools = [
{
tool: async () => ({
functionDeclarations: [
{
name: 'dynamic_tool',
description: 'A dynamically resolved tool',
parameters: {
type: Type.OBJECT,
properties: {},
},
},
],
}),
},
] as CallableTool[];
const result = await converter.convertGeminiToolsToOpenAI(callableTools);
expect(result).toHaveLength(1);
expect(result[0].function.name).toBe('dynamic_tool');
});
it('should skip functions without name or description', async () => {
const geminiTools = [
{
functionDeclarations: [
{
name: 'valid_tool',
description: 'A valid tool',
},
{
name: 'missing_description',
// no description
},
{
// no name
description: 'Missing name',
},
],
},
] as Tool[];
const result = await converter.convertGeminiToolsToOpenAI(geminiTools);
expect(result).toHaveLength(1);
expect(result[0].function.name).toBe('valid_tool');
});
it('should handle tools without functionDeclarations', async () => {
const emptyTools: Tool[] = [
{} as Tool,
{ functionDeclarations: [] },
];
const result = await converter.convertGeminiToolsToOpenAI(emptyTools);
expect(result).toHaveLength(0);
});
it('should handle functions without parameters', async () => {
const geminiTools: Tool[] = [
{
functionDeclarations: [
{
name: 'no_params_tool',
description: 'A tool without parameters',
},
],
},
];
const result = await converter.convertGeminiToolsToOpenAI(geminiTools);
expect(result).toHaveLength(1);
expect(result[0].function.parameters).toBeUndefined();
});
it('should not mutate original parametersJsonSchema', async () => {
const originalSchema = {
type: 'object',
properties: { foo: { type: 'string' } },
};
const mcpTools: Tool[] = [
{
functionDeclarations: [
{
name: 'test_tool',
description: 'Test tool',
parametersJsonSchema: originalSchema,
},
],
} as Tool,
];
const result = await converter.convertGeminiToolsToOpenAI(mcpTools);
// Verify the result is a copy, not the same reference
expect(result[0].function.parameters).not.toBe(originalSchema);
expect(result[0].function.parameters).toEqual(originalSchema);
});
});
describe('convertGeminiToolParametersToOpenAI', () => {
it('should convert type names to lowercase', () => {
const params = {
type: 'OBJECT',
properties: {
count: { type: 'INTEGER' },
amount: { type: 'NUMBER' },
name: { type: 'STRING' },
},
};
const result = converter.convertGeminiToolParametersToOpenAI(params);
expect(result).toEqual({
type: 'object',
properties: {
count: { type: 'integer' },
amount: { type: 'number' },
name: { type: 'string' },
},
});
});
it('should convert string numeric constraints to numbers', () => {
const params = {
type: 'object',
properties: {
value: {
type: 'number',
minimum: '0',
maximum: '100',
multipleOf: '0.5',
},
},
};
const result = converter.convertGeminiToolParametersToOpenAI(params);
const properties = result?.['properties'] as Record<string, unknown>;
expect(properties?.['value']).toEqual({
type: 'number',
minimum: 0,
maximum: 100,
multipleOf: 0.5,
});
});
it('should convert string length constraints to integers', () => {
const params = {
type: 'object',
properties: {
text: {
type: 'string',
minLength: '1',
maxLength: '100',
},
items: {
type: 'array',
minItems: '0',
maxItems: '10',
},
},
};
const result = converter.convertGeminiToolParametersToOpenAI(params);
const properties = result?.['properties'] as Record<string, unknown>;
expect(properties?.['text']).toEqual({
type: 'string',
minLength: 1,
maxLength: 100,
});
expect(properties?.['items']).toEqual({
type: 'array',
minItems: 0,
maxItems: 10,
});
});
it('should handle nested objects', () => {
const params = {
type: 'object',
properties: {
nested: {
type: 'object',
properties: {
deep: {
type: 'INTEGER',
minimum: '0',
},
},
},
},
};
const result = converter.convertGeminiToolParametersToOpenAI(params);
const properties = result?.['properties'] as Record<string, unknown>;
const nested = properties?.['nested'] as Record<string, unknown>;
const nestedProperties = nested?.['properties'] as Record<string, unknown>;
expect(nestedProperties?.['deep']).toEqual({
type: 'integer',
minimum: 0,
});
});
it('should handle arrays', () => {
const params = {
type: 'array',
items: {
type: 'INTEGER',
},
};
const result = converter.convertGeminiToolParametersToOpenAI(params);
expect(result).toEqual({
type: 'array',
items: {
type: 'integer',
},
});
});
it('should return undefined for null or non-object input', () => {
expect(
converter.convertGeminiToolParametersToOpenAI(
null as unknown as Record<string, unknown>,
),
).toBeNull();
expect(
converter.convertGeminiToolParametersToOpenAI(
undefined as unknown as Record<string, unknown>,
),
).toBeUndefined();
});
it('should not mutate the original parameters', () => {
const original = {
type: 'OBJECT',
properties: {
count: { type: 'INTEGER' },
},
};
const originalCopy = JSON.parse(JSON.stringify(original));
converter.convertGeminiToolParametersToOpenAI(original);
expect(original).toEqual(originalCopy);
});
});
});

View File

@@ -193,13 +193,11 @@ export class OpenAIContentConverter {
// Handle both Gemini tools (parameters) and MCP tools (parametersJsonSchema)
if (func.parametersJsonSchema) {
// MCP tool format - use parametersJsonSchema directly
if (func.parametersJsonSchema) {
// Create a shallow copy to avoid mutating the original object
const paramsCopy = {
...(func.parametersJsonSchema as Record<string, unknown>),
};
parameters = paramsCopy;
}
// Create a shallow copy to avoid mutating the original object
const paramsCopy = {
...(func.parametersJsonSchema as Record<string, unknown>),
};
parameters = paramsCopy;
} else if (func.parameters) {
// Gemini tool format - convert parameters to OpenAI format
parameters = this.convertGeminiToolParametersToOpenAI(

View File

@@ -761,7 +761,6 @@ describe('getQwenOAuthClient', () => {
});
it('should load cached credentials if available', async () => {
const fs = await import('node:fs');
const mockCredentials = {
access_token: 'cached-token',
refresh_token: 'cached-refresh',
@@ -769,10 +768,6 @@ describe('getQwenOAuthClient', () => {
expiry_date: Date.now() + 3600000,
};
vi.mocked(fs.promises.readFile).mockResolvedValue(
JSON.stringify(mockCredentials),
);
// Mock SharedTokenManager to use cached credentials
const mockTokenManager = {
getValidCredentials: vi.fn().mockResolvedValue(mockCredentials),
@@ -792,18 +787,6 @@ describe('getQwenOAuthClient', () => {
});
it('should handle cached credentials refresh failure', async () => {
const fs = await import('node:fs');
const mockCredentials = {
access_token: 'cached-token',
refresh_token: 'expired-refresh',
token_type: 'Bearer',
expiry_date: Date.now() + 3600000, // Valid expiry time so loadCachedQwenCredentials returns true
};
vi.mocked(fs.promises.readFile).mockResolvedValue(
JSON.stringify(mockCredentials),
);
// Mock SharedTokenManager to fail with a specific error
const mockTokenManager = {
getValidCredentials: vi
@@ -833,6 +816,35 @@ describe('getQwenOAuthClient', () => {
SharedTokenManager.getInstance = originalGetInstance;
});
it('should not start device flow when requireCachedCredentials is true', async () => {
// Make SharedTokenManager fail so we hit the fallback path
const mockTokenManager = {
getValidCredentials: vi
.fn()
.mockRejectedValue(new Error('No credentials')),
};
const originalGetInstance = SharedTokenManager.getInstance;
SharedTokenManager.getInstance = vi.fn().mockReturnValue(mockTokenManager);
// If requireCachedCredentials is honored, device-flow network requests should not start
vi.mocked(global.fetch).mockResolvedValue({ ok: true } as Response);
await expect(
import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig, {
requireCachedCredentials: true,
}),
),
).rejects.toThrow(
'No cached Qwen-OAuth credentials found. Please re-authenticate.',
);
expect(global.fetch).not.toHaveBeenCalled();
SharedTokenManager.getInstance = originalGetInstance;
});
});
describe('CredentialsClearRequiredError', () => {
@@ -1574,178 +1586,6 @@ describe('Credential Caching Functions', () => {
expect(updatedCredentials.access_token).toBe('new-token');
});
});
describe('loadCachedQwenCredentials', () => {
it('should load and validate cached credentials successfully', async () => {
const { promises: fs } = await import('node:fs');
const mockCredentials = {
access_token: 'cached-token',
refresh_token: 'cached-refresh',
token_type: 'Bearer',
expiry_date: Date.now() + 3600000,
};
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockCredentials));
// Test through getQwenOAuthClient which calls loadCachedQwenCredentials
const mockConfig = {
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(true),
} as unknown as Config;
// Make SharedTokenManager fail to test the fallback
const mockTokenManager = {
getValidCredentials: vi
.fn()
.mockRejectedValue(new Error('No cached creds')),
};
const originalGetInstance = SharedTokenManager.getInstance;
SharedTokenManager.getInstance = vi
.fn()
.mockReturnValue(mockTokenManager);
// Mock successful auth flow after cache load fails
const mockAuthResponse = {
ok: true,
json: async () => ({
device_code: 'test-device-code',
user_code: 'TEST123',
verification_uri: 'https://chat.qwen.ai/device',
verification_uri_complete: 'https://chat.qwen.ai/device?code=TEST123',
expires_in: 1800,
}),
};
const mockTokenResponse = {
ok: true,
json: async () => ({
access_token: 'new-access-token',
refresh_token: 'new-refresh-token',
token_type: 'Bearer',
expires_in: 3600,
scope: 'openid profile email model.completion',
}),
};
global.fetch = vi
.fn()
.mockResolvedValueOnce(mockAuthResponse as Response)
.mockResolvedValue(mockTokenResponse as Response);
try {
await import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
);
} catch {
// Expected to fail in test environment
}
expect(fs.readFile).toHaveBeenCalled();
SharedTokenManager.getInstance = originalGetInstance;
});
it('should handle invalid cached credentials gracefully', async () => {
const { promises: fs } = await import('node:fs');
// Mock file read to return invalid JSON
vi.mocked(fs.readFile).mockResolvedValue('invalid-json');
const mockConfig = {
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(true),
} as unknown as Config;
const mockTokenManager = {
getValidCredentials: vi
.fn()
.mockRejectedValue(new Error('No cached creds')),
};
const originalGetInstance = SharedTokenManager.getInstance;
SharedTokenManager.getInstance = vi
.fn()
.mockReturnValue(mockTokenManager);
// Mock auth flow
const mockAuthResponse = {
ok: true,
json: async () => ({
device_code: 'test-device-code',
user_code: 'TEST123',
verification_uri: 'https://chat.qwen.ai/device',
verification_uri_complete: 'https://chat.qwen.ai/device?code=TEST123',
expires_in: 1800,
}),
};
const mockTokenResponse = {
ok: true,
json: async () => ({
access_token: 'new-token',
refresh_token: 'new-refresh',
token_type: 'Bearer',
expires_in: 3600,
}),
};
global.fetch = vi
.fn()
.mockResolvedValueOnce(mockAuthResponse as Response)
.mockResolvedValue(mockTokenResponse as Response);
try {
await import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
);
} catch {
// Expected to fail in test environment
}
SharedTokenManager.getInstance = originalGetInstance;
});
it('should handle file access errors', async () => {
const { promises: fs } = await import('node:fs');
vi.mocked(fs.readFile).mockRejectedValue(new Error('File not found'));
const mockConfig = {
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(true),
} as unknown as Config;
const mockTokenManager = {
getValidCredentials: vi
.fn()
.mockRejectedValue(new Error('No cached creds')),
};
const originalGetInstance = SharedTokenManager.getInstance;
SharedTokenManager.getInstance = vi
.fn()
.mockReturnValue(mockTokenManager);
// Mock device flow to fail quickly
const mockAuthResponse = {
ok: true,
json: async () => ({
error: 'invalid_request',
error_description: 'Invalid request parameters',
}),
};
global.fetch = vi.fn().mockResolvedValue(mockAuthResponse as Response);
// Should proceed to device flow when cache loading fails
try {
await import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
);
} catch {
// Expected to fail in test environment
}
SharedTokenManager.getInstance = originalGetInstance;
});
});
});
describe('Enhanced Error Handling and Edge Cases', () => {

View File

@@ -514,26 +514,14 @@ export async function getQwenOAuthClient(
}
}
// If shared manager fails, check if we have cached credentials for device flow
if (await loadCachedQwenCredentials(client)) {
// We have cached credentials but they might be expired
// Try device flow instead of forcing refresh
const result = await authWithQwenDeviceFlow(client, config);
if (!result.success) {
// Use detailed error message if available, otherwise use default
const errorMessage =
result.message || 'Qwen OAuth authentication failed';
throw new Error(errorMessage);
}
return client;
}
if (options?.requireCachedCredentials) {
throw new Error(
'No cached Qwen-OAuth credentials found. Please re-authenticate.',
);
}
// If we couldn't obtain valid credentials via SharedTokenManager, fall back to
// interactive device authorization (unless explicitly forbidden above).
const result = await authWithQwenDeviceFlow(client, config);
if (!result.success) {
// Only emit timeout event if the failure reason is actually timeout
@@ -689,6 +677,19 @@ async function authWithQwenDeviceFlow(
// Cache the new tokens
await cacheQwenCredentials(credentials);
// IMPORTANT:
// SharedTokenManager maintains an in-memory cache and throttles file checks.
// If we only write the creds file here, a subsequent `getQwenOAuthClient()`
// call in the same process (within the throttle window) may not re-read the
// updated file and could incorrectly re-trigger device auth.
// Clearing the cache forces the next call to reload from disk.
try {
SharedTokenManager.getInstance().clearCache();
} catch {
// In unit tests we sometimes mock SharedTokenManager.getInstance() with a
// minimal stub; cache invalidation is best-effort and should not break auth.
}
// Emit auth progress success event
qwenOAuth2Events.emit(
QwenOAuth2Event.AuthProgress,
@@ -847,27 +848,6 @@ async function authWithQwenDeviceFlow(
}
}
async function loadCachedQwenCredentials(
client: QwenOAuth2Client,
): Promise<boolean> {
try {
const keyFile = getQwenCachedCredentialPath();
const creds = await fs.readFile(keyFile, 'utf-8');
const credentials = JSON.parse(creds) as QwenCredentials;
client.setCredentials(credentials);
// Verify that the credentials are still valid
const { token } = await client.getAccessToken();
if (!token) {
return false;
}
return true;
} catch (_) {
return false;
}
}
async function cacheQwenCredentials(credentials: QwenCredentials) {
const filePath = getQwenCachedCredentialPath();
try {
@@ -913,6 +893,14 @@ export async function clearQwenCredentials(): Promise<void> {
}
// Log other errors but don't throw - clearing credentials should be non-critical
console.warn('Warning: Failed to clear cached Qwen credentials:', error);
} finally {
// Also clear SharedTokenManager in-memory cache to prevent stale credentials
// from being reused within the same process after the file is removed.
try {
SharedTokenManager.getInstance().clearCache();
} catch {
// Best-effort; don't fail credential clearing if SharedTokenManager is mocked.
}
}
}

View File

@@ -1,5 +1,6 @@
**
!dist/
!dist/**
../
../../
!LICENSE

View File

@@ -113,7 +113,7 @@
"main": "./dist/extension.cjs",
"type": "module",
"scripts": {
"prepackage": "npm run generate:notices && npm run check-types && npm run lint && npm run build:prod",
"prepackage": "node ./scripts/prepackage.js",
"build": "npm run build:dev",
"build:dev": "npm run check-types && npm run lint && node esbuild.js",
"build:prod": "node esbuild.js --production",

View File

@@ -0,0 +1,67 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Copy the already-built root dist/ folder into the extension dist/qwen-cli/.
*
* Assumes repoRoot/dist already exists (e.g. produced by `npm run bundle` and
* optionally `npm run prepare:package`).
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import fs from 'node:fs/promises';
import { existsSync } from 'node:fs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const extensionRoot = path.resolve(__dirname, '..');
const repoRoot = path.resolve(extensionRoot, '..', '..');
const rootDistDir = path.join(repoRoot, 'dist');
const extensionDistDir = path.join(extensionRoot, 'dist');
const bundledCliDir = path.join(extensionDistDir, 'qwen-cli');
async function main() {
const cliJs = path.join(rootDistDir, 'cli.js');
const vendorDir = path.join(rootDistDir, 'vendor');
if (!existsSync(cliJs) || !existsSync(vendorDir)) {
throw new Error(
`[copy-bundled-cli] Missing root dist artifacts. Expected:\n- ${cliJs}\n- ${vendorDir}\n\nRun root "npm run bundle" first.`,
);
}
await fs.mkdir(extensionDistDir, { recursive: true });
const existingNodeModules = path.join(bundledCliDir, 'node_modules');
const tmpNodeModules = path.join(
extensionDistDir,
'qwen-cli.node_modules.tmp',
);
const keepNodeModules = existsSync(existingNodeModules);
// Preserve destination node_modules if it exists (e.g. after packaging install).
if (keepNodeModules) {
await fs.rm(tmpNodeModules, { recursive: true, force: true });
await fs.rename(existingNodeModules, tmpNodeModules);
}
await fs.rm(bundledCliDir, { recursive: true, force: true });
await fs.mkdir(bundledCliDir, { recursive: true });
await fs.cp(rootDistDir, bundledCliDir, { recursive: true });
if (keepNodeModules) {
await fs.rename(tmpNodeModules, existingNodeModules);
}
console.log(`[copy-bundled-cli] Copied ${rootDistDir} -> ${bundledCliDir}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,98 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* VS Code extension packaging orchestration.
*
* We bundle the CLI into the extension so users don't need a global install.
* To match the published CLI layout, we need to:
* - build root bundle (dist/cli.js + vendor/ + sandbox profiles)
* - run root prepare:package (dist/package.json + locales + README/LICENSE)
* - install production deps into root dist/ (dist/node_modules) so runtime deps
* like optional node-pty are present inside the VSIX payload.
*
* Then we generate notices and build the extension.
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const extensionRoot = path.resolve(__dirname, '..');
const repoRoot = path.resolve(extensionRoot, '..', '..');
const bundledCliDir = path.join(extensionRoot, 'dist', 'qwen-cli');
function npmBin() {
return process.platform === 'win32' ? 'npm.cmd' : 'npm';
}
function run(cmd, args, opts = {}) {
const res = spawnSync(cmd, args, {
stdio: 'inherit',
shell: process.platform === 'win32' ? true : false,
...opts,
});
if (res.error) {
throw res.error;
}
if (typeof res.status === 'number' && res.status !== 0) {
throw new Error(
`Command failed (${res.status}): ${cmd} ${args.map((a) => JSON.stringify(a)).join(' ')}`,
);
}
}
function main() {
const npm = npmBin();
console.log('[prepackage] Bundling root CLI...');
run(npm, ['--prefix', repoRoot, 'run', 'bundle'], { cwd: repoRoot });
console.log('[prepackage] Preparing root dist/ package metadata...');
run(npm, ['--prefix', repoRoot, 'run', 'prepare:package'], { cwd: repoRoot });
console.log('[prepackage] Generating notices...');
run(npm, ['run', 'generate:notices'], { cwd: extensionRoot });
console.log('[prepackage] Typechecking...');
run(npm, ['run', 'check-types'], { cwd: extensionRoot });
console.log('[prepackage] Linting...');
run(npm, ['run', 'lint'], { cwd: extensionRoot });
console.log('[prepackage] Building extension (production)...');
run(npm, ['run', 'build:prod'], { cwd: extensionRoot });
console.log('[prepackage] Copying bundled CLI dist/ into extension...');
run(
'node',
[`${path.join(extensionRoot, 'scripts', 'copy-bundled-cli.js')}`],
{
cwd: extensionRoot,
},
);
console.log(
'[prepackage] Installing production deps into extension dist/qwen-cli...',
);
run(
npm,
[
'--prefix',
bundledCliDir,
'install',
'--omit=dev',
'--no-audit',
'--no-fund',
],
{ cwd: bundledCliDir },
);
}
main();

View File

@@ -1,58 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { CliFeatureFlags, CliVersionInfo } from './cliVersionManager.js';
export class CliContextManager {
private static instance: CliContextManager;
private currentVersionInfo: CliVersionInfo | null = null;
private constructor() {}
/**
* Get singleton instance
*/
static getInstance(): CliContextManager {
if (!CliContextManager.instance) {
CliContextManager.instance = new CliContextManager();
}
return CliContextManager.instance;
}
/**
* Set current CLI version information
*
* @param versionInfo - CLI version information
*/
setCurrentVersionInfo(versionInfo: CliVersionInfo): void {
this.currentVersionInfo = versionInfo;
}
/**
* Get current CLI feature flags
*
* @returns Current CLI feature flags or default flags if not set
*/
getCurrentFeatures(): CliFeatureFlags {
if (this.currentVersionInfo) {
return this.currentVersionInfo.features;
}
// Return default feature flags (all disabled)
return {
supportsSessionList: false,
supportsSessionLoad: false,
};
}
supportsSessionList(): boolean {
return this.getCurrentFeatures().supportsSessionList;
}
supportsSessionLoad(): boolean {
return this.getCurrentFeatures().supportsSessionLoad;
}
}

View File

@@ -1,215 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
export interface CliDetectionResult {
isInstalled: boolean;
cliPath?: string;
version?: string;
error?: string;
}
/**
* Detects if Qwen Code CLI is installed and accessible
*/
export class CliDetector {
private static cachedResult: CliDetectionResult | null = null;
private static lastCheckTime: number = 0;
private static readonly CACHE_DURATION_MS = 30000; // 30 seconds
/**
* Checks if the Qwen Code CLI is installed
* @param forceRefresh - Force a new check, ignoring cache
* @returns Detection result with installation status and details
*/
static async detectQwenCli(
forceRefresh = false,
): Promise<CliDetectionResult> {
const now = Date.now();
// Return cached result if available and not expired
if (
!forceRefresh &&
this.cachedResult &&
now - this.lastCheckTime < this.CACHE_DURATION_MS
) {
console.log('[CliDetector] Returning cached result');
return this.cachedResult;
}
console.log(
'[CliDetector] Starting CLI detection, current PATH:',
process.env.PATH,
);
try {
const isWindows = process.platform === 'win32';
const whichCommand = isWindows ? 'where' : 'which';
// Check if qwen command exists
try {
// Use NVM environment for consistent detection
// Fallback chain: default alias -> node alias -> current version
const detectionCommand =
process.platform === 'win32'
? `${whichCommand} qwen`
: 'source ~/.nvm/nvm.sh 2>/dev/null && (nvm use default 2>/dev/null || nvm use node 2>/dev/null || nvm use 2>/dev/null); which qwen';
console.log(
'[CliDetector] Detecting CLI with command:',
detectionCommand,
);
const { stdout } = await execAsync(detectionCommand, {
timeout: 5000,
shell: '/bin/bash',
});
// The output may contain multiple lines, with NVM activation messages
// We want the last line which should be the actual path
const lines = stdout
.trim()
.split('\n')
.filter((line) => line.trim());
const cliPath = lines[lines.length - 1];
console.log('[CliDetector] Found CLI at:', cliPath);
// Try to get version
let version: string | undefined;
try {
// Use NVM environment for version check
// Fallback chain: default alias -> node alias -> current version
// Also ensure we use the correct Node.js version that matches the CLI installation
const versionCommand =
process.platform === 'win32'
? 'qwen --version'
: 'source ~/.nvm/nvm.sh 2>/dev/null && (nvm use default 2>/dev/null || nvm use node 2>/dev/null || nvm use 2>/dev/null); qwen --version';
console.log(
'[CliDetector] Getting version with command:',
versionCommand,
);
const { stdout: versionOutput } = await execAsync(versionCommand, {
timeout: 5000,
shell: '/bin/bash',
});
// The output may contain multiple lines, with NVM activation messages
// We want the last line which should be the actual version
const versionLines = versionOutput
.trim()
.split('\n')
.filter((line) => line.trim());
version = versionLines[versionLines.length - 1];
console.log('[CliDetector] CLI version:', version);
} catch (versionError) {
console.log('[CliDetector] Failed to get CLI version:', versionError);
// Version check failed, but CLI is installed
}
this.cachedResult = {
isInstalled: true,
cliPath,
version,
};
this.lastCheckTime = now;
return this.cachedResult;
} catch (detectionError) {
console.log('[CliDetector] CLI not found, error:', detectionError);
// CLI not found
let error = `Qwen Code CLI not found in PATH. Please install it using: npm install -g @qwen-code/qwen-code@latest`;
// Provide specific guidance for permission errors
if (detectionError instanceof Error) {
const errorMessage = detectionError.message;
if (
errorMessage.includes('EACCES') ||
errorMessage.includes('Permission denied')
) {
error += `\n\nThis may be due to permission issues. Possible solutions:
\n1. Reinstall the CLI without sudo: npm install -g @qwen-code/qwen-code@latest
\n2. If previously installed with sudo, fix ownership: sudo chown -R $(whoami) $(npm config get prefix)/lib/node_modules/@qwen-code/qwen-code
\n3. Use nvm for Node.js version management to avoid permission issues
\n4. Check your PATH environment variable includes npm's global bin directory`;
}
}
this.cachedResult = {
isInstalled: false,
error,
};
this.lastCheckTime = now;
return this.cachedResult;
}
} catch (error) {
console.log('[CliDetector] General detection error:', error);
const errorMessage =
error instanceof Error ? error.message : String(error);
let userFriendlyError = `Failed to detect Qwen Code CLI: ${errorMessage}`;
// Provide specific guidance for permission errors
if (
errorMessage.includes('EACCES') ||
errorMessage.includes('Permission denied')
) {
userFriendlyError += `\n\nThis may be due to permission issues. Possible solutions:
\n1. Reinstall the CLI without sudo: npm install -g @qwen-code/qwen-code@latest
\n2. If previously installed with sudo, fix ownership: sudo chown -R $(whoami) $(npm config get prefix)/lib/node_modules/@qwen-code/qwen-code
\n3. Use nvm for Node.js version management to avoid permission issues
\n4. Check your PATH environment variable includes npm's global bin directory`;
}
this.cachedResult = {
isInstalled: false,
error: userFriendlyError,
};
this.lastCheckTime = now;
return this.cachedResult;
}
}
/**
* Clears the cached detection result
*/
static clearCache(): void {
this.cachedResult = null;
this.lastCheckTime = 0;
}
/**
* Gets installation instructions based on the platform
*/
static getInstallationInstructions(): {
title: string;
steps: string[];
documentationUrl: string;
} {
return {
title: 'Qwen Code CLI is not installed',
steps: [
'Install via npm:',
' npm install -g @qwen-code/qwen-code@latest',
'',
'If you are using nvm (automatically handled by the plugin):',
' The plugin will automatically use your default nvm version',
'',
'Or install from source:',
' git clone https://github.com/QwenLM/qwen-code.git',
' cd qwen-code',
' npm install',
' npm install -g .',
'',
'After installation, reload VS Code or restart the extension.',
],
documentationUrl: 'https://github.com/QwenLM/qwen-code#installation',
};
}
}

View File

@@ -1,225 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as vscode from 'vscode';
import { CliDetector } from './cliDetector.js';
/**
* CLI Detection and Installation Handler
* Responsible for detecting, installing, and prompting for Qwen CLI
*/
export class CliInstaller {
/**
* Check CLI installation status and send results to WebView
* @param sendToWebView Callback function to send messages to WebView
*/
static async checkInstallation(
sendToWebView: (message: unknown) => void,
): Promise<void> {
try {
const result = await CliDetector.detectQwenCli();
sendToWebView({
type: 'cliDetectionResult',
data: {
isInstalled: result.isInstalled,
cliPath: result.cliPath,
version: result.version,
error: result.error,
installInstructions: result.isInstalled
? undefined
: CliDetector.getInstallationInstructions(),
},
});
if (!result.isInstalled) {
console.log('[CliInstaller] Qwen CLI not detected:', result.error);
} else {
console.log(
'[CliInstaller] Qwen CLI detected:',
result.cliPath,
result.version,
);
}
} catch (error) {
console.error('[CliInstaller] CLI detection error:', error);
}
}
/**
* Prompt user to install CLI
* Display warning message with installation options
*/
static async promptInstallation(): Promise<void> {
const selection = await vscode.window.showWarningMessage(
'Qwen Code CLI is not installed. You can browse conversation history, but cannot send new messages.',
'Install Now',
'View Documentation',
'Remind Me Later',
);
if (selection === 'Install Now') {
await this.install();
} else if (selection === 'View Documentation') {
vscode.env.openExternal(
vscode.Uri.parse('https://github.com/QwenLM/qwen-code#installation'),
);
}
}
/**
* Install Qwen CLI
* Install global CLI package via npm
*/
static async install(): Promise<void> {
try {
// Show progress notification
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: 'Installing Qwen Code CLI',
cancellable: false,
},
async (progress) => {
progress.report({
message: 'Running: npm install -g @qwen-code/qwen-code@latest',
});
const { exec } = await import('child_process');
const { promisify } = await import('util');
const execAsync = promisify(exec);
try {
// Use NVM environment to ensure we get the same Node.js version
// as when they run 'node -v' in terminal
// Fallback chain: default alias -> node alias -> current version
const installCommand =
process.platform === 'win32'
? 'npm install -g @qwen-code/qwen-code@latest'
: 'source ~/.nvm/nvm.sh 2>/dev/null && (nvm use default 2>/dev/null || nvm use node 2>/dev/null || nvm use 2>/dev/null); npm install -g @qwen-code/qwen-code@latest';
console.log(
'[CliInstaller] Installing with command:',
installCommand,
);
console.log(
'[CliInstaller] Current process PATH:',
process.env['PATH'],
);
// Also log Node.js version being used by VS Code
console.log(
'[CliInstaller] VS Code Node.js version:',
process.version,
);
console.log(
'[CliInstaller] VS Code Node.js execPath:',
process.execPath,
);
const { stdout, stderr } = await execAsync(
installCommand,
{
timeout: 120000,
shell: '/bin/bash',
}, // 2 minutes timeout
);
console.log('[CliInstaller] Installation output:', stdout);
if (stderr) {
console.warn('[CliInstaller] Installation stderr:', stderr);
}
// Clear cache and recheck
CliDetector.clearCache();
const detection = await CliDetector.detectQwenCli();
if (detection.isInstalled) {
vscode.window
.showInformationMessage(
`✅ Qwen Code CLI installed successfully! Version: ${detection.version}`,
'Reload Window',
)
.then((selection) => {
if (selection === 'Reload Window') {
vscode.commands.executeCommand(
'workbench.action.reloadWindow',
);
}
});
} else {
throw new Error(
'Installation completed but CLI still not detected',
);
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error('[CliInstaller] Installation failed:', errorMessage);
console.error('[CliInstaller] Error stack:', error);
// Provide specific guidance for permission errors
let userFriendlyMessage = `Failed to install Qwen Code CLI: ${errorMessage}`;
if (
errorMessage.includes('EACCES') ||
errorMessage.includes('Permission denied')
) {
userFriendlyMessage += `\n\nThis is likely due to permission issues. Possible solutions:
\n1. Reinstall without sudo: npm install -g @qwen-code/qwen-code@latest
\n2. Fix npm permissions: sudo chown -R $(whoami) $(npm config get prefix)/{lib/node_modules,bin,share}
\n3. Use nvm for Node.js version management to avoid permission issues
\n4. Configure npm to use a different directory: npm config set prefix ~/.npm-global`;
}
vscode.window
.showErrorMessage(
userFriendlyMessage,
'Try Manual Installation',
'View Documentation',
)
.then((selection) => {
if (selection === 'Try Manual Installation') {
const terminal = vscode.window.createTerminal(
'Qwen Code Installation',
);
terminal.show();
// Provide different installation commands based on error type
if (
errorMessage.includes('EACCES') ||
errorMessage.includes('Permission denied')
) {
terminal.sendText('# Try installing without sudo:');
terminal.sendText(
'npm install -g @qwen-code/qwen-code@latest',
);
terminal.sendText('');
terminal.sendText('# Or fix npm permissions:');
terminal.sendText(
'sudo chown -R $(whoami) $(npm config get prefix)/{lib/node_modules,bin,share}',
);
} else {
terminal.sendText(
'npm install -g @qwen-code/qwen-code@latest',
);
}
} else if (selection === 'View Documentation') {
vscode.env.openExternal(
vscode.Uri.parse(
'https://github.com/QwenLM/qwen-code#installation',
),
);
}
});
}
},
);
} catch (error) {
console.error('[CliInstaller] Install CLI error:', error);
}
}
}

View File

@@ -1,128 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { statSync } from 'fs';
export interface CliPathDetectionResult {
path: string | null;
error?: string;
}
/**
* Determine the correct Node.js executable path for a given CLI installation
* Handles various Node.js version managers (nvm, n, manual installations)
*
* @param cliPath - Path to the CLI executable
* @returns Path to the Node.js executable, or null if not found
*/
export function determineNodePathForCli(
cliPath: string,
): CliPathDetectionResult {
// Common patterns for Node.js installations
const nodePathPatterns = [
// NVM pattern: /Users/user/.nvm/versions/node/vXX.XX.X/bin/qwen -> /Users/user/.nvm/versions/node/vXX.XX.X/bin/node
cliPath.replace(/\/bin\/qwen$/, '/bin/node'),
// N pattern: /Users/user/n/bin/qwen -> /Users/user/n/bin/node
cliPath.replace(/\/bin\/qwen$/, '/bin/node'),
// Manual installation pattern: /usr/local/bin/qwen -> /usr/local/bin/node
cliPath.replace(/\/qwen$/, '/node'),
// Alternative pattern: /opt/nodejs/bin/qwen -> /opt/nodejs/bin/node
cliPath.replace(/\/bin\/qwen$/, '/bin/node'),
];
// Check each pattern
for (const nodePath of nodePathPatterns) {
try {
const stats = statSync(nodePath);
if (stats.isFile()) {
// Verify it's executable
if (stats.mode & 0o111) {
console.log(`[CLI] Found Node.js executable for CLI at: ${nodePath}`);
return { path: nodePath };
} else {
console.log(`[CLI] Node.js found at ${nodePath} but not executable`);
return {
path: null,
error: `Node.js found at ${nodePath} but not executable. You may need to fix file permissions or reinstall the CLI.`,
};
}
}
} catch (error) {
// Differentiate between error types
if (error instanceof Error) {
if ('code' in error && error.code === 'EACCES') {
console.log(`[CLI] Permission denied accessing ${nodePath}`);
return {
path: null,
error: `Permission denied accessing ${nodePath}. The CLI may have been installed with sudo privileges. Try reinstalling without sudo or adjusting file permissions.`,
};
} else if ('code' in error && error.code === 'ENOENT') {
// File not found, continue to next pattern
continue;
} else {
console.log(`[CLI] Error accessing ${nodePath}: ${error.message}`);
return {
path: null,
error: `Error accessing Node.js at ${nodePath}: ${error.message}`,
};
}
}
}
}
// Try to find node in the same directory as the CLI
const cliDir = cliPath.substring(0, cliPath.lastIndexOf('/'));
const potentialNodePaths = [`${cliDir}/node`, `${cliDir}/bin/node`];
for (const nodePath of potentialNodePaths) {
try {
const stats = statSync(nodePath);
if (stats.isFile()) {
if (stats.mode & 0o111) {
console.log(
`[CLI] Found Node.js executable in CLI directory at: ${nodePath}`,
);
return { path: nodePath };
} else {
console.log(`[CLI] Node.js found at ${nodePath} but not executable`);
return {
path: null,
error: `Node.js found at ${nodePath} but not executable. You may need to fix file permissions or reinstall the CLI.`,
};
}
}
} catch (error) {
// Differentiate between error types
if (error instanceof Error) {
if ('code' in error && error.code === 'EACCES') {
console.log(`[CLI] Permission denied accessing ${nodePath}`);
return {
path: null,
error: `Permission denied accessing ${nodePath}. The CLI may have been installed with sudo privileges. Try reinstalling without sudo or adjusting file permissions.`,
};
} else if ('code' in error && error.code === 'ENOENT') {
// File not found, continue
continue;
} else {
console.log(`[CLI] Error accessing ${nodePath}: ${error.message}`);
return {
path: null,
error: `Error accessing Node.js at ${nodePath}: ${error.message}`,
};
}
}
}
}
console.log(`[CLI] Could not determine Node.js path for CLI: ${cliPath}`);
return {
path: null,
error: `Could not find Node.js executable for CLI at ${cliPath}. Please verify the CLI installation.`,
};
}

View File

@@ -1,191 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import semver from 'semver';
import { CliDetector, type CliDetectionResult } from './cliDetector.js';
export const MIN_CLI_VERSION_FOR_SESSION_METHODS = '0.5.0';
export interface CliFeatureFlags {
supportsSessionList: boolean;
supportsSessionLoad: boolean;
}
export interface CliVersionInfo {
version: string | undefined;
isSupported: boolean;
features: CliFeatureFlags;
detectionResult: CliDetectionResult;
}
/**
* CLI Version Manager
*
* Manages CLI version detection and feature availability based on version
*/
export class CliVersionManager {
private static instance: CliVersionManager;
private cachedVersionInfo: CliVersionInfo | null = null;
private lastCheckTime: number = 0;
private static readonly CACHE_DURATION_MS = 30000; // 30 seconds
private constructor() {}
/**
* Get singleton instance
*/
static getInstance(): CliVersionManager {
if (!CliVersionManager.instance) {
CliVersionManager.instance = new CliVersionManager();
}
return CliVersionManager.instance;
}
/**
* Check if CLI version meets minimum requirements
*
* @param version - Version string to check
* @param minVersion - Minimum required version
* @returns Whether version meets requirements
*/
private isVersionSupported(
version: string | undefined,
minVersion: string,
): boolean {
if (!version) {
return false;
}
// Use semver for robust comparison (handles v-prefix, pre-release, etc.)
const v = semver.valid(version) ?? semver.coerce(version)?.version ?? null;
const min =
semver.valid(minVersion) ?? semver.coerce(minVersion)?.version ?? null;
if (!v || !min) {
console.warn(
`[CliVersionManager] Invalid semver: version=${version}, min=${minVersion}`,
);
return false;
}
console.log(`[CliVersionManager] Version ${v} meets requirements: ${min}`);
return semver.gte(v, min);
}
/**
* Get feature flags based on CLI version
*
* @param version - CLI version string
* @returns Feature flags
*/
private getFeatureFlags(version: string | undefined): CliFeatureFlags {
const isSupportedVersion = this.isVersionSupported(
version,
MIN_CLI_VERSION_FOR_SESSION_METHODS,
);
return {
supportsSessionList: isSupportedVersion,
supportsSessionLoad: isSupportedVersion,
};
}
/**
* Detect CLI version and features
*
* @param forceRefresh - Force a new check, ignoring cache
* @returns CLI version information
*/
async detectCliVersion(forceRefresh = false): Promise<CliVersionInfo> {
const now = Date.now();
// Return cached result if available and not expired
if (
!forceRefresh &&
this.cachedVersionInfo &&
now - this.lastCheckTime < CliVersionManager.CACHE_DURATION_MS
) {
console.log('[CliVersionManager] Returning cached version info');
return this.cachedVersionInfo;
}
console.log('[CliVersionManager] Detecting CLI version...');
try {
// Detect CLI installation
const detectionResult = await CliDetector.detectQwenCli(forceRefresh);
const versionInfo: CliVersionInfo = {
version: detectionResult.version,
isSupported: this.isVersionSupported(
detectionResult.version,
MIN_CLI_VERSION_FOR_SESSION_METHODS,
),
features: this.getFeatureFlags(detectionResult.version),
detectionResult,
};
// Cache the result
this.cachedVersionInfo = versionInfo;
this.lastCheckTime = now;
console.log(
'[CliVersionManager] CLI version detection result:',
versionInfo,
);
return versionInfo;
} catch (error) {
console.error('[CliVersionManager] Failed to detect CLI version:', error);
// Return fallback result
const fallbackResult: CliVersionInfo = {
version: undefined,
isSupported: false,
features: {
supportsSessionList: false,
supportsSessionLoad: false,
},
detectionResult: {
isInstalled: false,
error: error instanceof Error ? error.message : String(error),
},
};
return fallbackResult;
}
}
/**
* Clear cached version information
*/
clearCache(): void {
this.cachedVersionInfo = null;
this.lastCheckTime = 0;
CliDetector.clearCache();
}
/**
* Check if CLI supports session/list method
*
* @param forceRefresh - Force a new check, ignoring cache
* @returns Whether session/list is supported
*/
async supportsSessionList(forceRefresh = false): Promise<boolean> {
const versionInfo = await this.detectCliVersion(forceRefresh);
return versionInfo.features.supportsSessionList;
}
/**
* Check if CLI supports session/load method
*
* @param forceRefresh - Force a new check, ignoring cache
* @returns Whether session/load is supported
*/
async supportsSessionLoad(forceRefresh = false): Promise<boolean> {
const versionInfo = await this.detectCliVersion(forceRefresh);
return versionInfo.features.supportsSessionLoad;
}
}

View File

@@ -19,6 +19,7 @@ export const AGENT_METHODS = {
export const CLIENT_METHODS = {
fs_read_text_file: 'fs/read_text_file',
fs_write_text_file: 'fs/write_text_file',
authenticate_update: 'authenticate/update',
session_request_permission: 'session/request_permission',
session_update: 'session/update',
} as const;

View File

@@ -292,7 +292,14 @@ export async function activate(context: vscode.ExtensionContext) {
}
if (selectedFolder) {
const qwenCmd = 'qwen';
const cliEntry = vscode.Uri.joinPath(
context.extensionUri,
'dist',
'qwen-cli',
'cli.js',
).fsPath;
const quote = (s: string) => `"${s.replaceAll('"', '\\"')}"`;
const qwenCmd = `${quote(process.execPath)} ${quote(cliEntry)}`;
const terminal = vscode.window.createTerminal({
name: `Qwen Code (${selectedFolder.name})`,
cwd: selectedFolder.uri.fsPath,

View File

@@ -10,8 +10,9 @@ import type {
AcpPermissionRequest,
AcpResponse,
AcpSessionUpdate,
ApprovalModeValue,
AuthenticateUpdateNotification,
} from '../types/acpTypes.js';
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
import type { ChildProcess, SpawnOptions } from 'child_process';
import { spawn } from 'child_process';
import type {
@@ -20,7 +21,7 @@ import type {
} from '../types/connectionTypes.js';
import { AcpMessageHandler } from './acpMessageHandler.js';
import { AcpSessionManager } from './acpSessionManager.js';
import { determineNodePathForCli } from '../cli/cliPathDetector.js';
import * as fs from 'node:fs';
/**
* ACP Connection Handler for VSCode Extension
@@ -42,6 +43,8 @@ export class AcpConnection {
onPermissionRequest: (data: AcpPermissionRequest) => Promise<{
optionId: string;
}> = () => Promise.resolve({ optionId: 'allow' });
onAuthenticateUpdate: (data: AuthenticateUpdateNotification) => void =
() => {};
onEndTurn: () => void = () => {};
// Called after successful initialize() with the initialize result
onInitialized: (init: unknown) => void = () => {};
@@ -54,12 +57,12 @@ export class AcpConnection {
/**
* Connect to Qwen ACP
*
* @param cliPath - CLI path
* @param cliEntryPath - Path to the bundled CLI entrypoint (cli.js)
* @param workingDir - Working directory
* @param extraArgs - Extra command line arguments
*/
async connect(
cliPath: string,
cliEntryPath: string,
workingDir: string = process.cwd(),
extraArgs: string[] = [],
): Promise<void> {
@@ -69,7 +72,6 @@ export class AcpConnection {
this.workingDir = workingDir;
const isWindows = process.platform === 'win32';
const env = { ...process.env };
// If proxy is configured in extraArgs, also set it as environment variable
@@ -88,48 +90,20 @@ export class AcpConnection {
env['https_proxy'] = proxyUrl;
}
let spawnCommand: string;
let spawnArgs: string[];
// Always run the bundled CLI using the VS Code extension host's Node runtime.
// This avoids PATH/NVM/global install problems and ensures deterministic behavior.
const spawnCommand: string = process.execPath;
const spawnArgs: string[] = [
cliEntryPath,
'--experimental-acp',
'--channel=VSCode',
...extraArgs,
];
if (cliPath.startsWith('npx ')) {
const parts = cliPath.split(' ');
spawnCommand = isWindows ? 'npx.cmd' : 'npx';
spawnArgs = [
...parts.slice(1),
'--experimental-acp',
'--channel=VSCode',
...extraArgs,
];
} else {
// For qwen CLI, ensure we use the correct Node.js version
// Handle various Node.js version managers (nvm, n, manual installations)
if (cliPath.includes('/qwen') && !isWindows) {
// Try to determine the correct node executable for this qwen installation
const nodePathResult = determineNodePathForCli(cliPath);
if (nodePathResult.path) {
spawnCommand = nodePathResult.path;
spawnArgs = [
cliPath,
'--experimental-acp',
'--channel=VSCode',
...extraArgs,
];
} else {
// Fallback to direct execution
spawnCommand = cliPath;
spawnArgs = ['--experimental-acp', '--channel=VSCode', ...extraArgs];
// Log any error for debugging
if (nodePathResult.error) {
console.warn(
`[ACP] Node.js path detection warning: ${nodePathResult.error}`,
);
}
}
} else {
spawnCommand = cliPath;
spawnArgs = ['--experimental-acp', '--channel=VSCode', ...extraArgs];
}
if (!fs.existsSync(cliEntryPath)) {
throw new Error(
`Bundled Qwen CLI entry not found at ${cliEntryPath}. The extension may not have been packaged correctly.`,
);
}
console.log('[ACP] Spawning command:', spawnCommand, spawnArgs.join(' '));
@@ -138,7 +112,8 @@ export class AcpConnection {
cwd: workingDir,
stdio: ['pipe', 'pipe', 'pipe'],
env,
shell: isWindows,
// We spawn node directly; no shell needed (and shell quoting can break paths).
shell: false,
};
this.child = spawn(spawnCommand, spawnArgs, options);
@@ -235,6 +210,7 @@ export class AcpConnection {
const callbacks: AcpConnectionCallbacks = {
onSessionUpdate: this.onSessionUpdate,
onPermissionRequest: this.onPermissionRequest,
onAuthenticateUpdate: this.onAuthenticateUpdate,
onEndTurn: this.onEndTurn,
};

View File

@@ -17,6 +17,7 @@ import type {
AcpResponse,
AcpSessionUpdate,
AcpPermissionRequest,
AuthenticateUpdateNotification,
} from '../types/acpTypes.js';
import { CLIENT_METHODS } from '../constants/acpSchema.js';
import type {
@@ -110,13 +111,20 @@ export class AcpMessageHandler {
// JSON.stringify(message.result).substring(0, 200),
message.result,
);
if (
message.result &&
typeof message.result === 'object' &&
'stopReason' in message.result &&
message.result.stopReason === 'end_turn'
) {
callbacks.onEndTurn();
if (message.result && typeof message.result === 'object') {
const stopReasonValue =
(message.result as { stopReason?: unknown }).stopReason ??
(message.result as { stop_reason?: unknown }).stop_reason;
if (typeof stopReasonValue === 'string') {
callbacks.onEndTurn(stopReasonValue);
} else if (
'stopReason' in message.result ||
'stop_reason' in message.result
) {
// stop_reason present but not a string (e.g., null) -> still emit
callbacks.onEndTurn();
}
}
resolve(message.result);
} else if ('error' in message) {
@@ -161,6 +169,15 @@ export class AcpMessageHandler {
);
callbacks.onSessionUpdate(params as AcpSessionUpdate);
break;
case CLIENT_METHODS.authenticate_update:
console.log(
'[ACP] >>> Processing authenticate_update:',
JSON.stringify(params).substring(0, 300),
);
callbacks.onAuthenticateUpdate(
params as AuthenticateUpdateNotification,
);
break;
case CLIENT_METHODS.session_request_permission:
result = await this.handlePermissionRequest(
params as AcpPermissionRequest,

View File

@@ -14,8 +14,8 @@ import type {
AcpRequest,
AcpNotification,
AcpResponse,
ApprovalModeValue,
} from '../types/acpTypes.js';
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
import { AGENT_METHODS } from '../constants/acpSchema.js';
import type { PendingRequest } from '../types/connectionTypes.js';
import type { ChildProcess } from 'child_process';
@@ -54,8 +54,14 @@ export class AcpSessionManager {
};
return new Promise((resolve, reject) => {
const timeoutDuration =
method === AGENT_METHODS.session_prompt ? 120000 : 60000;
// different timeout durations based on methods
let timeoutDuration = 60000; // default 60 seconds
if (
method === AGENT_METHODS.session_prompt ||
method === AGENT_METHODS.initialize
) {
timeoutDuration = 120000; // 2min for session_prompt and initialize
}
const timeoutId = setTimeout(() => {
pendingRequests.delete(id);
@@ -163,7 +169,7 @@ export class AcpSessionManager {
pendingRequests,
nextRequestId,
);
console.log('[ACP] Authenticate successful');
console.log('[ACP] Authenticate successful', response);
return response;
}

View File

@@ -7,8 +7,9 @@ import { AcpConnection } from './acpConnection.js';
import type {
AcpSessionUpdate,
AcpPermissionRequest,
ApprovalModeValue,
AuthenticateUpdateNotification,
} from '../types/acpTypes.js';
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
import { QwenSessionReader, type QwenSession } from './qwenSessionReader.js';
import { QwenSessionManager } from './qwenSessionManager.js';
import type {
@@ -17,11 +18,14 @@ import type {
ToolCallUpdateData,
QwenAgentCallbacks,
} from '../types/chatTypes.js';
import { QwenConnectionHandler } from '../services/qwenConnectionHandler.js';
import {
QwenConnectionHandler,
type QwenConnectionResult,
} from '../services/qwenConnectionHandler.js';
import { QwenSessionUpdateHandler } from './qwenSessionUpdateHandler.js';
import { CliContextManager } from '../cli/cliContextManager.js';
import { authMethod } from '../types/acpTypes.js';
import { MIN_CLI_VERSION_FOR_SESSION_METHODS } from '../cli/cliVersionManager.js';
import { isAuthenticationRequiredError } from '../utils/authErrors.js';
import { handleAuthenticateUpdate } from '../utils/authNotificationHandler.js';
export type { ChatMessage, PlanEntry, ToolCallUpdateData };
@@ -30,6 +34,13 @@ export type { ChatMessage, PlanEntry, ToolCallUpdateData };
*
* Coordinates various modules and provides unified interface
*/
interface AgentConnectOptions {
autoAuthenticate?: boolean;
}
interface AgentSessionOptions {
autoAuthenticate?: boolean;
}
export class QwenAgentManager {
private connection: AcpConnection;
private sessionReader: QwenSessionReader;
@@ -119,10 +130,10 @@ export class QwenAgentManager {
return { optionId: 'allow_once' };
};
this.connection.onEndTurn = () => {
this.connection.onEndTurn = (reason?: string) => {
try {
if (this.callbacks.onEndTurn) {
this.callbacks.onEndTurn();
this.callbacks.onEndTurn(reason);
} else if (this.callbacks.onStreamChunk) {
// Fallback: send a zero-length chunk then rely on streamEnd elsewhere
this.callbacks.onStreamChunk('');
@@ -132,6 +143,20 @@ export class QwenAgentManager {
}
};
this.connection.onAuthenticateUpdate = (
data: AuthenticateUpdateNotification,
) => {
try {
// Handle authentication update notifications by showing VS Code notification
handleAuthenticateUpdate(data);
} catch (err) {
console.warn(
'[QwenAgentManager] onAuthenticateUpdate callback error:',
err,
);
}
};
// Initialize callback to surface available modes and current mode to UI
this.connection.onInitialized = (init: unknown) => {
try {
@@ -162,15 +187,19 @@ export class QwenAgentManager {
* Connect to Qwen service
*
* @param workingDir - Working directory
* @param cliPath - CLI path (optional, if provided will override the path in configuration)
* @param cliEntryPath - Path to bundled CLI entrypoint (cli.js)
*/
async connect(workingDir: string, _cliPath?: string): Promise<void> {
async connect(
workingDir: string,
cliEntryPath: string,
options?: AgentConnectOptions,
): Promise<QwenConnectionResult> {
this.currentWorkingDir = workingDir;
await this.connectionHandler.connect(
return this.connectionHandler.connect(
this.connection,
this.sessionReader,
workingDir,
_cliPath,
cliEntryPath,
options,
);
}
@@ -252,71 +281,59 @@ export class QwenAgentManager {
'[QwenAgentManager] Getting session list with version-aware strategy',
);
// Check if CLI supports session/list method
const cliContextManager = CliContextManager.getInstance();
const supportsSessionList = cliContextManager.supportsSessionList();
try {
console.log(
'[QwenAgentManager] Attempting to get session list via ACP method',
);
const response = await this.connection.listSessions();
console.log('[QwenAgentManager] ACP session list response:', response);
console.log(
'[QwenAgentManager] CLI supports session/list:',
supportsSessionList,
);
// sendRequest resolves with the JSON-RPC "result" directly
// Newer CLI returns an object: { items: [...], nextCursor?, hasMore }
// Older prototypes might return an array. Support both.
const res: unknown = response;
let items: Array<Record<string, unknown>> = [];
// Try ACP method first if supported
if (supportsSessionList) {
try {
console.log(
'[QwenAgentManager] Attempting to get session list via ACP method',
);
const response = await this.connection.listSessions();
console.log('[QwenAgentManager] ACP session list response:', response);
// Note: AcpSessionManager resolves `sendRequest` with the JSON-RPC
// "result" directly (not the full AcpResponse). Treat it as unknown
// and carefully narrow before accessing `items` to satisfy strict TS.
if (res && typeof res === 'object' && 'items' in res) {
const itemsValue = (res as { items?: unknown }).items;
items = Array.isArray(itemsValue)
? (itemsValue as Array<Record<string, unknown>>)
: [];
}
// sendRequest resolves with the JSON-RPC "result" directly
// Newer CLI returns an object: { items: [...], nextCursor?, hasMore }
// Older prototypes might return an array. Support both.
const res: unknown = response;
let items: Array<Record<string, unknown>> = [];
// Note: AcpSessionManager resolves `sendRequest` with the JSON-RPC
// "result" directly (not the full AcpResponse). Treat it as unknown
// and carefully narrow before accessing `items` to satisfy strict TS.
if (res && typeof res === 'object' && 'items' in res) {
const itemsValue = (res as { items?: unknown }).items;
items = Array.isArray(itemsValue)
? (itemsValue as Array<Record<string, unknown>>)
: [];
}
console.log(
'[QwenAgentManager] Sessions retrieved via ACP:',
res,
items.length,
);
if (items.length > 0) {
const sessions = items.map((item) => ({
id: item.sessionId || item.id,
sessionId: item.sessionId || item.id,
title: item.title || item.name || item.prompt || 'Untitled Session',
name: item.title || item.name || item.prompt || 'Untitled Session',
startTime: item.startTime,
lastUpdated: item.mtime || item.lastUpdated,
messageCount: item.messageCount || 0,
projectHash: item.projectHash,
filePath: item.filePath,
cwd: item.cwd,
}));
console.log(
'[QwenAgentManager] Sessions retrieved via ACP:',
res,
items.length,
);
if (items.length > 0) {
const sessions = items.map((item) => ({
id: item.sessionId || item.id,
sessionId: item.sessionId || item.id,
title: item.title || item.name || item.prompt || 'Untitled Session',
name: item.title || item.name || item.prompt || 'Untitled Session',
startTime: item.startTime,
lastUpdated: item.mtime || item.lastUpdated,
messageCount: item.messageCount || 0,
projectHash: item.projectHash,
filePath: item.filePath,
cwd: item.cwd,
}));
console.log(
'[QwenAgentManager] Sessions retrieved via ACP:',
sessions.length,
);
return sessions;
}
} catch (error) {
console.warn(
'[QwenAgentManager] ACP session list failed, falling back to file system method:',
error,
sessions.length,
);
return sessions;
}
} catch (error) {
console.warn(
'[QwenAgentManager] ACP session list failed, falling back to file system method:',
error,
);
}
// Always fall back to file system method
@@ -336,8 +353,10 @@ export class QwenAgentManager {
name: this.sessionReader.getSessionTitle(session),
startTime: session.startTime,
lastUpdated: session.lastUpdated,
messageCount: session.messages.length,
messageCount: session.messageCount ?? session.messages.length,
projectHash: session.projectHash,
filePath: session.filePath,
cwd: session.cwd,
}),
);
@@ -371,62 +390,52 @@ export class QwenAgentManager {
const size = params?.size ?? 20;
const cursor = params?.cursor;
const cliContextManager = CliContextManager.getInstance();
const supportsSessionList = cliContextManager.supportsSessionList();
try {
const response = await this.connection.listSessions({
size,
...(cursor !== undefined ? { cursor } : {}),
});
// sendRequest resolves with the JSON-RPC "result" directly
const res: unknown = response;
let items: Array<Record<string, unknown>> = [];
if (supportsSessionList) {
try {
const response = await this.connection.listSessions({
size,
...(cursor !== undefined ? { cursor } : {}),
});
// sendRequest resolves with the JSON-RPC "result" directly
const res: unknown = response;
let items: Array<Record<string, unknown>> = [];
if (Array.isArray(res)) {
items = res;
} else if (typeof res === 'object' && res !== null && 'items' in res) {
const responseObject = res as {
items?: Array<Record<string, unknown>>;
};
items = Array.isArray(responseObject.items)
? responseObject.items
: [];
}
const mapped = items.map((item) => ({
id: item.sessionId || item.id,
sessionId: item.sessionId || item.id,
title: item.title || item.name || item.prompt || 'Untitled Session',
name: item.title || item.name || item.prompt || 'Untitled Session',
startTime: item.startTime,
lastUpdated: item.mtime || item.lastUpdated,
messageCount: item.messageCount || 0,
projectHash: item.projectHash,
filePath: item.filePath,
cwd: item.cwd,
}));
const nextCursor: number | undefined =
typeof res === 'object' && res !== null && 'nextCursor' in res
? typeof res.nextCursor === 'number'
? res.nextCursor
: undefined
: undefined;
const hasMore: boolean =
typeof res === 'object' && res !== null && 'hasMore' in res
? Boolean(res.hasMore)
: false;
return { sessions: mapped, nextCursor, hasMore };
} catch (error) {
console.warn(
'[QwenAgentManager] Paged ACP session list failed:',
error,
);
// fall through to file system
if (Array.isArray(res)) {
items = res;
} else if (typeof res === 'object' && res !== null && 'items' in res) {
const responseObject = res as {
items?: Array<Record<string, unknown>>;
};
items = Array.isArray(responseObject.items) ? responseObject.items : [];
}
const mapped = items.map((item) => ({
id: item.sessionId || item.id,
sessionId: item.sessionId || item.id,
title: item.title || item.name || item.prompt || 'Untitled Session',
name: item.title || item.name || item.prompt || 'Untitled Session',
startTime: item.startTime,
lastUpdated: item.mtime || item.lastUpdated,
messageCount: item.messageCount || 0,
projectHash: item.projectHash,
filePath: item.filePath,
cwd: item.cwd,
}));
const nextCursor: number | undefined =
typeof res === 'object' && res !== null && 'nextCursor' in res
? typeof res.nextCursor === 'number'
? res.nextCursor
: undefined
: undefined;
const hasMore: boolean =
typeof res === 'object' && res !== null && 'hasMore' in res
? Boolean(res.hasMore)
: false;
return { sessions: mapped, nextCursor, hasMore };
} catch (error) {
console.warn('[QwenAgentManager] Paged ACP session list failed:', error);
// fall through to file system
}
// Fallback: file system for current project only (to match ACP semantics)
@@ -452,8 +461,10 @@ export class QwenAgentManager {
name: this.sessionReader.getSessionTitle(x.raw),
startTime: x.raw.startTime,
lastUpdated: x.raw.lastUpdated,
messageCount: x.raw.messages.length,
messageCount: x.raw.messageCount ?? x.raw.messages.length,
projectHash: x.raw.projectHash,
filePath: x.raw.filePath,
cwd: x.raw.cwd,
}));
const nextCursorVal =
page.length > 0 ? page[page.length - 1].mtime : undefined;
@@ -473,32 +484,28 @@ export class QwenAgentManager {
*/
async getSessionMessages(sessionId: string): Promise<ChatMessage[]> {
try {
// Prefer reading CLI's JSONL if we can find filePath from session/list
const cliContextManager = CliContextManager.getInstance();
if (cliContextManager.supportsSessionList()) {
try {
const list = await this.getSessionList();
const item = list.find(
(s) => s.sessionId === sessionId || s.id === sessionId,
);
console.log(
'[QwenAgentManager] Session list item for filePath lookup:',
item,
);
if (
typeof item === 'object' &&
item !== null &&
'filePath' in item &&
typeof item.filePath === 'string'
) {
const messages = await this.readJsonlMessages(item.filePath);
// Even if messages array is empty, we should return it rather than falling back
// This ensures we don't accidentally show messages from a different session format
return messages;
}
} catch (e) {
console.warn('[QwenAgentManager] JSONL read path lookup failed:', e);
try {
const list = await this.getSessionList();
const item = list.find(
(s) => s.sessionId === sessionId || s.id === sessionId,
);
console.log(
'[QwenAgentManager] Session list item for filePath lookup:',
item,
);
if (
typeof item === 'object' &&
item !== null &&
'filePath' in item &&
typeof item.filePath === 'string'
) {
const messages = await this.readJsonlMessages(item.filePath);
// Even if messages array is empty, we should return it rather than falling back
// This ensures we don't accidentally show messages from a different session format
return messages;
}
} catch (e) {
console.warn('[QwenAgentManager] JSONL read path lookup failed:', e);
}
// Fallback: legacy JSON session files
@@ -696,7 +703,9 @@ export class QwenAgentManager {
const planText = planEntries
.map(
(entry: Record<string, unknown>, index: number) =>
`${index + 1}. ${entry.description || entry.title || 'Unnamed step'}`,
`${index + 1}. ${
entry.description || entry.title || 'Unnamed step'
}`,
)
.join('\n');
msgs.push({
@@ -891,80 +900,6 @@ export class QwenAgentManager {
return this.saveSessionViaCommand(sessionId, tag);
}
/**
* Save session as checkpoint (using CLI format)
* Saves to ~/.qwen/tmp/{projectHash}/checkpoint-{tag}.json
* Saves two copies with sessionId and conversationId to ensure recovery via either ID
*
* @param messages - Current session messages
* @param conversationId - Conversation ID (from VSCode extension)
* @returns Save result
*/
async saveCheckpoint(
messages: ChatMessage[],
conversationId: string,
): Promise<{ success: boolean; tag?: string; message?: string }> {
try {
console.log('[QwenAgentManager] ===== CHECKPOINT SAVE START =====');
console.log('[QwenAgentManager] Conversation ID:', conversationId);
console.log('[QwenAgentManager] Message count:', messages.length);
console.log(
'[QwenAgentManager] Current working dir:',
this.currentWorkingDir,
);
console.log(
'[QwenAgentManager] Current session ID (from CLI):',
this.currentSessionId,
);
// In ACP mode, the CLI does not accept arbitrary slash commands like
// "/chat save". To ensure we never block on unsupported features,
// persist checkpoints directly to ~/.qwen/tmp using our SessionManager.
const qwenMessages = messages.map((m) => ({
// Generate minimal QwenMessage shape expected by the writer
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
timestamp: new Date().toISOString(),
type: m.role === 'user' ? ('user' as const) : ('qwen' as const),
content: m.content,
}));
const tag = await this.sessionManager.saveCheckpoint(
qwenMessages,
conversationId,
this.currentWorkingDir,
this.currentSessionId || undefined,
);
return { success: true, tag };
} catch (error) {
console.error('[QwenAgentManager] ===== CHECKPOINT SAVE FAILED =====');
console.error('[QwenAgentManager] Error:', error);
console.error(
'[QwenAgentManager] Error stack:',
error instanceof Error ? error.stack : 'N/A',
);
return {
success: false,
message: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Save session directly to file system (without relying on ACP)
*
* @param messages - Current session messages
* @param sessionName - Session name
* @returns Save result
*/
async saveSessionDirect(
messages: ChatMessage[],
sessionName: string,
): Promise<{ success: boolean; sessionId?: string; message?: string }> {
// Use checkpoint format instead of session format
// This matches CLI's /chat save behavior
return this.saveCheckpoint(messages, sessionName);
}
/**
* Try to load session via ACP session/load method
* This method will only be used if CLI version supports it
@@ -976,16 +911,6 @@ export class QwenAgentManager {
sessionId: string,
cwdOverride?: string,
): Promise<unknown> {
// Check if CLI supports session/load method
const cliContextManager = CliContextManager.getInstance();
const supportsSessionLoad = cliContextManager.supportsSessionLoad();
if (!supportsSessionLoad) {
throw new Error(
`CLI version does not support session/load method. Please upgrade to version ${MIN_CLI_VERSION_FOR_SESSION_METHODS} or later.`,
);
}
try {
// Route upcoming session/update messages as discrete messages for replay
this.rehydratingSessionId = sessionId;
@@ -1059,32 +984,20 @@ export class QwenAgentManager {
sessionId,
);
// Check if CLI supports session/load method
const cliContextManager = CliContextManager.getInstance();
const supportsSessionLoad = cliContextManager.supportsSessionLoad();
try {
console.log(
'[QwenAgentManager] Attempting to load session via ACP method',
);
await this.loadSessionViaAcp(sessionId);
console.log('[QwenAgentManager] Session loaded successfully via ACP');
console.log(
'[QwenAgentManager] CLI supports session/load:',
supportsSessionLoad,
);
// Try ACP method first if supported
if (supportsSessionLoad) {
try {
console.log(
'[QwenAgentManager] Attempting to load session via ACP method',
);
await this.loadSessionViaAcp(sessionId);
console.log('[QwenAgentManager] Session loaded successfully via ACP');
// After loading via ACP, we still need to get messages from file system
// In future, we might get them directly from the ACP response
} catch (error) {
console.warn(
'[QwenAgentManager] ACP session load failed, falling back to file system method:',
error,
);
}
// After loading via ACP, we still need to get messages from file system
// In future, we might get them directly from the ACP response
} catch (error) {
console.warn(
'[QwenAgentManager] ACP session load failed, falling back to file system method:',
error,
);
}
// Always fall back to file system method
@@ -1152,16 +1065,6 @@ export class QwenAgentManager {
}
}
/**
* Load session, preferring ACP method if CLI version supports it
*
* @param sessionId - Session ID
* @returns Loaded session messages or null
*/
async loadSessionDirect(sessionId: string): Promise<ChatMessage[] | null> {
return this.loadSession(sessionId);
}
/**
* Create new session
*
@@ -1170,7 +1073,11 @@ export class QwenAgentManager {
* @param workingDir - Working directory
* @returns Newly created session ID
*/
async createNewSession(workingDir: string): Promise<string | null> {
async createNewSession(
workingDir: string,
options?: AgentSessionOptions,
): Promise<string | null> {
const autoAuthenticate = options?.autoAuthenticate ?? true;
// Reuse existing session if present
if (this.connection.currentSessionId) {
return this.connection.currentSessionId;
@@ -1188,18 +1095,24 @@ export class QwenAgentManager {
try {
await this.connection.newSession(workingDir);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const requiresAuth =
msg.includes('Authentication required') ||
msg.includes('(code: -32000)');
const requiresAuth = isAuthenticationRequiredError(err);
if (requiresAuth) {
if (!autoAuthenticate) {
console.warn(
'[QwenAgentManager] session/new requires authentication but auto-auth is disabled. Deferring until user logs in.',
);
throw err;
}
console.warn(
'[QwenAgentManager] session/new requires authentication. Retrying with authenticate...',
);
try {
// Let CLI handle authentication - it's the single source of truth
await this.connection.authenticate(authMethod);
console.log(
'[QwenAgentManager] createNewSession Authentication successful. Retrying session/new...',
);
// Add a slight delay to ensure auth state is settled
await new Promise((resolve) => setTimeout(resolve, 300));
await this.connection.newSession(workingDir);
@@ -1310,9 +1223,9 @@ export class QwenAgentManager {
/**
* Register end-of-turn callback
*
* @param callback - Called when ACP stopReason === 'end_turn'
* @param callback - Called when ACP stopReason is reported
*/
onEndTurn(callback: () => void): void {
onEndTurn(callback: (reason?: string) => void): void {
this.callbacks.onEndTurn = callback;
this.sessionUpdateHandler.updateCallbacks(this.callbacks);
}

View File

@@ -10,16 +10,15 @@
* Handles Qwen Agent connection establishment, authentication, and session creation
*/
import * as vscode from 'vscode';
import type { AcpConnection } from './acpConnection.js';
import type { QwenSessionReader } from '../services/qwenSessionReader.js';
import {
CliVersionManager,
MIN_CLI_VERSION_FOR_SESSION_METHODS,
} from '../cli/cliVersionManager.js';
import { CliContextManager } from '../cli/cliContextManager.js';
import { isAuthenticationRequiredError } from '../utils/authErrors.js';
import { authMethod } from '../types/acpTypes.js';
export interface QwenConnectionResult {
sessionCreated: boolean;
requiresAuth: boolean;
}
/**
* Qwen Connection Handler class
* Handles connection, authentication, and session initialization
@@ -29,45 +28,27 @@ export class QwenConnectionHandler {
* Connect to Qwen service and establish session
*
* @param connection - ACP connection instance
* @param sessionReader - Session reader instance
* @param workingDir - Working directory
* @param cliPath - CLI path (optional, if provided will override the path in configuration)
*/
async connect(
connection: AcpConnection,
sessionReader: QwenSessionReader,
workingDir: string,
cliPath?: string,
): Promise<void> {
cliEntryPath: string,
options?: {
autoAuthenticate?: boolean;
},
): Promise<QwenConnectionResult> {
const connectId = Date.now();
console.log(`[QwenAgentManager] 🚀 CONNECT() CALLED - ID: ${connectId}`);
// Check CLI version and features
const cliVersionManager = CliVersionManager.getInstance();
const versionInfo = await cliVersionManager.detectCliVersion();
console.log('[QwenAgentManager] CLI version info:', versionInfo);
// Store CLI context
const cliContextManager = CliContextManager.getInstance();
cliContextManager.setCurrentVersionInfo(versionInfo);
// Show warning if CLI version is below minimum requirement
if (!versionInfo.isSupported) {
// Wait to determine release version number
vscode.window.showWarningMessage(
`Qwen Code CLI version ${versionInfo.version} is below the minimum required version. Some features may not work properly. Please upgrade to version ${MIN_CLI_VERSION_FOR_SESSION_METHODS} or later.`,
);
}
const config = vscode.workspace.getConfiguration('qwenCode');
// Use the provided CLI path if available, otherwise use the configured path
const effectiveCliPath =
cliPath || config.get<string>('qwen.cliPath', 'qwen');
const autoAuthenticate = options?.autoAuthenticate ?? true;
let sessionCreated = false;
let requiresAuth = false;
// Build extra CLI arguments (only essential parameters)
const extraArgs: string[] = [];
await connection.connect(effectiveCliPath, workingDir, extraArgs);
await connection.connect(cliEntryPath!, workingDir, extraArgs);
// Try to restore existing session or create new session
// Note: Auto-restore on connect is disabled to avoid surprising loads
@@ -85,18 +66,40 @@ export class QwenConnectionHandler {
console.log(
'[QwenAgentManager] Creating new session (letting CLI handle authentication)...',
);
await this.newSessionWithRetry(connection, workingDir, 3, authMethod);
await this.newSessionWithRetry(
connection,
workingDir,
3,
authMethod,
autoAuthenticate,
);
console.log('[QwenAgentManager] New session created successfully');
sessionCreated = true;
} catch (sessionError) {
console.log(`\n⚠ [SESSION FAILED] newSessionWithRetry threw error\n`);
console.log(`[QwenAgentManager] Error details:`, sessionError);
throw sessionError;
const needsAuth =
autoAuthenticate === false &&
isAuthenticationRequiredError(sessionError);
if (needsAuth) {
requiresAuth = true;
console.log(
'[QwenAgentManager] Session creation requires authentication; waiting for user-triggered login.',
);
} else {
console.log(
`\n⚠ [SESSION FAILED] newSessionWithRetry threw error\n`,
);
console.log(`[QwenAgentManager] Error details:`, sessionError);
throw sessionError;
}
}
} else {
sessionCreated = true;
}
console.log(`\n========================================`);
console.log(`[QwenAgentManager] ✅ CONNECT() COMPLETED SUCCESSFULLY`);
console.log(`========================================\n`);
return { sessionCreated, requiresAuth };
}
/**
@@ -111,6 +114,7 @@ export class QwenConnectionHandler {
workingDir: string,
maxRetries: number,
authMethod: string,
autoAuthenticate: boolean,
): Promise<void> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
@@ -130,10 +134,14 @@ export class QwenConnectionHandler {
// If Qwen reports that authentication is required, try to
// authenticate on-the-fly once and retry without waiting.
const requiresAuth =
errorMessage.includes('Authentication required') ||
errorMessage.includes('(code: -32000)');
const requiresAuth = isAuthenticationRequiredError(error);
if (requiresAuth) {
if (!autoAuthenticate) {
console.log(
'[QwenAgentManager] Authentication required but auto-authentication is disabled. Propagating error.',
);
throw error;
}
console.log(
'[QwenAgentManager] Qwen requires authentication. Authenticating and retrying session/new...',
);
@@ -143,6 +151,9 @@ export class QwenConnectionHandler {
// newSession may cause the cli authorization jump to be triggered again
// Add a slight delay to ensure auth state is settled
await new Promise((resolve) => setTimeout(resolve, 300));
console.log(
'[QwenAgentManager] newSessionWithRetry Authentication successful',
);
// Retry immediately after successful auth
await connection.newSession(workingDir);
console.log(

View File

@@ -51,131 +51,7 @@ export class QwenSessionManager {
}
/**
* Save current conversation as a checkpoint (matching CLI's /chat save format)
* Creates checkpoint with BOTH conversationId and sessionId as tags for compatibility
*
* @param messages - Current conversation messages
* @param conversationId - Conversation ID (from VSCode extension)
* @param sessionId - Session ID (from CLI tmp session file, optional)
* @param workingDir - Current working directory
* @returns Checkpoint tag
*/
async saveCheckpoint(
messages: QwenMessage[],
conversationId: string,
workingDir: string,
sessionId?: string,
): Promise<string> {
try {
console.log('[QwenSessionManager] ===== SAVEPOINT START =====');
console.log('[QwenSessionManager] Conversation ID:', conversationId);
console.log(
'[QwenSessionManager] Session ID:',
sessionId || 'not provided',
);
console.log('[QwenSessionManager] Working dir:', workingDir);
console.log('[QwenSessionManager] Message count:', messages.length);
// Get project directory (parent of chats directory)
const projectHash = this.getProjectHash(workingDir);
console.log('[QwenSessionManager] Project hash:', projectHash);
const projectDir = path.join(this.qwenDir, 'tmp', projectHash);
console.log('[QwenSessionManager] Project dir:', projectDir);
if (!fs.existsSync(projectDir)) {
console.log('[QwenSessionManager] Creating project directory...');
fs.mkdirSync(projectDir, { recursive: true });
console.log('[QwenSessionManager] Directory created');
} else {
console.log('[QwenSessionManager] Project directory already exists');
}
// Convert messages to checkpoint format (Gemini-style messages)
console.log(
'[QwenSessionManager] Converting messages to checkpoint format...',
);
const checkpointMessages = messages.map((msg, index) => {
console.log(
`[QwenSessionManager] Message ${index}: type=${msg.type}, contentLength=${msg.content?.length || 0}`,
);
return {
role: msg.type === 'user' ? 'user' : 'model',
parts: [
{
text: msg.content,
},
],
};
});
console.log(
'[QwenSessionManager] Converted',
checkpointMessages.length,
'messages',
);
const jsonContent = JSON.stringify(checkpointMessages, null, 2);
console.log(
'[QwenSessionManager] JSON content length:',
jsonContent.length,
);
// Save with conversationId as primary tag
const convFilename = `checkpoint-${conversationId}.json`;
const convFilePath = path.join(projectDir, convFilename);
console.log(
'[QwenSessionManager] Saving checkpoint with conversationId:',
convFilePath,
);
fs.writeFileSync(convFilePath, jsonContent, 'utf-8');
// Also save with sessionId if provided (for compatibility with CLI session/load)
if (sessionId) {
const sessionFilename = `checkpoint-${sessionId}.json`;
const sessionFilePath = path.join(projectDir, sessionFilename);
console.log(
'[QwenSessionManager] Also saving checkpoint with sessionId:',
sessionFilePath,
);
fs.writeFileSync(sessionFilePath, jsonContent, 'utf-8');
}
// Verify primary file exists
if (fs.existsSync(convFilePath)) {
const stats = fs.statSync(convFilePath);
console.log(
'[QwenSessionManager] Primary checkpoint verified, size:',
stats.size,
);
} else {
console.error(
'[QwenSessionManager] ERROR: Primary checkpoint does not exist after write!',
);
}
console.log('[QwenSessionManager] ===== CHECKPOINT SAVED =====');
console.log('[QwenSessionManager] Primary path:', convFilePath);
if (sessionId) {
console.log(
'[QwenSessionManager] Secondary path (sessionId):',
path.join(projectDir, `checkpoint-${sessionId}.json`),
);
}
return conversationId;
} catch (error) {
console.error('[QwenSessionManager] ===== CHECKPOINT SAVE FAILED =====');
console.error('[QwenSessionManager] Error:', error);
console.error(
'[QwenSessionManager] Error stack:',
error instanceof Error ? error.stack : 'N/A',
);
throw error;
}
}
/**
* Save current conversation as a named session (checkpoint-like functionality)
* Save current conversation as a named session
*
* @param messages - Current conversation messages
* @param sessionName - Name/tag for the saved session

View File

@@ -7,6 +7,8 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import * as readline from 'readline';
import * as crypto from 'crypto';
export interface QwenMessage {
id: string;
@@ -32,6 +34,9 @@ export interface QwenSession {
lastUpdated: string;
messages: QwenMessage[];
filePath?: string;
messageCount?: number;
firstUserText?: string;
cwd?: string;
}
export class QwenSessionReader {
@@ -96,11 +101,17 @@ export class QwenSessionReader {
return sessions;
}
const files = fs
.readdirSync(chatsDir)
.filter((f) => f.startsWith('session-') && f.endsWith('.json'));
const files = fs.readdirSync(chatsDir);
for (const file of files) {
const jsonSessionFiles = files.filter(
(f) => f.startsWith('session-') && f.endsWith('.json'),
);
const jsonlSessionFiles = files.filter((f) =>
/^[0-9a-fA-F-]{32,36}\.jsonl$/.test(f),
);
for (const file of jsonSessionFiles) {
const filePath = path.join(chatsDir, file);
try {
const content = fs.readFileSync(filePath, 'utf-8');
@@ -116,6 +127,23 @@ export class QwenSessionReader {
}
}
// Support new JSONL session format produced by the CLI
for (const file of jsonlSessionFiles) {
const filePath = path.join(chatsDir, file);
try {
const session = await this.readJsonlSession(filePath, false);
if (session) {
sessions.push(session);
}
} catch (error) {
console.error(
'[QwenSessionReader] Failed to read JSONL session file:',
filePath,
error,
);
}
}
return sessions;
}
@@ -128,7 +156,25 @@ export class QwenSessionReader {
): Promise<QwenSession | null> {
// First try to find in all projects
const sessions = await this.getAllSessions(undefined, true);
return sessions.find((s) => s.sessionId === sessionId) || null;
const found = sessions.find((s) => s.sessionId === sessionId);
if (!found) {
return null;
}
// If the session points to a JSONL file, load full content on demand
if (
found.filePath &&
found.filePath.endsWith('.jsonl') &&
found.messages.length === 0
) {
const hydrated = await this.readJsonlSession(found.filePath, true);
if (hydrated) {
return hydrated;
}
}
return found;
}
/**
@@ -136,7 +182,6 @@ export class QwenSessionReader {
* Qwen CLI uses SHA256 hash of project path
*/
private async getProjectHash(workingDir: string): Promise<string> {
const crypto = await import('crypto');
return crypto.createHash('sha256').update(workingDir).digest('hex');
}
@@ -144,6 +189,14 @@ export class QwenSessionReader {
* Get session title (based on first user message)
*/
getSessionTitle(session: QwenSession): string {
// Prefer cached prompt text to avoid loading messages for JSONL sessions
if (session.firstUserText) {
return (
session.firstUserText.substring(0, 50) +
(session.firstUserText.length > 50 ? '...' : '')
);
}
const firstUserMessage = session.messages.find((m) => m.type === 'user');
if (firstUserMessage) {
// Extract first 50 characters as title
@@ -155,6 +208,137 @@ export class QwenSessionReader {
return 'Untitled Session';
}
/**
* Parse a JSONL session file written by the CLI.
* When includeMessages is false, only lightweight metadata is returned.
*/
private async readJsonlSession(
filePath: string,
includeMessages: boolean,
): Promise<QwenSession | null> {
try {
if (!fs.existsSync(filePath)) {
return null;
}
const stats = fs.statSync(filePath);
const fileStream = fs.createReadStream(filePath, { encoding: 'utf-8' });
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity,
});
const messages: QwenMessage[] = [];
const seenUuids = new Set<string>();
let sessionId: string | undefined;
let startTime: string | undefined;
let firstUserText: string | undefined;
let cwd: string | undefined;
for await (const line of rl) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
let obj: Record<string, unknown>;
try {
obj = JSON.parse(trimmed) as Record<string, unknown>;
} catch {
continue;
}
if (!sessionId && typeof obj.sessionId === 'string') {
sessionId = obj.sessionId;
}
if (!startTime && typeof obj.timestamp === 'string') {
startTime = obj.timestamp;
}
if (!cwd && typeof obj.cwd === 'string') {
cwd = obj.cwd;
}
const type = typeof obj.type === 'string' ? obj.type : '';
if (type === 'user' || type === 'assistant') {
const uuid = typeof obj.uuid === 'string' ? obj.uuid : undefined;
if (uuid) {
seenUuids.add(uuid);
}
const text = this.contentToText(obj.message);
if (includeMessages) {
messages.push({
id: uuid || `${messages.length}`,
timestamp: typeof obj.timestamp === 'string' ? obj.timestamp : '',
type: type === 'user' ? 'user' : 'qwen',
content: text,
});
}
if (!firstUserText && type === 'user' && text) {
firstUserText = text;
}
}
}
// Ensure stream is closed
rl.close();
if (!sessionId) {
return null;
}
const projectHash = cwd
? await this.getProjectHash(cwd)
: path.basename(path.dirname(path.dirname(filePath)));
return {
sessionId,
projectHash,
startTime: startTime || new Date(stats.birthtimeMs).toISOString(),
lastUpdated: new Date(stats.mtimeMs).toISOString(),
messages: includeMessages ? messages : [],
filePath,
messageCount: seenUuids.size,
firstUserText,
cwd,
};
} catch (error) {
console.error(
'[QwenSessionReader] Failed to parse JSONL session:',
error,
);
return null;
}
}
// Extract plain text from CLI Content structure
private contentToText(message: unknown): string {
try {
if (typeof message !== 'object' || message === null) {
return '';
}
const typed = message as { parts?: unknown[] };
const parts = Array.isArray(typed.parts) ? typed.parts : [];
const texts: string[] = [];
for (const part of parts) {
if (typeof part !== 'object' || part === null) {
continue;
}
const p = part as Record<string, unknown>;
if (typeof p.text === 'string') {
texts.push(p.text);
} else if (typeof p.data === 'string') {
texts.push(p.data);
}
}
return texts.join('\n');
} catch {
return '';
}
}
/**
* Delete session file
*/

View File

@@ -10,7 +10,8 @@
* Handles session updates from ACP and dispatches them to appropriate callbacks
*/
import type { AcpSessionUpdate, ApprovalModeValue } from '../types/acpTypes.js';
import type { AcpSessionUpdate } from '../types/acpTypes.js';
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
import type { QwenAgentCallbacks } from '../types/chatTypes.js';
/**

View File

@@ -3,6 +3,7 @@
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { ApprovalModeValue } from './approvalModeValueTypes.js';
export const JSONRPC_VERSION = '2.0' as const;
export const authMethod = 'qwen-oauth';
@@ -138,8 +139,6 @@ export interface PlanUpdate extends BaseSessionUpdate {
};
}
export type ApprovalModeValue = 'plan' | 'default' | 'auto-edit' | 'yolo';
export {
ApprovalMode,
APPROVAL_MODE_MAP,
@@ -167,6 +166,13 @@ export interface CurrentModeUpdate extends BaseSessionUpdate {
};
}
// Authenticate update (sent by agent during authentication process)
export interface AuthenticateUpdateNotification {
_meta: {
authUri: string;
};
}
export type AcpSessionUpdate =
| UserMessageChunkUpdate
| AgentMessageChunkUpdate

View File

@@ -0,0 +1,11 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Type for approval mode values
* Used in ACP protocol for controlling agent behavior
*/
export type ApprovalModeValue = 'plan' | 'default' | 'auto-edit' | 'yolo';

View File

@@ -3,7 +3,8 @@
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { AcpPermissionRequest, ApprovalModeValue } from './acpTypes.js';
import type { AcpPermissionRequest } from './acpTypes.js';
import type { ApprovalModeValue } from './approvalModeValueTypes.js';
export interface ChatMessage {
role: 'user' | 'assistant';
@@ -34,7 +35,7 @@ export interface QwenAgentCallbacks {
onToolCall?: (update: ToolCallUpdateData) => void;
onPlan?: (entries: PlanEntry[]) => void;
onPermissionRequest?: (request: AcpPermissionRequest) => Promise<string>;
onEndTurn?: () => void;
onEndTurn?: (reason?: string) => void;
onModeInfo?: (info: {
currentModeId?: ApprovalModeValue;
availableModes?: Array<{

View File

@@ -5,7 +5,11 @@
*/
import type { ChildProcess } from 'child_process';
import type { AcpSessionUpdate, AcpPermissionRequest } from './acpTypes.js';
import type {
AcpSessionUpdate,
AcpPermissionRequest,
AuthenticateUpdateNotification,
} from './acpTypes.js';
export interface PendingRequest<T = unknown> {
resolve: (value: T) => void;
@@ -19,7 +23,8 @@ export interface AcpConnectionCallbacks {
onPermissionRequest: (data: AcpPermissionRequest) => Promise<{
optionId: string;
}>;
onEndTurn: () => void;
onAuthenticateUpdate: (data: AuthenticateUpdateNotification) => void;
onEndTurn: (reason?: string) => void;
}
export interface AcpConnectionState {

View File

@@ -0,0 +1,34 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
const AUTH_ERROR_PATTERNS = [
'Authentication required', // Standard authentication request message
'(code: -32000)', // RPC error code -32000 indicates authentication failure
'Unauthorized', // HTTP unauthorized error
'Invalid token', // Invalid token
'Session expired', // Session expired
];
/**
* Determines if the given error is authentication-related
*/
export const isAuthenticationRequiredError = (error: unknown): boolean => {
// Null check to avoid unnecessary processing
if (!error) {
return false;
}
// Extract error message text
const message =
error instanceof Error
? error.message
: typeof error === 'string'
? error
: String(error);
// Match authentication-related errors using predefined patterns
return AUTH_ERROR_PATTERNS.some((pattern) => message.includes(pattern));
};

View File

@@ -0,0 +1,50 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as vscode from 'vscode';
import type { AuthenticateUpdateNotification } from '../types/acpTypes.js';
// Store reference to the current notification
let currentNotification: Thenable<string | undefined> | null = null;
/**
* Handle authentication update notifications by showing a VS Code notification
* with the authentication URI and action buttons.
*
* @param data - Authentication update notification data containing the auth URI
*/
export function handleAuthenticateUpdate(
data: AuthenticateUpdateNotification,
): void {
const authUri = data._meta.authUri;
// Store reference to the current notification
currentNotification = vscode.window.showInformationMessage(
`Qwen Code needs authentication. Click an action below:`,
'Open in Browser',
'Copy Link',
'Dismiss',
);
currentNotification.then((selection) => {
if (selection === 'Open in Browser') {
// Open the authentication URI in the default browser
vscode.env.openExternal(vscode.Uri.parse(authUri));
vscode.window.showInformationMessage(
'Opening authentication page in your browser...',
);
} else if (selection === 'Copy Link') {
// Copy the authentication URI to clipboard
vscode.env.clipboard.writeText(authUri);
vscode.window.showInformationMessage(
'Authentication link copied to clipboard!',
);
}
// Clear the notification reference after user interaction
currentNotification = null;
});
}

View File

@@ -29,6 +29,7 @@ import { PermissionDrawer } from './components/PermissionDrawer/PermissionDrawer
import { ToolCall } from './components/messages/toolcalls/ToolCall.js';
import { hasToolCallOutput } from './components/messages/toolcalls/shared/utils.js';
import { EmptyState } from './components/layout/EmptyState.js';
import { Onboarding } from './components/layout/Onboarding.js';
import { type CompletionItem } from '../types/completionItemTypes.js';
import { useCompletionTrigger } from './hooks/useCompletionTrigger.js';
import { ChatHeader } from './components/layout/ChatHeader.js';
@@ -43,7 +44,7 @@ import { InputForm } from './components/layout/InputForm.js';
import { SessionSelector } from './components/layout/SessionSelector.js';
import { FileIcon, UserIcon } from './components/icons/index.js';
import { ApprovalMode, NEXT_APPROVAL_MODE } from '../types/acpTypes.js';
import type { ApprovalModeValue } from '../types/acpTypes.js';
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
import type { PlanEntry } from '../types/chatTypes.js';
export const App: React.FC = () => {
@@ -67,6 +68,8 @@ export const App: React.FC = () => {
toolCall: PermissionToolCall;
} | null>(null);
const [planEntries, setPlanEntries] = useState<PlanEntry[]>([]);
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true); // Track if we're still initializing/loading
const messagesEndRef = useRef<HTMLDivElement>(
null,
) as React.RefObject<HTMLDivElement>;
@@ -90,9 +93,13 @@ export const App: React.FC = () => {
const getCompletionItems = React.useCallback(
async (trigger: '@' | '/', query: string): Promise<CompletionItem[]> => {
if (trigger === '@') {
if (!fileContext.hasRequestedFiles) {
fileContext.requestWorkspaceFiles();
}
console.log('[App] getCompletionItems @ called', {
query,
requested: fileContext.hasRequestedFiles,
workspaceFiles: fileContext.workspaceFiles.length,
});
// 始终根据当前 query 触发请求,让 hook 判断是否需要真正请求
fileContext.requestWorkspaceFiles(query);
const fileIcon = <FileIcon />;
const allItems: CompletionItem[] = fileContext.workspaceFiles.map(
@@ -109,7 +116,6 @@ export const App: React.FC = () => {
);
if (query && query.length >= 1) {
fileContext.requestWorkspaceFiles(query);
const lowerQuery = query.toLowerCase();
return allItems.filter(
(item) =>
@@ -154,17 +160,39 @@ export const App: React.FC = () => {
const completion = useCompletionTrigger(inputFieldRef, getCompletionItems);
// Track a lightweight signature of workspace files to detect content changes even when length is unchanged
const workspaceFilesSignature = useMemo(
() =>
fileContext.workspaceFiles
.map(
(file) =>
`${file.id}|${file.label}|${file.description ?? ''}|${file.path}`,
)
.join('||'),
[fileContext.workspaceFiles],
);
// When workspace files update while menu open for @, refresh items so the first @ shows the list
// Note: Avoid depending on the entire `completion` object here, since its identity
// changes on every render which would retrigger this effect and can cause a refresh loop.
useEffect(() => {
if (completion.isOpen && completion.triggerChar === '@') {
// Only auto-refresh when there's no query (first @ popup) to avoid repeated refreshes during search
if (
completion.isOpen &&
completion.triggerChar === '@' &&
!completion.query
) {
// Only refresh items; do not change other completion state to avoid re-renders loops
completion.refreshCompletion();
}
// Only re-run when the actual data source changes, not on every render
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fileContext.workspaceFiles, completion.isOpen, completion.triggerChar]);
}, [
workspaceFilesSignature,
completion.isOpen,
completion.triggerChar,
completion.query,
]);
// Message submission
const { handleSubmit: submitMessage } = useMessageSubmit({
@@ -176,6 +204,7 @@ export const App: React.FC = () => {
vscode,
inputFieldRef,
isStreaming: messageHandling.isStreaming,
isWaitingForResponse: messageHandling.isWaitingForResponse,
});
// Handle cancel/stop from the input bar
@@ -218,6 +247,7 @@ export const App: React.FC = () => {
inputFieldRef,
setInputText,
setEditMode,
setIsAuthenticated,
});
// Auto-scroll handling: keep the view pinned to bottom when new content arrives,
@@ -331,6 +361,14 @@ export const App: React.FC = () => {
completedToolCalls,
]);
// Set loading state to false after initial mount and when we have authentication info
useEffect(() => {
// If we have determined authentication status, we're done loading
if (isAuthenticated !== null) {
setIsLoading(false);
}
}, [isAuthenticated]);
// Handle permission response
const handlePermissionResponse = useCallback(
(optionId: string) => {
@@ -637,7 +675,19 @@ export const App: React.FC = () => {
allMessages.length > 0;
return (
<div className="chat-container">
<div className="chat-container relative">
{/* Top-level loading overlay */}
{isLoading && (
<div className="bg-background/80 absolute inset-0 z-50 flex items-center justify-center backdrop-blur-sm">
<div className="text-center">
<div className="border-primary mx-auto mb-2 h-8 w-8 animate-spin rounded-full border-b-2"></div>
<p className="text-muted-foreground text-sm">
Preparing Qwen Code...
</p>
</div>
</div>
)}
<SessionSelector
visible={sessionManagement.showSessionSelector}
sessions={sessionManagement.filteredSessions}
@@ -662,96 +712,110 @@ export const App: React.FC = () => {
<div
ref={messagesContainerRef}
className="chat-messages messages-container flex-1 overflow-y-auto overflow-x-hidden pt-5 pr-5 pl-5 pb-[120px] flex flex-col relative min-w-0 focus:outline-none [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-white/20 [&::-webkit-scrollbar-thumb]:rounded-sm [&::-webkit-scrollbar-thumb]:hover:bg-white/30 [&>*]:flex [&>*]:gap-0 [&>*]:items-start [&>*]:text-left [&>*]:py-2 [&>*:not(:last-child)]:pb-[8px] [&>*]:flex-col [&>*]:relative [&>*]:animate-[fadeIn_0.2s_ease-in]"
className="chat-messages messages-container flex-1 overflow-y-auto overflow-x-hidden pt-5 pr-5 pl-5 pb-[140px] flex flex-col relative min-w-0 focus:outline-none [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-white/20 [&::-webkit-scrollbar-thumb]:rounded-sm [&::-webkit-scrollbar-thumb]:hover:bg-white/30 [&>*]:flex [&>*]:gap-0 [&>*]:items-start [&>*]:text-left [&>*]:py-2 [&>*:not(:last-child)]:pb-[8px] [&>*]:flex-col [&>*]:relative [&>*]:animate-[fadeIn_0.2s_ease-in]"
>
{!hasContent ? (
<EmptyState />
{!hasContent && !isLoading ? (
isAuthenticated === false ? (
<Onboarding
onLogin={() => {
vscode.postMessage({ type: 'login', data: {} });
messageHandling.setWaitingForResponse(
'Logging in to Qwen Code...',
);
}}
/>
) : isAuthenticated === null ? (
<EmptyState loadingMessage="Checking login status…" />
) : (
<EmptyState isAuthenticated />
)
) : (
<>
{/* Render all messages and tool calls */}
{renderMessages()}
{/* Flow-in persistent slot: keeps a small constant height so toggling */}
{/* the waiting message doesn't change list height to zero. When */}
{/* active, render the waiting message inline (not fixed). */}
<div className="waiting-message-slot min-h-[28px]">
{messageHandling.isWaitingForResponse &&
messageHandling.loadingMessage && (
{/* Waiting message positioned fixed above the input form to avoid layout shifts */}
{messageHandling.isWaitingForResponse &&
messageHandling.loadingMessage && (
<div className="waiting-message-slot min-h-[28px]">
<WaitingMessage
loadingMessage={messageHandling.loadingMessage}
/>
)}
</div>
</div>
)}
<div ref={messagesEndRef} />
</>
)}
</div>
<InputForm
inputText={inputText}
inputFieldRef={inputFieldRef}
isStreaming={messageHandling.isStreaming}
isWaitingForResponse={messageHandling.isWaitingForResponse}
isComposing={isComposing}
editMode={editMode}
thinkingEnabled={thinkingEnabled}
activeFileName={fileContext.activeFileName}
activeSelection={fileContext.activeSelection}
skipAutoActiveContext={skipAutoActiveContext}
onInputChange={setInputText}
onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={() => setIsComposing(false)}
onKeyDown={() => {}}
onSubmit={handleSubmitWithScroll}
onCancel={handleCancel}
onToggleEditMode={handleToggleEditMode}
onToggleThinking={handleToggleThinking}
onFocusActiveEditor={fileContext.focusActiveEditor}
onToggleSkipAutoActiveContext={() =>
setSkipAutoActiveContext((v) => !v)
}
onShowCommandMenu={async () => {
if (inputFieldRef.current) {
inputFieldRef.current.focus();
{isAuthenticated && (
<InputForm
inputText={inputText}
inputFieldRef={inputFieldRef}
isStreaming={messageHandling.isStreaming}
isWaitingForResponse={messageHandling.isWaitingForResponse}
isComposing={isComposing}
editMode={editMode}
thinkingEnabled={thinkingEnabled}
activeFileName={fileContext.activeFileName}
activeSelection={fileContext.activeSelection}
skipAutoActiveContext={skipAutoActiveContext}
onInputChange={setInputText}
onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={() => setIsComposing(false)}
onKeyDown={() => {}}
onSubmit={handleSubmitWithScroll}
onCancel={handleCancel}
onToggleEditMode={handleToggleEditMode}
onToggleThinking={handleToggleThinking}
onFocusActiveEditor={fileContext.focusActiveEditor}
onToggleSkipAutoActiveContext={() =>
setSkipAutoActiveContext((v) => !v)
}
onShowCommandMenu={async () => {
if (inputFieldRef.current) {
inputFieldRef.current.focus();
const selection = window.getSelection();
let position = { top: 0, left: 0 };
const selection = window.getSelection();
let position = { top: 0, left: 0 };
if (selection && selection.rangeCount > 0) {
try {
const range = selection.getRangeAt(0);
const rangeRect = range.getBoundingClientRect();
if (rangeRect.top > 0 && rangeRect.left > 0) {
position = {
top: rangeRect.top,
left: rangeRect.left,
};
} else {
if (selection && selection.rangeCount > 0) {
try {
const range = selection.getRangeAt(0);
const rangeRect = range.getBoundingClientRect();
if (rangeRect.top > 0 && rangeRect.left > 0) {
position = {
top: rangeRect.top,
left: rangeRect.left,
};
} else {
const inputRect =
inputFieldRef.current.getBoundingClientRect();
position = { top: inputRect.top, left: inputRect.left };
}
} catch (error) {
console.error('[App] Error getting cursor position:', error);
const inputRect =
inputFieldRef.current.getBoundingClientRect();
position = { top: inputRect.top, left: inputRect.left };
}
} catch (error) {
console.error('[App] Error getting cursor position:', error);
} else {
const inputRect = inputFieldRef.current.getBoundingClientRect();
position = { top: inputRect.top, left: inputRect.left };
}
} else {
const inputRect = inputFieldRef.current.getBoundingClientRect();
position = { top: inputRect.top, left: inputRect.left };
await completion.openCompletion('/', '', position);
}
}}
onAttachContext={handleAttachContextClick}
completionIsOpen={completion.isOpen}
completionItems={completion.items}
onCompletionSelect={handleCompletionSelect}
onCompletionClose={completion.closeCompletion}
/>
)}
await completion.openCompletion('/', '', position);
}
}}
onAttachContext={handleAttachContextClick}
completionIsOpen={completion.isOpen}
completionItems={completion.items}
onCompletionSelect={handleCompletionSelect}
onCompletionClose={completion.closeCompletion}
/>
{permissionRequest && (
{isAuthenticated && permissionRequest && (
<PermissionDrawer
isOpen={!!permissionRequest}
options={permissionRequest.options}

View File

@@ -73,11 +73,4 @@ export class MessageHandler {
appendStreamContent(chunk: string): void {
this.router.appendStreamContent(chunk);
}
/**
* Check if saving checkpoint
*/
getIsSavingCheckpoint(): boolean {
return this.router.getIsSavingCheckpoint();
}
}

View File

@@ -8,13 +8,12 @@ import * as vscode from 'vscode';
import { QwenAgentManager } from '../services/qwenAgentManager.js';
import { ConversationStore } from '../services/conversationStore.js';
import type { AcpPermissionRequest } from '../types/acpTypes.js';
import { CliDetector } from '../cli/cliDetector.js';
import { PanelManager } from '../webview/PanelManager.js';
import { MessageHandler } from '../webview/MessageHandler.js';
import { WebViewContent } from '../webview/WebViewContent.js';
import { CliInstaller } from '../cli/cliInstaller.js';
import { getFileName } from './utils/webviewUtils.js';
import { type ApprovalModeValue } from '../types/acpTypes.js';
import { type ApprovalModeValue } from '../types/approvalModeValueTypes.js';
import { isAuthenticationRequiredError } from '../utils/authErrors.js';
export class WebViewProvider {
private panelManager: PanelManager;
@@ -32,7 +31,7 @@ export class WebViewProvider {
private currentModeId: ApprovalModeValue | null = null;
constructor(
context: vscode.ExtensionContext,
private context: vscode.ExtensionContext,
private extensionUri: vscode.Uri,
) {
this.agentManager = new QwenAgentManager();
@@ -119,12 +118,15 @@ export class WebViewProvider {
});
});
// Setup end-turn handler from ACP stopReason=end_turn
this.agentManager.onEndTurn(() => {
// Setup end-turn handler from ACP stopReason notifications
this.agentManager.onEndTurn((reason) => {
// Ensure WebView exits streaming state even if no explicit streamEnd was emitted elsewhere
this.sendMessageToWebView({
type: 'streamEnd',
data: { timestamp: Date.now(), reason: 'end_turn' },
data: {
timestamp: Date.now(),
reason: reason || 'end_turn',
},
});
});
@@ -519,11 +521,9 @@ export class WebViewProvider {
*/
private async attemptAuthStateRestoration(): Promise<void> {
try {
console.log(
'[WebViewProvider] Attempting connection (CLI handle authentication)...',
);
//always attempt connection and let CLI handle authentication
await this.initializeAgentConnection();
console.log('[WebViewProvider] Attempting connection...');
// Attempt a connection to detect prior auth without forcing login
await this.initializeAgentConnection({ autoAuthenticate: false });
} catch (error) {
console.error(
'[WebViewProvider] Error in attemptAuthStateRestoration:',
@@ -537,14 +537,19 @@ export class WebViewProvider {
* Initialize agent connection and session
* Can be called from show() or via /login command
*/
async initializeAgentConnection(): Promise<void> {
return this.doInitializeAgentConnection();
async initializeAgentConnection(options?: {
autoAuthenticate?: boolean;
}): Promise<void> {
return this.doInitializeAgentConnection(options);
}
/**
* Internal: perform actual connection/initialization (no auth locking).
*/
private async doInitializeAgentConnection(): Promise<void> {
private async doInitializeAgentConnection(options?: {
autoAuthenticate?: boolean;
}): Promise<void> {
const autoAuthenticate = options?.autoAuthenticate ?? true;
const run = async () => {
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
@@ -553,65 +558,80 @@ export class WebViewProvider {
'[WebViewProvider] Starting initialization, workingDir:',
workingDir,
);
console.log('[WebViewProvider] Using CLI-managed authentication');
console.log(
`[WebViewProvider] Using CLI-managed authentication (autoAuth=${autoAuthenticate})`,
);
// Check if CLI is installed before attempting to connect
const cliDetection = await CliDetector.detectQwenCli();
const bundledCliEntry = vscode.Uri.joinPath(
this.extensionUri,
'dist',
'qwen-cli',
'cli.js',
).fsPath;
if (!cliDetection.isInstalled) {
console.log(
'[WebViewProvider] Qwen CLI not detected, skipping agent connection',
);
console.log(
'[WebViewProvider] CLI detection error:',
cliDetection.error,
try {
console.log('[WebViewProvider] Connecting to agent...');
// Pass the detected CLI path to ensure we use the correct installation
const connectResult = await this.agentManager.connect(
workingDir,
bundledCliEntry,
options,
);
console.log('[WebViewProvider] Agent connected successfully');
this.agentInitialized = true;
// Show VSCode notification with installation option
await CliInstaller.promptInstallation();
// If authentication is required and autoAuthenticate is false,
// send authState message and return without creating session
if (connectResult.requiresAuth && !autoAuthenticate) {
console.log(
'[WebViewProvider] Authentication required but auto-auth disabled, sending authState and returning',
);
this.sendMessageToWebView({
type: 'authState',
data: { authenticated: false },
});
// Initialize empty conversation to allow browsing history
await this.initializeEmptyConversation();
return;
}
// Initialize empty conversation (can still browse history)
await this.initializeEmptyConversation();
} else {
console.log(
'[WebViewProvider] Qwen CLI detected, attempting connection...',
);
console.log('[WebViewProvider] CLI path:', cliDetection.cliPath);
console.log('[WebViewProvider] CLI version:', cliDetection.version);
if (connectResult.requiresAuth) {
this.sendMessageToWebView({
type: 'authState',
data: { authenticated: false },
});
}
try {
console.log('[WebViewProvider] Connecting to agent...');
// Pass the detected CLI path to ensure we use the correct installation
await this.agentManager.connect(workingDir, cliDetection.cliPath);
console.log('[WebViewProvider] Agent connected successfully');
this.agentInitialized = true;
// Load messages from the current Qwen session
await this.loadCurrentSessionMessages();
// Load messages from the current Qwen session
const sessionReady = await this.loadCurrentSessionMessages(options);
if (sessionReady) {
// Notify webview that agent is connected
this.sendMessageToWebView({
type: 'agentConnected',
data: {},
});
} catch (_error) {
console.error('[WebViewProvider] Agent connection error:', _error);
vscode.window.showWarningMessage(
`Failed to connect to Qwen CLI: ${_error}\nYou can still use the chat UI, but messages won't be sent to AI.`,
} else {
console.log(
'[WebViewProvider] Session creation deferred until user logs in.',
);
// Fallback to empty conversation
await this.initializeEmptyConversation();
// Notify webview that agent connection failed
this.sendMessageToWebView({
type: 'agentConnectionError',
data: {
message:
_error instanceof Error ? _error.message : String(_error),
},
});
}
} catch (_error) {
console.error('[WebViewProvider] Agent connection error:', _error);
vscode.window.showWarningMessage(
`Failed to connect to Qwen CLI: ${_error}\nYou can still use the chat UI, but messages won't be sent to AI.`,
);
// Fallback to empty conversation
await this.initializeEmptyConversation();
// Notify webview that agent connection failed
this.sendMessageToWebView({
type: 'agentConnectionError',
data: {
message: _error instanceof Error ? _error.message : String(_error),
},
});
}
};
@@ -628,7 +648,6 @@ export class WebViewProvider {
return vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: 'Logging in to Qwen Code... ',
cancellable: false,
},
async (progress) => {
@@ -654,7 +673,7 @@ export class WebViewProvider {
});
// Reinitialize connection (will trigger fresh authentication)
await this.doInitializeAgentConnection();
await this.doInitializeAgentConnection({ autoAuthenticate: true });
console.log(
'[WebViewProvider] Force re-login completed successfully',
);
@@ -737,7 +756,11 @@ export class WebViewProvider {
* Load messages from current Qwen session
* Skips session restoration and creates a new session directly
*/
private async loadCurrentSessionMessages(): Promise<void> {
private async loadCurrentSessionMessages(options?: {
autoAuthenticate?: boolean;
}): Promise<boolean> {
const autoAuthenticate = options?.autoAuthenticate ?? true;
let sessionReady = false;
try {
console.log(
'[WebViewProvider] Initializing with new session (skipping restoration)',
@@ -748,22 +771,47 @@ export class WebViewProvider {
// avoid creating another session if connect() already created one.
if (!this.agentManager.currentSessionId) {
try {
await this.agentManager.createNewSession(workingDir);
console.log('[WebViewProvider] ACP session created successfully');
} catch (sessionError) {
console.error(
'[WebViewProvider] Failed to create ACP session:',
sessionError,
);
vscode.window.showWarningMessage(
`Failed to create ACP session: ${sessionError}. You may need to authenticate first.`,
if (!autoAuthenticate) {
console.log(
'[WebViewProvider] Skipping ACP session creation until user logs in.',
);
this.sendMessageToWebView({
type: 'authState',
data: { authenticated: false },
});
} else {
try {
await this.agentManager.createNewSession(workingDir, {
autoAuthenticate,
});
console.log('[WebViewProvider] ACP session created successfully');
sessionReady = true;
} catch (sessionError) {
const requiresAuth = isAuthenticationRequiredError(sessionError);
if (requiresAuth && !autoAuthenticate) {
console.log(
'[WebViewProvider] ACP session requires authentication; waiting for explicit login.',
);
this.sendMessageToWebView({
type: 'authState',
data: { authenticated: false },
});
} else {
console.error(
'[WebViewProvider] Failed to create ACP session:',
sessionError,
);
vscode.window.showWarningMessage(
`Failed to create ACP session: ${sessionError}. You may need to authenticate first.`,
);
}
}
}
} else {
console.log(
'[WebViewProvider] Existing ACP session detected, skipping new session creation',
);
sessionReady = true;
}
await this.initializeEmptyConversation();
@@ -776,7 +824,10 @@ export class WebViewProvider {
`Failed to load session messages: ${_error}`,
);
await this.initializeEmptyConversation();
return false;
}
return sessionReady;
}
/**

View File

@@ -92,9 +92,8 @@ export const CompletionMenu: React.FC<CompletionMenuProps> = ({
ref={containerRef}
role="menu"
className={[
// Semantic class name for readability (no CSS attached)
'completion-menu',
// Positioning and container styling (Tailwind)
// Positioning and container styling
'absolute bottom-full left-0 right-0 mb-2 flex flex-col overflow-hidden',
'rounded-large border bg-[var(--app-menu-background)]',
'border-[var(--app-input-border)] max-h-[50vh] z-[1000]',

View File

@@ -7,24 +7,56 @@
import type React from 'react';
import { generateIconUrl } from '../../utils/resourceUrl.js';
export const EmptyState: React.FC = () => {
interface EmptyStateProps {
isAuthenticated?: boolean;
loadingMessage?: string;
}
export const EmptyState: React.FC<EmptyStateProps> = ({
isAuthenticated = false,
loadingMessage,
}) => {
// Generate icon URL using the utility function
const iconUri = generateIconUrl('icon.png');
const description = loadingMessage
? 'Preparing Qwen Code…'
: isAuthenticated
? 'What would you like to do? Ask about this codebase or we can start writing code.'
: 'Welcome! Please log in to start using Qwen Code.';
return (
<div className="flex flex-col items-center justify-center h-full p-5 md:p-10">
<div className="flex flex-col items-center gap-8 w-full">
{/* Qwen Logo */}
<div className="flex flex-col items-center gap-6">
<img
src={iconUri}
alt="Qwen Logo"
className="w-[60px] h-[60px] object-contain"
/>
{iconUri ? (
<img
src={iconUri}
alt="Qwen Logo"
className="w-[60px] h-[60px] object-contain"
onError={(e) => {
// Fallback to a div with text if image fails to load
const target = e.target as HTMLImageElement;
target.style.display = 'none';
const parent = target.parentElement;
if (parent) {
const fallback = document.createElement('div');
fallback.className =
'w-[60px] h-[60px] flex items-center justify-center text-2xl font-bold';
fallback.textContent = 'Q';
parent.appendChild(fallback);
}
}}
/>
) : (
<div className="w-[60px] h-[60px] flex items-center justify-center text-2xl font-bold bg-gray-200 rounded">
Q
</div>
)}
<div className="text-center">
<div className="text-[15px] text-app-primary-foreground leading-normal font-normal max-w-[400px]">
What to do first? Ask about this codebase or we can start writing
code.
{description}
</div>
</div>
</div>

View File

@@ -11,7 +11,7 @@ import {
PlanModeIcon,
CodeBracketsIcon,
HideContextIcon,
ThinkingIcon,
// ThinkingIcon, // Temporarily disabled
SlashCommandIcon,
LinkIcon,
ArrowUpIcon,
@@ -20,7 +20,7 @@ import {
import { CompletionMenu } from '../layout/CompletionMenu.js';
import type { CompletionItem } from '../../../types/completionItemTypes.js';
import { getApprovalModeInfoFromString } from '../../../types/acpTypes.js';
import type { ApprovalModeValue } from '../../../types/acpTypes.js';
import type { ApprovalModeValue } from '../../../types/approvalModeValueTypes.js';
interface InputFormProps {
inputText: string;
@@ -92,7 +92,7 @@ export const InputForm: React.FC<InputFormProps> = ({
isWaitingForResponse,
isComposing,
editMode,
thinkingEnabled,
// thinkingEnabled, // Temporarily disabled
activeFileName,
activeSelection,
skipAutoActiveContext,
@@ -103,7 +103,7 @@ export const InputForm: React.FC<InputFormProps> = ({
onSubmit,
onCancel,
onToggleEditMode,
onToggleThinking,
// onToggleThinking, // Temporarily disabled
onToggleSkipAutoActiveContext,
onShowCommandMenu,
onAttachContext,
@@ -113,6 +113,7 @@ export const InputForm: React.FC<InputFormProps> = ({
onCompletionClose,
}) => {
const editModeInfo = getEditModeInfo(editMode);
const composerDisabled = isStreaming || isWaitingForResponse;
const handleKeyDown = (e: React.KeyboardEvent) => {
// ESC should cancel the current interaction (stop generation)
@@ -144,7 +145,7 @@ export const InputForm: React.FC<InputFormProps> = ({
return (
<div
className="p-1 px-4 pb-4"
className="p-1 px-4 pb-4 absolute bottom-0 left-0 right-0"
style={{ backgroundColor: 'var(--app-primary-background)' }}
>
<div className="block">
@@ -179,10 +180,16 @@ export const InputForm: React.FC<InputFormProps> = ({
data-placeholder="Ask Qwen Code …"
// Use a data flag so CSS can show placeholder even if the browser
// inserts an invisible <br> into contentEditable (so :empty no longer matches)
data-empty={inputText.trim().length === 0 ? 'true' : 'false'}
data-empty={
inputText.replace(/\u200B/g, '').trim().length === 0
? 'true'
: 'false'
}
onInput={(e) => {
const target = e.target as HTMLDivElement;
onInputChange(target.textContent || '');
// Filter out zero-width space that we use to maintain height
const text = target.textContent?.replace(/\u200B/g, '') || '';
onInputChange(text);
}}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
@@ -236,15 +243,16 @@ export const InputForm: React.FC<InputFormProps> = ({
{/* Spacer */}
<div className="flex-1 min-w-0" />
{/* @yiliang114. closed temporarily */}
{/* Thinking button */}
<button
{/* <button
type="button"
className={`btn-icon-compact ${thinkingEnabled ? 'btn-icon-compact--active' : ''}`}
title={thinkingEnabled ? 'Thinking on' : 'Thinking off'}
onClick={onToggleThinking}
>
<ThinkingIcon enabled={thinkingEnabled} />
</button>
</button> */}
{/* Command button */}
<button
@@ -280,7 +288,7 @@ export const InputForm: React.FC<InputFormProps> = ({
<button
type="submit"
className="btn-send-compact [&>svg]:w-5 [&>svg]:h-5"
disabled={!inputText.trim()}
disabled={composerDisabled || !inputText.trim()}
>
<ArrowUpIcon />
</button>

View File

@@ -0,0 +1,49 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { generateIconUrl } from '../../utils/resourceUrl.js';
interface OnboardingPageProps {
onLogin: () => void;
}
export const Onboarding: React.FC<OnboardingPageProps> = ({ onLogin }) => {
const iconUri = generateIconUrl('icon.png');
return (
<div className="flex flex-col items-center justify-center h-full p-5 md:p-10">
<div className="flex flex-col items-center gap-8 w-full max-w-md mx-auto">
<div className="flex flex-col items-center gap-6">
{/* Application icon container */}
<div className="relative">
<img
src={iconUri}
alt="Qwen Code Logo"
className="w-[80px] h-[80px] object-contain"
/>
</div>
<div className="text-center">
<h1 className="text-2xl font-bold text-app-primary-foreground mb-2">
Welcome to Qwen Code
</h1>
<p className="text-app-secondary-foreground max-w-sm">
Unlock the power of AI to understand, navigate, and transform your
codebase faster than ever before.
</p>
</div>
<button
onClick={onLogin}
className="w-full px-4 py-3 bg-[#4f46e5] text-white font-medium rounded-lg shadow-sm hover:bg-[#4338ca] transition-colors duration-200"
>
Get Started with Qwen Code
</button>
</div>
</div>
</div>
);
};

View File

@@ -5,8 +5,10 @@
*/
import React from 'react';
import { groupSessionsByDate } from '../../utils/sessionGrouping.js';
import { getTimeAgo } from '../../utils/timeUtils.js';
import {
getTimeAgo,
groupSessionsByDate,
} from '../../utils/sessionGrouping.js';
import { SearchIcon } from '../icons/index.js';
interface SessionSelectorProps {

View File

@@ -75,7 +75,11 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
whiteSpace: 'normal',
}}
>
<MessageContent content={content} onFileClick={onFileClick} />
<MessageContent
content={content}
onFileClick={onFileClick}
enableFileLinks={false}
/>
</div>
</span>
</div>

View File

@@ -49,7 +49,6 @@ export class FileMessageHandler extends BaseMessageHandler {
break;
case 'openDiff':
console.log('[FileMessageHandler ===== ] openDiff called with:', data);
await this.handleOpenDiff(data);
break;

View File

@@ -11,7 +11,6 @@ import { SessionMessageHandler } from './SessionMessageHandler.js';
import { FileMessageHandler } from './FileMessageHandler.js';
import { EditorMessageHandler } from './EditorMessageHandler.js';
import { AuthMessageHandler } from './AuthMessageHandler.js';
import { SettingsMessageHandler } from './SettingsMessageHandler.js';
/**
* Message Router
@@ -63,20 +62,12 @@ export class MessageRouter {
sendToWebView,
);
const settingsHandler = new SettingsMessageHandler(
agentManager,
conversationStore,
currentConversationId,
sendToWebView,
);
// Register handlers in order of priority
this.handlers = [
this.sessionHandler,
fileHandler,
editorHandler,
this.authHandler,
settingsHandler,
];
}
@@ -159,11 +150,4 @@ export class MessageRouter {
appendStreamContent(chunk: string): void {
this.sessionHandler.appendStreamContent(chunk);
}
/**
* Check if saving checkpoint
*/
getIsSavingCheckpoint(): boolean {
return this.sessionHandler.getIsSavingCheckpoint();
}
}

View File

@@ -7,6 +7,7 @@
import * as vscode from 'vscode';
import { BaseMessageHandler } from './BaseMessageHandler.js';
import type { ChatMessage } from '../../services/qwenAgentManager.js';
import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js';
/**
* Session message handler
@@ -14,7 +15,6 @@ import type { ChatMessage } from '../../services/qwenAgentManager.js';
*/
export class SessionMessageHandler extends BaseMessageHandler {
private currentStreamContent = '';
private isSavingCheckpoint = false;
private loginHandler: (() => Promise<void>) | null = null;
private isTitleSet = false; // Flag to track if title has been set
@@ -29,6 +29,8 @@ export class SessionMessageHandler extends BaseMessageHandler {
'cancelStreaming',
// UI action: open a new chat tab (new WebviewPanel)
'openNewChatTab',
// Settings-related messages
'setApprovalMode',
].includes(messageType);
}
@@ -112,6 +114,14 @@ export class SessionMessageHandler extends BaseMessageHandler {
await this.handleCancelStreaming();
break;
case 'setApprovalMode':
await this.handleSetApprovalMode(
message.data as {
modeId?: ApprovalModeValue;
},
);
break;
default:
console.warn(
'[SessionMessageHandler] Unknown message type:',
@@ -142,13 +152,6 @@ export class SessionMessageHandler extends BaseMessageHandler {
this.currentStreamContent = '';
}
/**
* Check if saving checkpoint
*/
getIsSavingCheckpoint(): boolean {
return this.isSavingCheckpoint;
}
/**
* Prompt user to login and invoke the registered login handler/command.
* Returns true if a login was initiated.
@@ -374,41 +377,6 @@ export class SessionMessageHandler extends BaseMessageHandler {
type: 'streamEnd',
data: { timestamp: Date.now() },
});
// Auto-save checkpoint
if (this.currentConversationId) {
try {
const conversation = await this.conversationStore.getConversation(
this.currentConversationId,
);
const messages = conversation?.messages || [];
this.isSavingCheckpoint = true;
const result = await this.agentManager.saveCheckpoint(
messages,
this.currentConversationId,
);
setTimeout(() => {
this.isSavingCheckpoint = false;
}, 2000);
if (result.success) {
console.log(
'[SessionMessageHandler] Checkpoint saved:',
result.tag,
);
}
} catch (error) {
console.error(
'[SessionMessageHandler] Checkpoint save failed:',
error,
);
this.isSavingCheckpoint = false;
}
}
} catch (error) {
console.error('[SessionMessageHandler] Error sending message:', error);
@@ -482,23 +450,6 @@ export class SessionMessageHandler extends BaseMessageHandler {
}
}
// Save current session before creating new one
if (this.currentConversationId && this.agentManager.isConnected) {
try {
const conversation = await this.conversationStore.getConversation(
this.currentConversationId,
);
const messages = conversation?.messages || [];
await this.agentManager.saveCheckpoint(
messages,
this.currentConversationId,
);
} catch (error) {
console.warn('[SessionMessageHandler] Failed to auto-save:', error);
}
}
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
@@ -578,27 +529,6 @@ export class SessionMessageHandler extends BaseMessageHandler {
}
}
// Save current session before switching
if (
this.currentConversationId &&
this.currentConversationId !== sessionId &&
this.agentManager.isConnected
) {
try {
const conversation = await this.conversationStore.getConversation(
this.currentConversationId,
);
const messages = conversation?.messages || [];
await this.agentManager.saveCheckpoint(
messages,
this.currentConversationId,
);
} catch (error) {
console.warn('[SessionMessageHandler] Failed to auto-save:', error);
}
}
// Get session details (includes cwd and filePath when using ACP)
let sessionDetails: Record<string, unknown> | null = null;
try {
@@ -841,11 +771,6 @@ export class SessionMessageHandler extends BaseMessageHandler {
throw new Error('No active conversation to save');
}
const conversation = await this.conversationStore.getConversation(
this.currentConversationId,
);
const messages = conversation?.messages || [];
// Try ACP save first
try {
const response = await this.agentManager.saveSessionViaAcp(
@@ -880,17 +805,6 @@ export class SessionMessageHandler extends BaseMessageHandler {
});
return;
}
// Fallback to direct save
const response = await this.agentManager.saveSessionDirect(
messages,
tag,
);
this.sendToWebView({
type: 'saveSessionResponse',
data: response,
});
}
await this.handleGetQwenSessions();
@@ -1025,20 +939,6 @@ export class SessionMessageHandler extends BaseMessageHandler {
});
return;
}
// Fallback to direct load
const messages = await this.agentManager.loadSessionDirect(sessionId);
if (messages) {
this.currentConversationId = sessionId;
this.sendToWebView({
type: 'qwenSessionSwitched',
data: { sessionId, messages },
});
} else {
throw new Error('Failed to load session');
}
}
await this.handleGetQwenSessions();
@@ -1073,4 +973,23 @@ export class SessionMessageHandler extends BaseMessageHandler {
}
}
}
/**
* Set approval mode via agent (ACP session/set_mode)
*/
private async handleSetApprovalMode(data?: {
modeId?: ApprovalModeValue;
}): Promise<void> {
try {
const modeId = data?.modeId || 'default';
await this.agentManager.setApprovalModeFromUi(modeId);
// No explicit response needed; WebView listens for modeChanged
} catch (error) {
console.error('[SessionMessageHandler] Failed to set mode:', error);
this.sendToWebView({
type: 'error',
data: { message: `Failed to set mode: ${error}` },
});
}
}
}

View File

@@ -1,101 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as vscode from 'vscode';
import { BaseMessageHandler } from './BaseMessageHandler.js';
import type { ApprovalModeValue } from '../../types/acpTypes.js';
/**
* Settings message handler
* Handles all settings-related messages
*/
export class SettingsMessageHandler extends BaseMessageHandler {
canHandle(messageType: string): boolean {
return ['openSettings', 'recheckCli', 'setApprovalMode'].includes(
messageType,
);
}
async handle(message: { type: string; data?: unknown }): Promise<void> {
switch (message.type) {
case 'openSettings':
await this.handleOpenSettings();
break;
case 'recheckCli':
await this.handleRecheckCli();
break;
case 'setApprovalMode':
await this.handleSetApprovalMode(
message.data as {
modeId?: ApprovalModeValue;
},
);
break;
default:
console.warn(
'[SettingsMessageHandler] Unknown message type:',
message.type,
);
break;
}
}
/**
* Open settings page
*/
private async handleOpenSettings(): Promise<void> {
try {
// Open settings in a side panel
await vscode.commands.executeCommand('workbench.action.openSettings', {
query: 'qwenCode',
});
} catch (error) {
console.error('[SettingsMessageHandler] Failed to open settings:', error);
vscode.window.showErrorMessage(`Failed to open settings: ${error}`);
}
}
/**
* Recheck CLI
*/
private async handleRecheckCli(): Promise<void> {
try {
await vscode.commands.executeCommand('qwenCode.recheckCli');
this.sendToWebView({
type: 'cliRechecked',
data: { success: true },
});
} catch (error) {
console.error('[SettingsMessageHandler] Failed to recheck CLI:', error);
this.sendToWebView({
type: 'error',
data: { message: `Failed to recheck CLI: ${error}` },
});
}
}
/**
* Set approval mode via agent (ACP session/set_mode)
*/
private async handleSetApprovalMode(data?: {
modeId?: ApprovalModeValue;
}): Promise<void> {
try {
const modeId = data?.modeId || 'default';
await this.agentManager.setApprovalModeFromUi(modeId);
// No explicit response needed; WebView listens for modeChanged
} catch (error) {
console.error('[SettingsMessageHandler] Failed to set mode:', error);
this.sendToWebView({
type: 'error',
data: { message: `Failed to set mode: ${error}` },
});
}
}
}

View File

@@ -34,6 +34,9 @@ export const useFileContext = (vscode: VSCodeAPI) => {
// Whether workspace files have been requested
const hasRequestedFilesRef = useRef(false);
// Last non-empty query to decide when to refetch full list
const lastQueryRef = useRef<string | undefined>(undefined);
// Search debounce timer
const searchTimerRef = useRef<NodeJS.Timeout | null>(null);
@@ -42,12 +45,10 @@ export const useFileContext = (vscode: VSCodeAPI) => {
*/
const requestWorkspaceFiles = useCallback(
(query?: string) => {
if (!hasRequestedFilesRef.current && !query) {
hasRequestedFilesRef.current = true;
}
const normalizedQuery = query?.trim();
// If there's a query, clear previous timer and set up debounce
if (query && query.length >= 1) {
if (normalizedQuery && normalizedQuery.length >= 1) {
if (searchTimerRef.current) {
clearTimeout(searchTimerRef.current);
}
@@ -55,14 +56,23 @@ export const useFileContext = (vscode: VSCodeAPI) => {
searchTimerRef.current = setTimeout(() => {
vscode.postMessage({
type: 'getWorkspaceFiles',
data: { query },
data: { query: normalizedQuery },
});
}, 300);
lastQueryRef.current = normalizedQuery;
} else {
vscode.postMessage({
type: 'getWorkspaceFiles',
data: query ? { query } : {},
});
// For empty query, request once initially and whenever we are returning from a search
const shouldRequestFullList =
!hasRequestedFilesRef.current || lastQueryRef.current !== undefined;
if (shouldRequestFullList) {
lastQueryRef.current = undefined;
hasRequestedFilesRef.current = true;
vscode.postMessage({
type: 'getWorkspaceFiles',
data: {},
});
}
}
},
[vscode],

View File

@@ -131,12 +131,55 @@ export function useCompletionTrigger(
[getCompletionItems, LOADING_ITEM, TIMEOUT_ITEM],
);
// Helper function to compare completion items arrays
const areItemsEqual = (
items1: CompletionItem[],
items2: CompletionItem[],
): boolean => {
if (items1.length !== items2.length) {
return false;
}
// Compare each item by stable fields (ignore non-deterministic props like icons)
for (let i = 0; i < items1.length; i++) {
const a = items1[i];
const b = items2[i];
if (a.id !== b.id) {
return false;
}
if (a.label !== b.label) {
return false;
}
if ((a.description ?? '') !== (b.description ?? '')) {
return false;
}
if (a.type !== b.type) {
return false;
}
if ((a.value ?? '') !== (b.value ?? '')) {
return false;
}
if ((a.path ?? '') !== (b.path ?? '')) {
return false;
}
}
return true;
};
const refreshCompletion = useCallback(async () => {
if (!state.isOpen || !state.triggerChar) {
return;
}
const items = await getCompletionItems(state.triggerChar, state.query);
setState((prev) => ({ ...prev, items }));
// Only update state if items have actually changed
setState((prev) => {
if (areItemsEqual(prev.items, items)) {
return prev;
}
return { ...prev, items };
});
}, [state.isOpen, state.triggerChar, state.query, getCompletionItems]);
useEffect(() => {

View File

@@ -14,6 +14,7 @@ interface UseMessageSubmitProps {
setInputText: (text: string) => void;
inputFieldRef: React.RefObject<HTMLDivElement>;
isStreaming: boolean;
isWaitingForResponse: boolean;
// When true, do NOT auto-attach the active editor file/selection to context
skipAutoActiveContext?: boolean;
@@ -40,6 +41,7 @@ export const useMessageSubmit = ({
setInputText,
inputFieldRef,
isStreaming,
isWaitingForResponse,
skipAutoActiveContext = false,
fileContext,
messageHandling,
@@ -48,7 +50,7 @@ export const useMessageSubmit = ({
(e: React.FormEvent) => {
e.preventDefault();
if (!inputText.trim() || isStreaming) {
if (!inputText.trim() || isStreaming || isWaitingForResponse) {
return;
}
@@ -56,7 +58,10 @@ export const useMessageSubmit = ({
if (inputText.trim() === '/login') {
setInputText('');
if (inputFieldRef.current) {
inputFieldRef.current.textContent = '';
// Use a zero-width space to maintain the height of the contentEditable element
inputFieldRef.current.textContent = '\u200B';
// Set the data-empty attribute to show the placeholder
inputFieldRef.current.setAttribute('data-empty', 'true');
}
vscode.postMessage({
type: 'login',
@@ -142,7 +147,10 @@ export const useMessageSubmit = ({
setInputText('');
if (inputFieldRef.current) {
inputFieldRef.current.textContent = '';
// Use a zero-width space to maintain the height of the contentEditable element
inputFieldRef.current.textContent = '\u200B';
// Set the data-empty attribute to show the placeholder
inputFieldRef.current.setAttribute('data-empty', 'true');
}
fileContext.clearFileReferences();
},
@@ -154,6 +162,7 @@ export const useMessageSubmit = ({
vscode,
fileContext,
skipAutoActiveContext,
isWaitingForResponse,
messageHandling,
],
);

View File

@@ -12,7 +12,7 @@ import type {
ToolCall as PermissionToolCall,
} from '../components/PermissionDrawer/PermissionRequest.js';
import type { ToolCallUpdate } from '../../types/chatTypes.js';
import type { ApprovalModeValue } from '../../types/acpTypes.js';
import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js';
import type { PlanEntry } from '../../types/chatTypes.js';
interface UseWebViewMessagesProps {
@@ -109,6 +109,8 @@ interface UseWebViewMessagesProps {
setInputText: (text: string) => void;
// Edit mode setter (maps ACP modes to UI modes)
setEditMode?: (mode: ApprovalModeValue) => void;
// Authentication state setter
setIsAuthenticated?: (authenticated: boolean | null) => void;
}
/**
@@ -126,6 +128,7 @@ export const useWebViewMessages = ({
inputFieldRef,
setInputText,
setEditMode,
setIsAuthenticated,
}: UseWebViewMessagesProps) => {
// VS Code API for posting messages back to the extension host
const vscode = useVSCode();
@@ -141,6 +144,7 @@ export const useWebViewMessages = ({
clearToolCalls,
setPlanEntries,
handlePermissionRequest,
setIsAuthenticated,
});
// Track last "Updated Plan" snapshot toolcall to support merge/dedupe
@@ -185,6 +189,7 @@ export const useWebViewMessages = ({
clearToolCalls,
setPlanEntries,
handlePermissionRequest,
setIsAuthenticated,
};
});
@@ -216,6 +221,7 @@ export const useWebViewMessages = ({
}
break;
}
case 'loginSuccess': {
// Clear loading state and show a short assistant notice
handlers.messageHandling.clearWaitingForResponse();
@@ -224,12 +230,16 @@ export const useWebViewMessages = ({
content: 'Successfully logged in. You can continue chatting.',
timestamp: Date.now(),
});
// Set authentication state to true
handlers.setIsAuthenticated?.(true);
break;
}
case 'agentConnected': {
// Agent connected successfully; clear any pending spinner
handlers.messageHandling.clearWaitingForResponse();
// Set authentication state to true
handlers.setIsAuthenticated?.(true);
break;
}
@@ -245,6 +255,8 @@ export const useWebViewMessages = ({
content: `Failed to connect to Qwen agent: ${errorMsg}\nYou can still use the chat UI, but messages won't be sent to AI.`,
timestamp: Date.now(),
});
// Set authentication state to false
handlers.setIsAuthenticated?.(false);
break;
}
@@ -259,6 +271,20 @@ export const useWebViewMessages = ({
content: errorMsg,
timestamp: Date.now(),
});
// Set authentication state to false
handlers.setIsAuthenticated?.(false);
break;
}
case 'authState': {
const state = (
message?.data as { authenticated?: boolean | null } | undefined
)?.authenticated;
if (typeof state === 'boolean') {
handlers.setIsAuthenticated?.(state);
} else {
handlers.setIsAuthenticated?.(null);
}
break;
}
@@ -324,30 +350,42 @@ export const useWebViewMessages = ({
}
case 'streamEnd': {
// Always end local streaming state and collapse any thoughts
// Always end local streaming state and clear thinking state
handlers.messageHandling.endStreaming();
handlers.messageHandling.clearThinking();
// If the stream ended due to explicit user cancel, proactively
// clear the waiting indicator and reset any tracked exec calls.
// This avoids the UI being stuck with the Stop button visible
// after rejecting a permission request.
// If stream ended due to explicit user cancellation, proactively clear
// waiting indicator and reset tracked execution calls.
// This avoids UI getting stuck with Stop button visible after
// rejecting a permission request.
try {
const reason = (
(message.data as { reason?: string } | undefined)?.reason || ''
).toLowerCase();
if (reason === 'user_cancelled') {
/**
* Handle different types of stream end reasons:
* - 'user_cancelled': User explicitly cancelled operation
* - 'cancelled': General cancellation
* For these cases, immediately clear all active states
*/
if (reason === 'user_cancelled' || reason === 'cancelled') {
// Clear active execution tool call tracking, reset state
activeExecToolCallsRef.current.clear();
// Clear waiting response state to ensure UI returns to normal
handlers.messageHandling.clearWaitingForResponse();
break;
}
} catch (_error) {
// best-effort
// Best-effort handling, errors don't affect main flow
}
// Otherwise, clear the generic waiting indicator only if there are
// no active long-running tool calls. If there are still active
// execute/bash/command calls, keep the hint visible.
/**
* For other types of stream end (non-user cancellation):
* Only clear generic waiting indicator when there are no active
* long-running tool calls. If there are still active execute/bash/command
* calls, keep the hint visible.
*/
if (activeExecToolCallsRef.current.size === 0) {
handlers.messageHandling.clearWaitingForResponse();
}
@@ -548,15 +586,21 @@ export const useWebViewMessages = ({
// While long-running tools (e.g., execute/bash/command) are in progress,
// surface a lightweight loading indicator and expose the Stop button.
try {
const id = (toolCallData.toolCallId || '').toString();
const kind = (toolCallData.kind || '').toString().toLowerCase();
const isExec =
const isExecKind =
kind === 'execute' || kind === 'bash' || kind === 'command';
// CLI sometimes omits kind in tool_call_update payloads; fall back to
// whether we've already tracked this ID as an exec tool.
const wasTrackedExec = activeExecToolCallsRef.current.has(id);
const isExec = isExecKind || wasTrackedExec;
if (isExec) {
const id = (toolCallData.toolCallId || '').toString();
if (!isExec || !id) {
break;
}
// Maintain the active set by status
if (status === 'pending' || status === 'in_progress') {
if (status === 'pending' || status === 'in_progress') {
if (isExecKind) {
activeExecToolCallsRef.current.add(id);
// Build a helpful hint from rawInput
@@ -570,14 +614,14 @@ export const useWebViewMessages = ({
}
const hint = cmd ? `Running: ${cmd}` : 'Running command...';
handlers.messageHandling.setWaitingForResponse(hint);
} else if (status === 'completed' || status === 'failed') {
activeExecToolCallsRef.current.delete(id);
}
} else if (status === 'completed' || status === 'failed') {
activeExecToolCallsRef.current.delete(id);
}
// If no active exec tool remains, clear the waiting message.
if (activeExecToolCallsRef.current.size === 0) {
handlers.messageHandling.clearWaitingForResponse();
}
// If no active exec tool remains, clear the waiting message.
if (activeExecToolCallsRef.current.size === 0) {
handlers.messageHandling.clearWaitingForResponse();
}
} catch (_error) {
// Best-effort UI hint; ignore errors

View File

@@ -51,8 +51,7 @@
.composer-form:focus-within {
/* match existing highlight behavior */
border-color: var(--app-input-highlight);
box-shadow: 0 1px 2px
color-mix(in srgb, var(--app-input-highlight), transparent 80%);
box-shadow: 0 1px 2px color-mix(in srgb, var(--app-input-highlight), transparent 80%);
}
/* Composer: input editable area */
@@ -67,7 +66,7 @@
The data attribute is needed because some browsers insert a <br> in
contentEditable, which breaks :empty matching. */
.composer-input:empty:before,
.composer-input[data-empty='true']::before {
.composer-input[data-empty="true"]::before {
content: attr(data-placeholder);
color: var(--app-input-placeholder-foreground);
pointer-events: none;
@@ -81,7 +80,7 @@
outline: none;
}
.composer-input:disabled,
.composer-input[contenteditable='false'] {
.composer-input[contenteditable="false"] {
color: #999;
cursor: not-allowed;
}
@@ -111,7 +110,7 @@
}
.btn-text-compact > svg {
height: 1em;
width: 1em;
width: 1em;
flex-shrink: 0;
}
.btn-text-compact > span {

View File

@@ -88,6 +88,22 @@
z-index: 0;
}
/* Single-item AI sequence (both a start and an end): hide the connector entirely */
.qwen-message.message-item:not(.user-message-container):is(
:first-child,
.user-message-container
+ .qwen-message.message-item:not(.user-message-container),
.chat-messages
> :not(.qwen-message.message-item)
+ .qwen-message.message-item:not(.user-message-container)
):is(
:has(+ .user-message-container),
:has(+ :not(.qwen-message.message-item)),
:last-child
)::after {
display: none;
}
/* Handle the start of each AI message sequence - includes the first AI message in the entire message list and new AI messages interrupted by user messages */
.qwen-message.message-item:not(.user-message-container):first-child::after,
.user-message-container + .qwen-message.message-item:not(.user-message-container)::after,
@@ -123,4 +139,4 @@
position: relative;
padding-top: 8px;
padding-bottom: 8px;
}
}

View File

@@ -62,3 +62,38 @@ export const groupSessionsByDate = (
.filter(([, sessions]) => sessions.length > 0)
.map(([label, sessions]) => ({ label, sessions }));
};
/**
* Time ago formatter
*
* @param timestamp - ISO timestamp string
* @returns Formatted time string
*/
export const getTimeAgo = (timestamp: string): string => {
if (!timestamp) {
return '';
}
const now = new Date().getTime();
const then = new Date(timestamp).getTime();
const diffMs = now - then;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) {
return 'now';
}
if (diffMins < 60) {
return `${diffMins}m`;
}
if (diffHours < 24) {
return `${diffHours}h`;
}
if (diffDays === 1) {
return 'Yesterday';
}
if (diffDays < 7) {
return `${diffDays}d`;
}
return new Date(timestamp).toLocaleDateString();
};

View File

@@ -1,40 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Time ago formatter
*
* @param timestamp - ISO timestamp string
* @returns Formatted time string
*/
export const getTimeAgo = (timestamp: string): string => {
if (!timestamp) {
return '';
}
const now = new Date().getTime();
const then = new Date(timestamp).getTime();
const diffMs = now - then;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) {
return 'now';
}
if (diffMins < 60) {
return `${diffMins}m`;
}
if (diffHours < 24) {
return `${diffHours}h`;
}
if (diffDays === 1) {
return 'Yesterday';
}
if (diffDays < 7) {
return `${diffDays}d`;
}
return new Date(timestamp).toLocaleDateString();
};