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>
This commit is contained in:
yiliang114
2025-12-13 16:59:30 +08:00
31 changed files with 524 additions and 563 deletions

View File

@@ -38,16 +38,6 @@ export class CliDetector {
*
* @param forceRefresh - Whether to force refresh cached results, default is false
* @returns Promise<CliDetectionResult> - Detection result containing installation status and path
*
* @example
* ```typescript
* const result = await CliDetector.detectQwenCliLightweight();
* if (result.isInstalled) {
* console.log('CLI installed at:', result.cliPath);
* } else {
* console.log('CLI not found:', result.error);
* }
* ```
*/
static async detectQwenCliLightweight(
forceRefresh = false,

View File

@@ -11,14 +11,17 @@ import { MIN_CLI_VERSION_FOR_SESSION_METHODS } from './cliVersionManager.js';
import type { CliVersionInfo } from './cliVersionManager.js';
// Track which versions have already been warned about to avoid repetitive warnings
const warnedVersions = new Set<string>();
// Using a Map with timestamps to allow warnings to be shown again after a certain period
const warnedVersions = new Map<string, number>();
const WARNING_COOLDOWN_MS = 24 * 60 * 60 * 1000; // 24 hours cooldown
/**
* Check CLI version and show warning if below minimum requirement
* Provides an "Upgrade Now" option for unsupported versions
*
* @returns Version information
*/
export async function checkCliVersionAndWarn(): Promise<void> {
export async function checkCliVersionAndWarn(): Promise<CliVersionInfo> {
try {
const cliContextManager = CliContextManager.getInstance();
const versionInfo =
@@ -26,17 +29,53 @@ export async function checkCliVersionAndWarn(): Promise<void> {
cliContextManager.setCurrentVersionInfo(versionInfo);
if (!versionInfo.isSupported) {
// Only show warning if we haven't already warned about this specific version
// Only show warning if we haven't already warned about this specific version recently
const versionKey = versionInfo.version || 'unknown';
if (!warnedVersions.has(versionKey)) {
vscode.window.showWarningMessage(
const lastWarningTime = warnedVersions.get(versionKey);
const currentTime = Date.now();
// Show warning if we haven't warned about this version or if enough time has passed
if (
!lastWarningTime ||
currentTime - lastWarningTime > WARNING_COOLDOWN_MS
) {
// Wait to determine release version number
const selection = await 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.`,
'Upgrade Now',
);
warnedVersions.add(versionKey);
// Handle the user's selection
if (selection === 'Upgrade Now') {
// Open terminal and run npm install command
const terminal = vscode.window.createTerminal(
'Qwen Code CLI Upgrade',
);
terminal.show();
terminal.sendText('npm install -g @qwen-code/qwen-code@latest');
}
// Update the last warning time
warnedVersions.set(versionKey, currentTime);
}
}
return versionInfo;
} catch (error) {
console.error('[CliVersionChecker] Failed to check CLI version:', error);
// Return a default version info in case of error
return {
version: undefined,
isSupported: false,
features: {
supportsSessionList: false,
supportsSessionLoad: false,
},
detectionResult: {
isInstalled: false,
error: error instanceof Error ? error.message : String(error),
},
};
}
}

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

@@ -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 {
@@ -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 = () => {};
@@ -235,6 +238,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 {
@@ -168,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';

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 {
@@ -27,6 +28,7 @@ import { authMethod } from '../types/acpTypes.js';
import { MIN_CLI_VERSION_FOR_SESSION_METHODS } from '../cli/cliVersionManager.js';
import { processServerVersion } from '../cli/cliVersionChecker.js';
import { isAuthenticationRequiredError } from '../utils/authErrors.js';
import { handleAuthenticateUpdate } from '../utils/authNotificationHandler.js';
export type { ChatMessage, PlanEntry, ToolCallUpdateData };
@@ -144,6 +146,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 {
@@ -356,8 +372,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,
}),
);
@@ -472,8 +490,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;
@@ -911,80 +931,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
@@ -1172,16 +1118,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
*

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';

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,6 +23,7 @@ export interface AcpConnectionCallbacks {
onPermissionRequest: (data: AcpPermissionRequest) => Promise<{
optionId: string;
}>;
onAuthenticateUpdate: (data: AuthenticateUpdateNotification) => void;
onEndTurn: (reason?: string) => void;
}

View File

@@ -0,0 +1,40 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as vscode from 'vscode';
import type { AuthenticateUpdateNotification } from '../types/acpTypes.js';
/**
* Handle authentication update notifications by showing a VS Code notification
* with the authentication URI and a copy button.
*
* @param data - Authentication update notification data containing the auth URI
*/
export function handleAuthenticateUpdate(
data: AuthenticateUpdateNotification,
): void {
const authUri = data._meta.authUri;
// Show an information message with the auth URI and copy button
vscode.window
.showInformationMessage(
`Qwen Code needs authentication. Click the button below to open the authentication page or copy the link to your browser.`,
'Open in Browser',
'Copy Link',
)
.then((selection) => {
if (selection === 'Open in Browser') {
// Open the authentication URI in the default browser
vscode.env.openExternal(vscode.Uri.parse(authUri));
} else if (selection === 'Copy Link') {
// Copy the authentication URI to clipboard
vscode.env.clipboard.writeText(authUri);
vscode.window.showInformationMessage(
'Authentication link copied to clipboard!',
);
}
});
}

View File

@@ -44,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 = () => {
@@ -92,9 +92,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(
@@ -111,7 +115,6 @@ export const App: React.FC = () => {
);
if (query && query.length >= 1) {
fileContext.requestWorkspaceFiles(query);
const lowerQuery = query.toLowerCase();
return allItems.filter(
(item) =>
@@ -156,17 +159,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({

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

@@ -14,7 +14,7 @@ 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';
/**

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

@@ -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,
@@ -243,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

View File

@@ -17,42 +17,30 @@ export const Onboarding: React.FC<OnboardingPageProps> = ({ onLogin }) => {
<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">
<div className="flex flex-col items-center gap-6">
{/* Application icon container with brand logo and decorative close icon */}
{/* Application icon container */}
<div className="relative">
<img
src={iconUri}
alt="Qwen Code Logo"
className="w-[80px] h-[80px] object-contain"
/>
{/* Decorative close icon for enhanced visual effect */}
<div className="absolute -top-2 -right-2 w-6 h-6 bg-[#4f46e5] rounded-full flex items-center justify-center">
<svg width="12" height="12" viewBox="0 0 12 12" fill="white">
<path
d="M2.5 1.5L9.5 8.5M9.5 1.5L2.5 8.5"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</div>
</div>
{/* Text content area */}
<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">
Qwen Code helps you understand, navigate, and transform your
codebase with AI assistance.
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"
className="w-full px-4 py-3 bg-[#4f46e5] text-white font-medium rounded-lg shadow-sm hover:bg-[#4338ca] transition-colors duration-200"
>
Log in to Qwen Code
Get Started with Qwen Code
</button>
</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

@@ -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,15 +56,24 @@ export const useFileContext = (vscode: VSCodeAPI) => {
searchTimerRef.current = setTimeout(() => {
vscode.postMessage({
type: 'getWorkspaceFiles',
data: { query },
data: { query: normalizedQuery },
});
}, 300);
lastQueryRef.current = normalizedQuery;
} else {
// 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: query ? { query } : {},
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

@@ -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 {

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();
};