Merge pull request #1248 from QwenLM/feat/vscode-ide-companion-borading

refactor(vscode-ide-companion): optimize CLI detection and version management
This commit is contained in:
tanzhenxin
2025-12-13 23:37:06 +08:00
committed by GitHub
23 changed files with 713 additions and 203 deletions

View File

@@ -292,7 +292,7 @@ class GeminiAgent {
private async ensureAuthenticated(config: Config): Promise<void> { private async ensureAuthenticated(config: Config): Promise<void> {
const selectedType = this.settings.merged.security?.auth?.selectedType; const selectedType = this.settings.merged.security?.auth?.selectedType;
if (!selectedType) { if (!selectedType) {
throw acp.RequestError.authRequired(); throw acp.RequestError.authRequired('No Selected Type');
} }
try { try {
@@ -300,7 +300,9 @@ class GeminiAgent {
await config.refreshAuth(selectedType, true); await config.refreshAuth(selectedType, true);
} catch (e) { } catch (e) {
console.error(`Authentication failed: ${e}`); console.error(`Authentication failed: ${e}`);
throw acp.RequestError.authRequired(); throw acp.RequestError.authRequired(
'Authentication failed: ' + (e as Error).message,
);
} }
} }

View File

@@ -677,6 +677,19 @@ async function authWithQwenDeviceFlow(
// Cache the new tokens // Cache the new tokens
await cacheQwenCredentials(credentials); 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 // Emit auth progress success event
qwenOAuth2Events.emit( qwenOAuth2Events.emit(
QwenOAuth2Event.AuthProgress, QwenOAuth2Event.AuthProgress,
@@ -880,6 +893,14 @@ export async function clearQwenCredentials(): Promise<void> {
} }
// Log other errors but don't throw - clearing credentials should be non-critical // Log other errors but don't throw - clearing credentials should be non-critical
console.warn('Warning: Failed to clear cached Qwen credentials:', error); 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

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

View File

@@ -10,6 +10,7 @@ import type {
AcpPermissionRequest, AcpPermissionRequest,
AcpResponse, AcpResponse,
AcpSessionUpdate, AcpSessionUpdate,
AuthenticateUpdateNotification,
} from '../types/acpTypes.js'; } from '../types/acpTypes.js';
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
import type { ChildProcess, SpawnOptions } from 'child_process'; import type { ChildProcess, SpawnOptions } from 'child_process';
@@ -42,6 +43,8 @@ export class AcpConnection {
onPermissionRequest: (data: AcpPermissionRequest) => Promise<{ onPermissionRequest: (data: AcpPermissionRequest) => Promise<{
optionId: string; optionId: string;
}> = () => Promise.resolve({ optionId: 'allow' }); }> = () => Promise.resolve({ optionId: 'allow' });
onAuthenticateUpdate: (data: AuthenticateUpdateNotification) => void =
() => {};
onEndTurn: () => void = () => {}; onEndTurn: () => void = () => {};
// Called after successful initialize() with the initialize result // Called after successful initialize() with the initialize result
onInitialized: (init: unknown) => void = () => {}; onInitialized: (init: unknown) => void = () => {};
@@ -207,6 +210,7 @@ export class AcpConnection {
const callbacks: AcpConnectionCallbacks = { const callbacks: AcpConnectionCallbacks = {
onSessionUpdate: this.onSessionUpdate, onSessionUpdate: this.onSessionUpdate,
onPermissionRequest: this.onPermissionRequest, onPermissionRequest: this.onPermissionRequest,
onAuthenticateUpdate: this.onAuthenticateUpdate,
onEndTurn: this.onEndTurn, onEndTurn: this.onEndTurn,
}; };

View File

@@ -17,6 +17,7 @@ import type {
AcpResponse, AcpResponse,
AcpSessionUpdate, AcpSessionUpdate,
AcpPermissionRequest, AcpPermissionRequest,
AuthenticateUpdateNotification,
} from '../types/acpTypes.js'; } from '../types/acpTypes.js';
import { CLIENT_METHODS } from '../constants/acpSchema.js'; import { CLIENT_METHODS } from '../constants/acpSchema.js';
import type { import type {
@@ -110,14 +111,21 @@ export class AcpMessageHandler {
// JSON.stringify(message.result).substring(0, 200), // JSON.stringify(message.result).substring(0, 200),
message.result, message.result,
); );
if (
message.result && if (message.result && typeof message.result === 'object') {
typeof message.result === 'object' && const stopReasonValue =
'stopReason' in message.result && (message.result as { stopReason?: unknown }).stopReason ??
message.result.stopReason === 'end_turn' (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(); callbacks.onEndTurn();
} }
}
resolve(message.result); resolve(message.result);
} else if ('error' in message) { } else if ('error' in message) {
const errorCode = message.error?.code || 'unknown'; const errorCode = message.error?.code || 'unknown';
@@ -161,6 +169,15 @@ export class AcpMessageHandler {
); );
callbacks.onSessionUpdate(params as AcpSessionUpdate); callbacks.onSessionUpdate(params as AcpSessionUpdate);
break; 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: case CLIENT_METHODS.session_request_permission:
result = await this.handlePermissionRequest( result = await this.handlePermissionRequest(
params as AcpPermissionRequest, params as AcpPermissionRequest,

View File

@@ -54,8 +54,14 @@ export class AcpSessionManager {
}; };
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const timeoutDuration = // different timeout durations based on methods
method === AGENT_METHODS.session_prompt ? 120000 : 60000; 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(() => { const timeoutId = setTimeout(() => {
pendingRequests.delete(id); pendingRequests.delete(id);
@@ -163,7 +169,7 @@ export class AcpSessionManager {
pendingRequests, pendingRequests,
nextRequestId, nextRequestId,
); );
console.log('[ACP] Authenticate successful'); console.log('[ACP] Authenticate successful', response);
return response; return response;
} }

View File

@@ -7,6 +7,7 @@ import { AcpConnection } from './acpConnection.js';
import type { import type {
AcpSessionUpdate, AcpSessionUpdate,
AcpPermissionRequest, AcpPermissionRequest,
AuthenticateUpdateNotification,
} from '../types/acpTypes.js'; } from '../types/acpTypes.js';
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
import { QwenSessionReader, type QwenSession } from './qwenSessionReader.js'; import { QwenSessionReader, type QwenSession } from './qwenSessionReader.js';
@@ -17,9 +18,14 @@ import type {
ToolCallUpdateData, ToolCallUpdateData,
QwenAgentCallbacks, QwenAgentCallbacks,
} from '../types/chatTypes.js'; } from '../types/chatTypes.js';
import { QwenConnectionHandler } from '../services/qwenConnectionHandler.js'; import {
QwenConnectionHandler,
type QwenConnectionResult,
} from '../services/qwenConnectionHandler.js';
import { QwenSessionUpdateHandler } from './qwenSessionUpdateHandler.js'; import { QwenSessionUpdateHandler } from './qwenSessionUpdateHandler.js';
import { authMethod } from '../types/acpTypes.js'; import { authMethod } from '../types/acpTypes.js';
import { isAuthenticationRequiredError } from '../utils/authErrors.js';
import { handleAuthenticateUpdate } from '../utils/authNotificationHandler.js';
export type { ChatMessage, PlanEntry, ToolCallUpdateData }; export type { ChatMessage, PlanEntry, ToolCallUpdateData };
@@ -28,6 +34,13 @@ export type { ChatMessage, PlanEntry, ToolCallUpdateData };
* *
* Coordinates various modules and provides unified interface * Coordinates various modules and provides unified interface
*/ */
interface AgentConnectOptions {
autoAuthenticate?: boolean;
}
interface AgentSessionOptions {
autoAuthenticate?: boolean;
}
export class QwenAgentManager { export class QwenAgentManager {
private connection: AcpConnection; private connection: AcpConnection;
private sessionReader: QwenSessionReader; private sessionReader: QwenSessionReader;
@@ -117,10 +130,10 @@ export class QwenAgentManager {
return { optionId: 'allow_once' }; return { optionId: 'allow_once' };
}; };
this.connection.onEndTurn = () => { this.connection.onEndTurn = (reason?: string) => {
try { try {
if (this.callbacks.onEndTurn) { if (this.callbacks.onEndTurn) {
this.callbacks.onEndTurn(); this.callbacks.onEndTurn(reason);
} else if (this.callbacks.onStreamChunk) { } else if (this.callbacks.onStreamChunk) {
// Fallback: send a zero-length chunk then rely on streamEnd elsewhere // Fallback: send a zero-length chunk then rely on streamEnd elsewhere
this.callbacks.onStreamChunk(''); this.callbacks.onStreamChunk('');
@@ -130,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 // Initialize callback to surface available modes and current mode to UI
this.connection.onInitialized = (init: unknown) => { this.connection.onInitialized = (init: unknown) => {
try { try {
@@ -162,13 +189,17 @@ export class QwenAgentManager {
* @param workingDir - Working directory * @param workingDir - Working directory
* @param cliEntryPath - Path to bundled CLI entrypoint (cli.js) * @param cliEntryPath - Path to bundled CLI entrypoint (cli.js)
*/ */
async connect(workingDir: string, cliEntryPath: string): Promise<void> { async connect(
workingDir: string,
cliEntryPath: string,
options?: AgentConnectOptions,
): Promise<QwenConnectionResult> {
this.currentWorkingDir = workingDir; this.currentWorkingDir = workingDir;
await this.connectionHandler.connect( return this.connectionHandler.connect(
this.connection, this.connection,
this.sessionReader,
workingDir, workingDir,
cliEntryPath, cliEntryPath,
options,
); );
} }
@@ -250,9 +281,10 @@ export class QwenAgentManager {
'[QwenAgentManager] Getting session list with version-aware strategy', '[QwenAgentManager] Getting session list with version-aware strategy',
); );
// Prefer ACP method first; fall back to file system if it fails for any reason.
try { try {
console.log('[QwenAgentManager] Attempting to get session list via ACP'); console.log(
'[QwenAgentManager] Attempting to get session list via ACP method',
);
const response = await this.connection.listSessions(); const response = await this.connection.listSessions();
console.log('[QwenAgentManager] ACP session list response:', response); console.log('[QwenAgentManager] ACP session list response:', response);
@@ -262,19 +294,21 @@ export class QwenAgentManager {
const res: unknown = response; const res: unknown = response;
let items: Array<Record<string, unknown>> = []; let items: Array<Record<string, unknown>> = [];
if (Array.isArray(res)) { // Note: AcpSessionManager resolves `sendRequest` with the JSON-RPC
items = res as Array<Record<string, unknown>>; // "result" directly (not the full AcpResponse). Treat it as unknown
} else if (res && typeof res === 'object' && 'items' in res) { // 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; const itemsValue = (res as { items?: unknown }).items;
items = Array.isArray(itemsValue) items = Array.isArray(itemsValue)
? (itemsValue as Array<Record<string, unknown>>) ? (itemsValue as Array<Record<string, unknown>>)
: []; : [];
} }
console.log('[QwenAgentManager] Sessions retrieved via ACP:', { console.log(
count: items.length, '[QwenAgentManager] Sessions retrieved via ACP:',
}); res,
items.length,
);
if (items.length > 0) { if (items.length > 0) {
const sessions = items.map((item) => ({ const sessions = items.map((item) => ({
id: item.sessionId || item.id, id: item.sessionId || item.id,
@@ -288,6 +322,11 @@ export class QwenAgentManager {
filePath: item.filePath, filePath: item.filePath,
cwd: item.cwd, cwd: item.cwd,
})); }));
console.log(
'[QwenAgentManager] Sessions retrieved via ACP:',
sessions.length,
);
return sessions; return sessions;
} }
} catch (error) { } catch (error) {
@@ -350,6 +389,7 @@ export class QwenAgentManager {
}> { }> {
const size = params?.size ?? 20; const size = params?.size ?? 20;
const cursor = params?.cursor; const cursor = params?.cursor;
try { try {
const response = await this.connection.listSessions({ const response = await this.connection.listSessions({
size, size,
@@ -444,7 +484,6 @@ export class QwenAgentManager {
*/ */
async getSessionMessages(sessionId: string): Promise<ChatMessage[]> { async getSessionMessages(sessionId: string): Promise<ChatMessage[]> {
try { try {
// Prefer reading CLI's JSONL if we can find filePath from session/list
try { try {
const list = await this.getSessionList(); const list = await this.getSessionList();
const item = list.find( const item = list.find(
@@ -664,7 +703,9 @@ export class QwenAgentManager {
const planText = planEntries const planText = planEntries
.map( .map(
(entry: Record<string, unknown>, index: number) => (entry: Record<string, unknown>, index: number) =>
`${index + 1}. ${entry.description || entry.title || 'Unnamed step'}`, `${index + 1}. ${
entry.description || entry.title || 'Unnamed step'
}`,
) )
.join('\n'); .join('\n');
msgs.push({ msgs.push({
@@ -943,13 +984,15 @@ export class QwenAgentManager {
sessionId, sessionId,
); );
// Prefer ACP session/load first; fall back to file system on failure.
try { try {
console.log('[QwenAgentManager] Attempting to load session via ACP'); console.log(
'[QwenAgentManager] Attempting to load session via ACP method',
);
await this.loadSessionViaAcp(sessionId); await this.loadSessionViaAcp(sessionId);
console.log('[QwenAgentManager] Session loaded successfully via ACP'); 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. // 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) { } catch (error) {
console.warn( console.warn(
'[QwenAgentManager] ACP session load failed, falling back to file system method:', '[QwenAgentManager] ACP session load failed, falling back to file system method:',
@@ -1030,7 +1073,11 @@ export class QwenAgentManager {
* @param workingDir - Working directory * @param workingDir - Working directory
* @returns Newly created session ID * @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 // Reuse existing session if present
if (this.connection.currentSessionId) { if (this.connection.currentSessionId) {
return this.connection.currentSessionId; return this.connection.currentSessionId;
@@ -1048,18 +1095,24 @@ export class QwenAgentManager {
try { try {
await this.connection.newSession(workingDir); await this.connection.newSession(workingDir);
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err); const requiresAuth = isAuthenticationRequiredError(err);
const requiresAuth =
msg.includes('Authentication required') ||
msg.includes('(code: -32000)');
if (requiresAuth) { 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( console.warn(
'[QwenAgentManager] session/new requires authentication. Retrying with authenticate...', '[QwenAgentManager] session/new requires authentication. Retrying with authenticate...',
); );
try { try {
// Let CLI handle authentication - it's the single source of truth // Let CLI handle authentication - it's the single source of truth
await this.connection.authenticate(authMethod); await this.connection.authenticate(authMethod);
console.log(
'[QwenAgentManager] createNewSession Authentication successful. Retrying session/new...',
);
// Add a slight delay to ensure auth state is settled // Add a slight delay to ensure auth state is settled
await new Promise((resolve) => setTimeout(resolve, 300)); await new Promise((resolve) => setTimeout(resolve, 300));
await this.connection.newSession(workingDir); await this.connection.newSession(workingDir);
@@ -1170,9 +1223,9 @@ export class QwenAgentManager {
/** /**
* Register end-of-turn callback * 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.callbacks.onEndTurn = callback;
this.sessionUpdateHandler.updateCallbacks(this.callbacks); this.sessionUpdateHandler.updateCallbacks(this.callbacks);
} }

View File

@@ -11,9 +11,14 @@
*/ */
import type { AcpConnection } from './acpConnection.js'; import type { AcpConnection } from './acpConnection.js';
import type { QwenSessionReader } from '../services/qwenSessionReader.js'; import { isAuthenticationRequiredError } from '../utils/authErrors.js';
import { authMethod } from '../types/acpTypes.js'; import { authMethod } from '../types/acpTypes.js';
export interface QwenConnectionResult {
sessionCreated: boolean;
requiresAuth: boolean;
}
/** /**
* Qwen Connection Handler class * Qwen Connection Handler class
* Handles connection, authentication, and session initialization * Handles connection, authentication, and session initialization
@@ -23,23 +28,27 @@ export class QwenConnectionHandler {
* Connect to Qwen service and establish session * Connect to Qwen service and establish session
* *
* @param connection - ACP connection instance * @param connection - ACP connection instance
* @param sessionReader - Session reader instance
* @param workingDir - Working directory * @param workingDir - Working directory
* @param cliEntryPath - Path to bundled CLI entrypoint (cli.js) * @param cliPath - CLI path (optional, if provided will override the path in configuration)
*/ */
async connect( async connect(
connection: AcpConnection, connection: AcpConnection,
sessionReader: QwenSessionReader,
workingDir: string, workingDir: string,
cliEntryPath: string, cliEntryPath: string,
): Promise<void> { options?: {
autoAuthenticate?: boolean;
},
): Promise<QwenConnectionResult> {
const connectId = Date.now(); const connectId = Date.now();
console.log(`[QwenAgentManager] 🚀 CONNECT() CALLED - ID: ${connectId}`); console.log(`[QwenAgentManager] 🚀 CONNECT() CALLED - ID: ${connectId}`);
const autoAuthenticate = options?.autoAuthenticate ?? true;
let sessionCreated = false;
let requiresAuth = false;
// Build extra CLI arguments (only essential parameters) // Build extra CLI arguments (only essential parameters)
const extraArgs: string[] = []; const extraArgs: string[] = [];
await connection.connect(cliEntryPath, workingDir, extraArgs); await connection.connect(cliEntryPath!, workingDir, extraArgs);
// Try to restore existing session or create new session // Try to restore existing session or create new session
// Note: Auto-restore on connect is disabled to avoid surprising loads // Note: Auto-restore on connect is disabled to avoid surprising loads
@@ -57,18 +66,40 @@ export class QwenConnectionHandler {
console.log( console.log(
'[QwenAgentManager] Creating new session (letting CLI handle authentication)...', '[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'); console.log('[QwenAgentManager] New session created successfully');
sessionCreated = true;
} catch (sessionError) { } catch (sessionError) {
console.log(`\n⚠ [SESSION FAILED] newSessionWithRetry threw error\n`); 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); console.log(`[QwenAgentManager] Error details:`, sessionError);
throw sessionError; throw sessionError;
} }
} }
} else {
sessionCreated = true;
}
console.log(`\n========================================`); console.log(`\n========================================`);
console.log(`[QwenAgentManager] ✅ CONNECT() COMPLETED SUCCESSFULLY`); console.log(`[QwenAgentManager] ✅ CONNECT() COMPLETED SUCCESSFULLY`);
console.log(`========================================\n`); console.log(`========================================\n`);
return { sessionCreated, requiresAuth };
} }
/** /**
@@ -83,6 +114,7 @@ export class QwenConnectionHandler {
workingDir: string, workingDir: string,
maxRetries: number, maxRetries: number,
authMethod: string, authMethod: string,
autoAuthenticate: boolean,
): Promise<void> { ): Promise<void> {
for (let attempt = 1; attempt <= maxRetries; attempt++) { for (let attempt = 1; attempt <= maxRetries; attempt++) {
try { try {
@@ -102,10 +134,14 @@ export class QwenConnectionHandler {
// If Qwen reports that authentication is required, try to // If Qwen reports that authentication is required, try to
// authenticate on-the-fly once and retry without waiting. // authenticate on-the-fly once and retry without waiting.
const requiresAuth = const requiresAuth = isAuthenticationRequiredError(error);
errorMessage.includes('Authentication required') ||
errorMessage.includes('(code: -32000)');
if (requiresAuth) { if (requiresAuth) {
if (!autoAuthenticate) {
console.log(
'[QwenAgentManager] Authentication required but auto-authentication is disabled. Propagating error.',
);
throw error;
}
console.log( console.log(
'[QwenAgentManager] Qwen requires authentication. Authenticating and retrying session/new...', '[QwenAgentManager] Qwen requires authentication. Authenticating and retrying session/new...',
); );
@@ -115,6 +151,9 @@ export class QwenConnectionHandler {
// newSession may cause the cli authorization jump to be triggered again // newSession may cause the cli authorization jump to be triggered again
// Add a slight delay to ensure auth state is settled // Add a slight delay to ensure auth state is settled
await new Promise((resolve) => setTimeout(resolve, 300)); await new Promise((resolve) => setTimeout(resolve, 300));
console.log(
'[QwenAgentManager] newSessionWithRetry Authentication successful',
);
// Retry immediately after successful auth // Retry immediately after successful auth
await connection.newSession(workingDir); await connection.newSession(workingDir);
console.log( console.log(

View File

@@ -166,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 = export type AcpSessionUpdate =
| UserMessageChunkUpdate | UserMessageChunkUpdate
| AgentMessageChunkUpdate | AgentMessageChunkUpdate

View File

@@ -35,7 +35,7 @@ export interface QwenAgentCallbacks {
onToolCall?: (update: ToolCallUpdateData) => void; onToolCall?: (update: ToolCallUpdateData) => void;
onPlan?: (entries: PlanEntry[]) => void; onPlan?: (entries: PlanEntry[]) => void;
onPermissionRequest?: (request: AcpPermissionRequest) => Promise<string>; onPermissionRequest?: (request: AcpPermissionRequest) => Promise<string>;
onEndTurn?: () => void; onEndTurn?: (reason?: string) => void;
onModeInfo?: (info: { onModeInfo?: (info: {
currentModeId?: ApprovalModeValue; currentModeId?: ApprovalModeValue;
availableModes?: Array<{ availableModes?: Array<{

View File

@@ -5,7 +5,11 @@
*/ */
import type { ChildProcess } from 'child_process'; 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> { export interface PendingRequest<T = unknown> {
resolve: (value: T) => void; resolve: (value: T) => void;
@@ -19,7 +23,8 @@ export interface AcpConnectionCallbacks {
onPermissionRequest: (data: AcpPermissionRequest) => Promise<{ onPermissionRequest: (data: AcpPermissionRequest) => Promise<{
optionId: string; optionId: string;
}>; }>;
onEndTurn: () => void; onAuthenticateUpdate: (data: AuthenticateUpdateNotification) => void;
onEndTurn: (reason?: string) => void;
} }
export interface AcpConnectionState { 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 { ToolCall } from './components/messages/toolcalls/ToolCall.js';
import { hasToolCallOutput } from './components/messages/toolcalls/shared/utils.js'; import { hasToolCallOutput } from './components/messages/toolcalls/shared/utils.js';
import { EmptyState } from './components/layout/EmptyState.js'; import { EmptyState } from './components/layout/EmptyState.js';
import { Onboarding } from './components/layout/Onboarding.js';
import { type CompletionItem } from '../types/completionItemTypes.js'; import { type CompletionItem } from '../types/completionItemTypes.js';
import { useCompletionTrigger } from './hooks/useCompletionTrigger.js'; import { useCompletionTrigger } from './hooks/useCompletionTrigger.js';
import { ChatHeader } from './components/layout/ChatHeader.js'; import { ChatHeader } from './components/layout/ChatHeader.js';
@@ -67,6 +68,8 @@ export const App: React.FC = () => {
toolCall: PermissionToolCall; toolCall: PermissionToolCall;
} | null>(null); } | null>(null);
const [planEntries, setPlanEntries] = useState<PlanEntry[]>([]); 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>( const messagesEndRef = useRef<HTMLDivElement>(
null, null,
) as React.RefObject<HTMLDivElement>; ) as React.RefObject<HTMLDivElement>;
@@ -201,6 +204,7 @@ export const App: React.FC = () => {
vscode, vscode,
inputFieldRef, inputFieldRef,
isStreaming: messageHandling.isStreaming, isStreaming: messageHandling.isStreaming,
isWaitingForResponse: messageHandling.isWaitingForResponse,
}); });
// Handle cancel/stop from the input bar // Handle cancel/stop from the input bar
@@ -243,6 +247,7 @@ export const App: React.FC = () => {
inputFieldRef, inputFieldRef,
setInputText, setInputText,
setEditMode, setEditMode,
setIsAuthenticated,
}); });
// Auto-scroll handling: keep the view pinned to bottom when new content arrives, // Auto-scroll handling: keep the view pinned to bottom when new content arrives,
@@ -356,6 +361,14 @@ export const App: React.FC = () => {
completedToolCalls, 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 // Handle permission response
const handlePermissionResponse = useCallback( const handlePermissionResponse = useCallback(
(optionId: string) => { (optionId: string) => {
@@ -662,7 +675,19 @@ export const App: React.FC = () => {
allMessages.length > 0; allMessages.length > 0;
return ( 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 <SessionSelector
visible={sessionManagement.showSessionSelector} visible={sessionManagement.showSessionSelector}
sessions={sessionManagement.filteredSessions} sessions={sessionManagement.filteredSessions}
@@ -687,31 +712,43 @@ export const App: React.FC = () => {
<div <div
ref={messagesContainerRef} 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 ? ( {!hasContent && !isLoading ? (
<EmptyState /> 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 */} {/* Render all messages and tool calls */}
{renderMessages()} {renderMessages()}
{/* Flow-in persistent slot: keeps a small constant height so toggling */}
{/* the waiting message doesn't change list height to zero. When */} {/* Waiting message positioned fixed above the input form to avoid layout shifts */}
{/* active, render the waiting message inline (not fixed). */}
<div className="waiting-message-slot min-h-[28px]">
{messageHandling.isWaitingForResponse && {messageHandling.isWaitingForResponse &&
messageHandling.loadingMessage && ( messageHandling.loadingMessage && (
<div className="waiting-message-slot min-h-[28px]">
<WaitingMessage <WaitingMessage
loadingMessage={messageHandling.loadingMessage} loadingMessage={messageHandling.loadingMessage}
/> />
)}
</div> </div>
)}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</> </>
)} )}
</div> </div>
{isAuthenticated && (
<InputForm <InputForm
inputText={inputText} inputText={inputText}
inputFieldRef={inputFieldRef} inputFieldRef={inputFieldRef}
@@ -758,7 +795,8 @@ export const App: React.FC = () => {
} }
} catch (error) { } catch (error) {
console.error('[App] Error getting cursor position:', error); console.error('[App] Error getting cursor position:', error);
const inputRect = inputFieldRef.current.getBoundingClientRect(); const inputRect =
inputFieldRef.current.getBoundingClientRect();
position = { top: inputRect.top, left: inputRect.left }; position = { top: inputRect.top, left: inputRect.left };
} }
} else { } else {
@@ -775,8 +813,9 @@ export const App: React.FC = () => {
onCompletionSelect={handleCompletionSelect} onCompletionSelect={handleCompletionSelect}
onCompletionClose={completion.closeCompletion} onCompletionClose={completion.closeCompletion}
/> />
)}
{permissionRequest && ( {isAuthenticated && permissionRequest && (
<PermissionDrawer <PermissionDrawer
isOpen={!!permissionRequest} isOpen={!!permissionRequest}
options={permissionRequest.options} options={permissionRequest.options}

View File

@@ -13,6 +13,7 @@ import { MessageHandler } from '../webview/MessageHandler.js';
import { WebViewContent } from '../webview/WebViewContent.js'; import { WebViewContent } from '../webview/WebViewContent.js';
import { getFileName } from './utils/webviewUtils.js'; import { getFileName } from './utils/webviewUtils.js';
import { type ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import { type ApprovalModeValue } from '../types/approvalModeValueTypes.js';
import { isAuthenticationRequiredError } from '../utils/authErrors.js';
export class WebViewProvider { export class WebViewProvider {
private panelManager: PanelManager; private panelManager: PanelManager;
@@ -30,7 +31,7 @@ export class WebViewProvider {
private currentModeId: ApprovalModeValue | null = null; private currentModeId: ApprovalModeValue | null = null;
constructor( constructor(
context: vscode.ExtensionContext, private context: vscode.ExtensionContext,
private extensionUri: vscode.Uri, private extensionUri: vscode.Uri,
) { ) {
this.agentManager = new QwenAgentManager(); this.agentManager = new QwenAgentManager();
@@ -117,12 +118,15 @@ export class WebViewProvider {
}); });
}); });
// Setup end-turn handler from ACP stopReason=end_turn // Setup end-turn handler from ACP stopReason notifications
this.agentManager.onEndTurn(() => { this.agentManager.onEndTurn((reason) => {
// Ensure WebView exits streaming state even if no explicit streamEnd was emitted elsewhere // Ensure WebView exits streaming state even if no explicit streamEnd was emitted elsewhere
this.sendMessageToWebView({ this.sendMessageToWebView({
type: 'streamEnd', type: 'streamEnd',
data: { timestamp: Date.now(), reason: 'end_turn' }, data: {
timestamp: Date.now(),
reason: reason || 'end_turn',
},
}); });
}); });
@@ -517,11 +521,9 @@ export class WebViewProvider {
*/ */
private async attemptAuthStateRestoration(): Promise<void> { private async attemptAuthStateRestoration(): Promise<void> {
try { try {
console.log( console.log('[WebViewProvider] Attempting connection...');
'[WebViewProvider] Attempting connection (CLI handle authentication)...', // Attempt a connection to detect prior auth without forcing login
); await this.initializeAgentConnection({ autoAuthenticate: false });
//always attempt connection and let CLI handle authentication
await this.initializeAgentConnection();
} catch (error) { } catch (error) {
console.error( console.error(
'[WebViewProvider] Error in attemptAuthStateRestoration:', '[WebViewProvider] Error in attemptAuthStateRestoration:',
@@ -535,14 +537,19 @@ export class WebViewProvider {
* Initialize agent connection and session * Initialize agent connection and session
* Can be called from show() or via /login command * Can be called from show() or via /login command
*/ */
async initializeAgentConnection(): Promise<void> { async initializeAgentConnection(options?: {
return this.doInitializeAgentConnection(); autoAuthenticate?: boolean;
}): Promise<void> {
return this.doInitializeAgentConnection(options);
} }
/** /**
* Internal: perform actual connection/initialization (no auth locking). * 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 run = async () => {
const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
@@ -551,7 +558,9 @@ export class WebViewProvider {
'[WebViewProvider] Starting initialization, workingDir:', '[WebViewProvider] Starting initialization, workingDir:',
workingDir, workingDir,
); );
console.log('[WebViewProvider] Using CLI-managed authentication'); console.log(
`[WebViewProvider] Using CLI-managed authentication (autoAuth=${autoAuthenticate})`,
);
const bundledCliEntry = vscode.Uri.joinPath( const bundledCliEntry = vscode.Uri.joinPath(
this.extensionUri, this.extensionUri,
@@ -561,25 +570,57 @@ export class WebViewProvider {
).fsPath; ).fsPath;
try { try {
console.log('[WebViewProvider] Connecting to bundled agent...'); console.log('[WebViewProvider] Connecting to agent...');
console.log('[WebViewProvider] Bundled CLI entry:', bundledCliEntry);
await this.agentManager.connect(workingDir, bundledCliEntry); // 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'); console.log('[WebViewProvider] Agent connected successfully');
this.agentInitialized = true; this.agentInitialized = true;
// Load messages from the current Qwen session // If authentication is required and autoAuthenticate is false,
await this.loadCurrentSessionMessages(); // 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;
}
if (connectResult.requiresAuth) {
this.sendMessageToWebView({
type: 'authState',
data: { authenticated: false },
});
}
// Load messages from the current Qwen session
const sessionReady = await this.loadCurrentSessionMessages(options);
if (sessionReady) {
// Notify webview that agent is connected // Notify webview that agent is connected
this.sendMessageToWebView({ this.sendMessageToWebView({
type: 'agentConnected', type: 'agentConnected',
data: {}, data: {},
}); });
} else {
console.log(
'[WebViewProvider] Session creation deferred until user logs in.',
);
}
} catch (_error) { } catch (_error) {
console.error('[WebViewProvider] Agent connection error:', _error); console.error('[WebViewProvider] Agent connection error:', _error);
vscode.window.showWarningMessage( vscode.window.showWarningMessage(
`Failed to start bundled Qwen Code CLI: ${_error}\nYou can still use the chat UI, but messages won't be sent to AI.`, `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 // Fallback to empty conversation
await this.initializeEmptyConversation(); await this.initializeEmptyConversation();
@@ -607,7 +648,6 @@ export class WebViewProvider {
return vscode.window.withProgress( return vscode.window.withProgress(
{ {
location: vscode.ProgressLocation.Notification, location: vscode.ProgressLocation.Notification,
title: 'Logging in to Qwen Code... ',
cancellable: false, cancellable: false,
}, },
async (progress) => { async (progress) => {
@@ -633,7 +673,7 @@ export class WebViewProvider {
}); });
// Reinitialize connection (will trigger fresh authentication) // Reinitialize connection (will trigger fresh authentication)
await this.doInitializeAgentConnection(); await this.doInitializeAgentConnection({ autoAuthenticate: true });
console.log( console.log(
'[WebViewProvider] Force re-login completed successfully', '[WebViewProvider] Force re-login completed successfully',
); );
@@ -716,7 +756,11 @@ export class WebViewProvider {
* Load messages from current Qwen session * Load messages from current Qwen session
* Skips session restoration and creates a new session directly * 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 { try {
console.log( console.log(
'[WebViewProvider] Initializing with new session (skipping restoration)', '[WebViewProvider] Initializing with new session (skipping restoration)',
@@ -727,10 +771,32 @@ export class WebViewProvider {
// avoid creating another session if connect() already created one. // avoid creating another session if connect() already created one.
if (!this.agentManager.currentSessionId) { if (!this.agentManager.currentSessionId) {
if (!autoAuthenticate) {
console.log(
'[WebViewProvider] Skipping ACP session creation until user logs in.',
);
this.sendMessageToWebView({
type: 'authState',
data: { authenticated: false },
});
} else {
try { try {
await this.agentManager.createNewSession(workingDir); await this.agentManager.createNewSession(workingDir, {
autoAuthenticate,
});
console.log('[WebViewProvider] ACP session created successfully'); console.log('[WebViewProvider] ACP session created successfully');
sessionReady = true;
} catch (sessionError) { } 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( console.error(
'[WebViewProvider] Failed to create ACP session:', '[WebViewProvider] Failed to create ACP session:',
sessionError, sessionError,
@@ -739,10 +805,13 @@ export class WebViewProvider {
`Failed to create ACP session: ${sessionError}. You may need to authenticate first.`, `Failed to create ACP session: ${sessionError}. You may need to authenticate first.`,
); );
} }
}
}
} else { } else {
console.log( console.log(
'[WebViewProvider] Existing ACP session detected, skipping new session creation', '[WebViewProvider] Existing ACP session detected, skipping new session creation',
); );
sessionReady = true;
} }
await this.initializeEmptyConversation(); await this.initializeEmptyConversation();
@@ -755,7 +824,10 @@ export class WebViewProvider {
`Failed to load session messages: ${_error}`, `Failed to load session messages: ${_error}`,
); );
await this.initializeEmptyConversation(); await this.initializeEmptyConversation();
return false;
} }
return sessionReady;
} }
/** /**

View File

@@ -7,24 +7,56 @@
import type React from 'react'; import type React from 'react';
import { generateIconUrl } from '../../utils/resourceUrl.js'; 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 // Generate icon URL using the utility function
const iconUri = generateIconUrl('icon.png'); 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 ( return (
<div className="flex flex-col items-center justify-center h-full p-5 md:p-10"> <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"> <div className="flex flex-col items-center gap-8 w-full">
{/* Qwen Logo */} {/* Qwen Logo */}
<div className="flex flex-col items-center gap-6"> <div className="flex flex-col items-center gap-6">
{iconUri ? (
<img <img
src={iconUri} src={iconUri}
alt="Qwen Logo" alt="Qwen Logo"
className="w-[60px] h-[60px] object-contain" 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-center">
<div className="text-[15px] text-app-primary-foreground leading-normal font-normal max-w-[400px]"> <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 {description}
code.
</div> </div>
</div> </div>
</div> </div>

View File

@@ -113,6 +113,7 @@ export const InputForm: React.FC<InputFormProps> = ({
onCompletionClose, onCompletionClose,
}) => { }) => {
const editModeInfo = getEditModeInfo(editMode); const editModeInfo = getEditModeInfo(editMode);
const composerDisabled = isStreaming || isWaitingForResponse;
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
// ESC should cancel the current interaction (stop generation) // ESC should cancel the current interaction (stop generation)
@@ -144,7 +145,7 @@ export const InputForm: React.FC<InputFormProps> = ({
return ( return (
<div <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)' }} style={{ backgroundColor: 'var(--app-primary-background)' }}
> >
<div className="block"> <div className="block">
@@ -179,10 +180,16 @@ export const InputForm: React.FC<InputFormProps> = ({
data-placeholder="Ask Qwen Code …" data-placeholder="Ask Qwen Code …"
// Use a data flag so CSS can show placeholder even if the browser // Use a data flag so CSS can show placeholder even if the browser
// inserts an invisible <br> into contentEditable (so :empty no longer matches) // 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) => { onInput={(e) => {
const target = e.target as HTMLDivElement; 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} onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd} onCompositionEnd={onCompositionEnd}
@@ -281,7 +288,7 @@ export const InputForm: React.FC<InputFormProps> = ({
<button <button
type="submit" type="submit"
className="btn-send-compact [&>svg]:w-5 [&>svg]:h-5" className="btn-send-compact [&>svg]:w-5 [&>svg]:h-5"
disabled={!inputText.trim()} disabled={composerDisabled || !inputText.trim()}
> >
<ArrowUpIcon /> <ArrowUpIcon />
</button> </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

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

View File

@@ -14,6 +14,7 @@ interface UseMessageSubmitProps {
setInputText: (text: string) => void; setInputText: (text: string) => void;
inputFieldRef: React.RefObject<HTMLDivElement>; inputFieldRef: React.RefObject<HTMLDivElement>;
isStreaming: boolean; isStreaming: boolean;
isWaitingForResponse: boolean;
// When true, do NOT auto-attach the active editor file/selection to context // When true, do NOT auto-attach the active editor file/selection to context
skipAutoActiveContext?: boolean; skipAutoActiveContext?: boolean;
@@ -40,6 +41,7 @@ export const useMessageSubmit = ({
setInputText, setInputText,
inputFieldRef, inputFieldRef,
isStreaming, isStreaming,
isWaitingForResponse,
skipAutoActiveContext = false, skipAutoActiveContext = false,
fileContext, fileContext,
messageHandling, messageHandling,
@@ -48,7 +50,7 @@ export const useMessageSubmit = ({
(e: React.FormEvent) => { (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!inputText.trim() || isStreaming) { if (!inputText.trim() || isStreaming || isWaitingForResponse) {
return; return;
} }
@@ -56,7 +58,10 @@ export const useMessageSubmit = ({
if (inputText.trim() === '/login') { if (inputText.trim() === '/login') {
setInputText(''); setInputText('');
if (inputFieldRef.current) { 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({ vscode.postMessage({
type: 'login', type: 'login',
@@ -142,7 +147,10 @@ export const useMessageSubmit = ({
setInputText(''); setInputText('');
if (inputFieldRef.current) { 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(); fileContext.clearFileReferences();
}, },
@@ -154,6 +162,7 @@ export const useMessageSubmit = ({
vscode, vscode,
fileContext, fileContext,
skipAutoActiveContext, skipAutoActiveContext,
isWaitingForResponse,
messageHandling, messageHandling,
], ],
); );

View File

@@ -109,6 +109,8 @@ interface UseWebViewMessagesProps {
setInputText: (text: string) => void; setInputText: (text: string) => void;
// Edit mode setter (maps ACP modes to UI modes) // Edit mode setter (maps ACP modes to UI modes)
setEditMode?: (mode: ApprovalModeValue) => void; setEditMode?: (mode: ApprovalModeValue) => void;
// Authentication state setter
setIsAuthenticated?: (authenticated: boolean | null) => void;
} }
/** /**
@@ -126,6 +128,7 @@ export const useWebViewMessages = ({
inputFieldRef, inputFieldRef,
setInputText, setInputText,
setEditMode, setEditMode,
setIsAuthenticated,
}: UseWebViewMessagesProps) => { }: UseWebViewMessagesProps) => {
// VS Code API for posting messages back to the extension host // VS Code API for posting messages back to the extension host
const vscode = useVSCode(); const vscode = useVSCode();
@@ -141,6 +144,7 @@ export const useWebViewMessages = ({
clearToolCalls, clearToolCalls,
setPlanEntries, setPlanEntries,
handlePermissionRequest, handlePermissionRequest,
setIsAuthenticated,
}); });
// Track last "Updated Plan" snapshot toolcall to support merge/dedupe // Track last "Updated Plan" snapshot toolcall to support merge/dedupe
@@ -185,6 +189,7 @@ export const useWebViewMessages = ({
clearToolCalls, clearToolCalls,
setPlanEntries, setPlanEntries,
handlePermissionRequest, handlePermissionRequest,
setIsAuthenticated,
}; };
}); });
@@ -216,6 +221,7 @@ export const useWebViewMessages = ({
} }
break; break;
} }
case 'loginSuccess': { case 'loginSuccess': {
// Clear loading state and show a short assistant notice // Clear loading state and show a short assistant notice
handlers.messageHandling.clearWaitingForResponse(); handlers.messageHandling.clearWaitingForResponse();
@@ -224,12 +230,16 @@ export const useWebViewMessages = ({
content: 'Successfully logged in. You can continue chatting.', content: 'Successfully logged in. You can continue chatting.',
timestamp: Date.now(), timestamp: Date.now(),
}); });
// Set authentication state to true
handlers.setIsAuthenticated?.(true);
break; break;
} }
case 'agentConnected': { case 'agentConnected': {
// Agent connected successfully; clear any pending spinner // Agent connected successfully; clear any pending spinner
handlers.messageHandling.clearWaitingForResponse(); handlers.messageHandling.clearWaitingForResponse();
// Set authentication state to true
handlers.setIsAuthenticated?.(true);
break; 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.`, 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(), timestamp: Date.now(),
}); });
// Set authentication state to false
handlers.setIsAuthenticated?.(false);
break; break;
} }
@@ -259,6 +271,20 @@ export const useWebViewMessages = ({
content: errorMsg, content: errorMsg,
timestamp: Date.now(), 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; break;
} }
@@ -324,30 +350,42 @@ export const useWebViewMessages = ({
} }
case 'streamEnd': { 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.endStreaming();
handlers.messageHandling.clearThinking(); handlers.messageHandling.clearThinking();
// If the stream ended due to explicit user cancel, proactively // If stream ended due to explicit user cancellation, proactively clear
// clear the waiting indicator and reset any tracked exec calls. // waiting indicator and reset tracked execution calls.
// This avoids the UI being stuck with the Stop button visible // This avoids UI getting stuck with Stop button visible after
// after rejecting a permission request. // rejecting a permission request.
try { try {
const reason = ( const reason = (
(message.data as { reason?: string } | undefined)?.reason || '' (message.data as { reason?: string } | undefined)?.reason || ''
).toLowerCase(); ).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(); activeExecToolCallsRef.current.clear();
// Clear waiting response state to ensure UI returns to normal
handlers.messageHandling.clearWaitingForResponse(); handlers.messageHandling.clearWaitingForResponse();
break; break;
} }
} catch (_error) { } 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 * For other types of stream end (non-user cancellation):
// execute/bash/command calls, keep the hint visible. * 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) { if (activeExecToolCallsRef.current.size === 0) {
handlers.messageHandling.clearWaitingForResponse(); handlers.messageHandling.clearWaitingForResponse();
} }
@@ -548,15 +586,21 @@ export const useWebViewMessages = ({
// While long-running tools (e.g., execute/bash/command) are in progress, // While long-running tools (e.g., execute/bash/command) are in progress,
// surface a lightweight loading indicator and expose the Stop button. // surface a lightweight loading indicator and expose the Stop button.
try { try {
const kind = (toolCallData.kind || '').toString().toLowerCase();
const isExec =
kind === 'execute' || kind === 'bash' || kind === 'command';
if (isExec) {
const id = (toolCallData.toolCallId || '').toString(); const id = (toolCallData.toolCallId || '').toString();
const kind = (toolCallData.kind || '').toString().toLowerCase();
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 || !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); activeExecToolCallsRef.current.add(id);
// Build a helpful hint from rawInput // Build a helpful hint from rawInput
@@ -570,6 +614,7 @@ export const useWebViewMessages = ({
} }
const hint = cmd ? `Running: ${cmd}` : 'Running command...'; const hint = cmd ? `Running: ${cmd}` : 'Running command...';
handlers.messageHandling.setWaitingForResponse(hint); handlers.messageHandling.setWaitingForResponse(hint);
}
} else if (status === 'completed' || status === 'failed') { } else if (status === 'completed' || status === 'failed') {
activeExecToolCallsRef.current.delete(id); activeExecToolCallsRef.current.delete(id);
} }
@@ -578,7 +623,6 @@ export const useWebViewMessages = ({
if (activeExecToolCallsRef.current.size === 0) { if (activeExecToolCallsRef.current.size === 0) {
handlers.messageHandling.clearWaitingForResponse(); handlers.messageHandling.clearWaitingForResponse();
} }
}
} catch (_error) { } catch (_error) {
// Best-effort UI hint; ignore errors // Best-effort UI hint; ignore errors
} }

View File

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

View File

@@ -88,6 +88,22 @@
z-index: 0; 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 */ /* 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, .qwen-message.message-item:not(.user-message-container):first-child::after,
.user-message-container + .qwen-message.message-item:not(.user-message-container)::after, .user-message-container + .qwen-message.message-item:not(.user-message-container)::after,