mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
feat(vscode-ide-companion): implement session message handling and UI improvements
Complete session message handling with JSONL support and UI enhancements - Add JSONL session file reading capability - Improve error handling and authentication flows - Update UI components for better user experience - Fix command identifier references - Enhance MarkdownRenderer with copy functionality - Update Tailwind configuration for better component coverage
This commit is contained in:
@@ -147,50 +147,6 @@ export class QwenAgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current session is valid and can send messages
|
||||
* This performs a lightweight validation by sending a test prompt
|
||||
*
|
||||
* @returns True if session is valid, false otherwise
|
||||
*/
|
||||
async checkSessionValidity(): Promise<boolean> {
|
||||
try {
|
||||
// If we don't have a current session, it's definitely not valid
|
||||
if (!this.connection.currentSessionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try to send a lightweight test prompt to validate the session
|
||||
// We use a simple prompt that should return quickly
|
||||
await this.connection.sendPrompt('test session validity');
|
||||
return true;
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.warn(
|
||||
'[QwenAgentManager] Session validity check failed:',
|
||||
errorMsg,
|
||||
);
|
||||
|
||||
// Check for common authentication/session expiration errors
|
||||
const isAuthError =
|
||||
errorMsg.includes('Authentication required') ||
|
||||
errorMsg.includes('(code: -32000)') ||
|
||||
errorMsg.includes('No active ACP session') ||
|
||||
errorMsg.includes('Session not found');
|
||||
|
||||
if (isAuthError) {
|
||||
console.log(
|
||||
'[QwenAgentManager] Detected authentication/session expiration',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// For other errors, we can't determine validity definitively
|
||||
// Assume session is still valid unless we know it's not
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session list with version-aware strategy
|
||||
* First tries ACP method if CLI version supports it, falls back to file system method
|
||||
@@ -220,16 +176,42 @@ export class QwenAgentManager {
|
||||
const response = await this.connection.listSessions();
|
||||
console.log('[QwenAgentManager] ACP session list response:', response);
|
||||
|
||||
if (response.result && Array.isArray(response.result)) {
|
||||
const sessions = response.result.map((session) => ({
|
||||
id: session.sessionId || session.id,
|
||||
sessionId: session.sessionId || session.id,
|
||||
title: session.title || session.name || 'Untitled Session',
|
||||
name: session.title || session.name || 'Untitled Session',
|
||||
startTime: session.startTime,
|
||||
lastUpdated: session.lastUpdated,
|
||||
messageCount: session.messageCount || 0,
|
||||
projectHash: session.projectHash,
|
||||
// sendRequest resolves with the JSON-RPC "result" directly
|
||||
// Newer CLI returns an object: { items: [...], nextCursor?, hasMore }
|
||||
// Older prototypes might return an array. Support both.
|
||||
const res: unknown = response;
|
||||
let items: Array<Record<string, unknown>> = [];
|
||||
|
||||
if (
|
||||
typeof response === 'object' &&
|
||||
response !== null &&
|
||||
'items' in response
|
||||
) {
|
||||
// Type guard to safely access items property
|
||||
const responseObject: Record<string, unknown> = response;
|
||||
if ('items' in responseObject) {
|
||||
const itemsValue = responseObject.items;
|
||||
items = Array.isArray(itemsValue) ? itemsValue : [];
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
'[QwenAgentManager] Sessions retrieved via ACP:',
|
||||
res,
|
||||
items.length,
|
||||
);
|
||||
if (items.length > 0) {
|
||||
const sessions = items.map((item) => ({
|
||||
id: item.sessionId || item.id,
|
||||
sessionId: item.sessionId || item.id,
|
||||
title: item.title || item.name || item.prompt || 'Untitled Session',
|
||||
name: item.title || item.name || item.prompt || 'Untitled Session',
|
||||
startTime: item.startTime,
|
||||
lastUpdated: item.mtime || item.lastUpdated,
|
||||
messageCount: item.messageCount || 0,
|
||||
projectHash: item.projectHash,
|
||||
filePath: item.filePath,
|
||||
cwd: item.cwd,
|
||||
}));
|
||||
|
||||
console.log(
|
||||
@@ -282,6 +264,116 @@ export class QwenAgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session list (paged)
|
||||
* Uses ACP session/list with cursor-based pagination when available.
|
||||
* Falls back to file system scan with equivalent pagination semantics.
|
||||
*/
|
||||
async getSessionListPaged(params?: {
|
||||
cursor?: number;
|
||||
size?: number;
|
||||
}): Promise<{
|
||||
sessions: Array<Record<string, unknown>>;
|
||||
nextCursor?: number;
|
||||
hasMore: boolean;
|
||||
}> {
|
||||
const size = params?.size ?? 20;
|
||||
const cursor = params?.cursor;
|
||||
|
||||
const cliContextManager = CliContextManager.getInstance();
|
||||
const supportsSessionList = cliContextManager.supportsSessionList();
|
||||
|
||||
if (supportsSessionList) {
|
||||
try {
|
||||
const response = await this.connection.listSessions({
|
||||
size,
|
||||
...(cursor !== undefined ? { cursor } : {}),
|
||||
});
|
||||
// sendRequest resolves with the JSON-RPC "result" directly
|
||||
const res: unknown = response;
|
||||
let items: Array<Record<string, unknown>> = [];
|
||||
|
||||
if (Array.isArray(res)) {
|
||||
items = res;
|
||||
} else if (typeof res === 'object' && res !== null && 'items' in res) {
|
||||
const responseObject = res as {
|
||||
items?: Array<Record<string, unknown>>;
|
||||
};
|
||||
items = Array.isArray(responseObject.items)
|
||||
? responseObject.items
|
||||
: [];
|
||||
}
|
||||
|
||||
const mapped = items.map((item) => ({
|
||||
id: item.sessionId || item.id,
|
||||
sessionId: item.sessionId || item.id,
|
||||
title: item.title || item.name || item.prompt || 'Untitled Session',
|
||||
name: item.title || item.name || item.prompt || 'Untitled Session',
|
||||
startTime: item.startTime,
|
||||
lastUpdated: item.mtime || item.lastUpdated,
|
||||
messageCount: item.messageCount || 0,
|
||||
projectHash: item.projectHash,
|
||||
filePath: item.filePath,
|
||||
cwd: item.cwd,
|
||||
}));
|
||||
|
||||
const nextCursor: number | undefined =
|
||||
typeof res === 'object' && res !== null && 'nextCursor' in res
|
||||
? typeof res.nextCursor === 'number'
|
||||
? res.nextCursor
|
||||
: undefined
|
||||
: undefined;
|
||||
const hasMore: boolean =
|
||||
typeof res === 'object' && res !== null && 'hasMore' in res
|
||||
? Boolean(res.hasMore)
|
||||
: false;
|
||||
|
||||
return { sessions: mapped, nextCursor, hasMore };
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[QwenAgentManager] Paged ACP session list failed:',
|
||||
error,
|
||||
);
|
||||
// fall through to file system
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: file system for current project only (to match ACP semantics)
|
||||
try {
|
||||
const all = await this.sessionReader.getAllSessions(
|
||||
this.currentWorkingDir,
|
||||
false,
|
||||
);
|
||||
// Sorted by lastUpdated desc already per reader
|
||||
const allWithMtime = all.map((s) => ({
|
||||
raw: s,
|
||||
mtime: new Date(s.lastUpdated).getTime(),
|
||||
}));
|
||||
const filtered =
|
||||
cursor !== undefined
|
||||
? allWithMtime.filter((x) => x.mtime < cursor)
|
||||
: allWithMtime;
|
||||
const page = filtered.slice(0, size);
|
||||
const sessions = page.map((x) => ({
|
||||
id: x.raw.sessionId,
|
||||
sessionId: x.raw.sessionId,
|
||||
title: this.sessionReader.getSessionTitle(x.raw),
|
||||
name: this.sessionReader.getSessionTitle(x.raw),
|
||||
startTime: x.raw.startTime,
|
||||
lastUpdated: x.raw.lastUpdated,
|
||||
messageCount: x.raw.messages.length,
|
||||
projectHash: x.raw.projectHash,
|
||||
}));
|
||||
const nextCursorVal =
|
||||
page.length > 0 ? page[page.length - 1].mtime : undefined;
|
||||
const hasMore = filtered.length > size;
|
||||
return { sessions, nextCursor: nextCursorVal, hasMore };
|
||||
} catch (error) {
|
||||
console.error('[QwenAgentManager] File system paged list failed:', error);
|
||||
return { sessions: [], hasMore: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session messages (read from disk)
|
||||
*
|
||||
@@ -290,6 +382,35 @@ export class QwenAgentManager {
|
||||
*/
|
||||
async getSessionMessages(sessionId: string): Promise<ChatMessage[]> {
|
||||
try {
|
||||
// Prefer reading CLI's JSONL if we can find filePath from session/list
|
||||
const cliContextManager = CliContextManager.getInstance();
|
||||
if (cliContextManager.supportsSessionList()) {
|
||||
try {
|
||||
const list = await this.getSessionList();
|
||||
const item = list.find(
|
||||
(s) => s.sessionId === sessionId || s.id === sessionId,
|
||||
);
|
||||
console.log(
|
||||
'[QwenAgentManager] Session list item for filePath lookup:',
|
||||
item,
|
||||
);
|
||||
if (
|
||||
typeof item === 'object' &&
|
||||
item !== null &&
|
||||
'filePath' in item &&
|
||||
typeof item.filePath === 'string'
|
||||
) {
|
||||
const messages = await this.readJsonlMessages(item.filePath);
|
||||
// Even if messages array is empty, we should return it rather than falling back
|
||||
// This ensures we don't accidentally show messages from a different session format
|
||||
return messages;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[QwenAgentManager] JSONL read path lookup failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: legacy JSON session files
|
||||
const session = await this.sessionReader.getSession(
|
||||
sessionId,
|
||||
this.currentWorkingDir,
|
||||
@@ -297,11 +418,9 @@ export class QwenAgentManager {
|
||||
if (!session) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return session.messages.map(
|
||||
(msg: { type: string; content: string; timestamp: string }) => ({
|
||||
role:
|
||||
msg.type === 'user' ? ('user' as const) : ('assistant' as const),
|
||||
role: msg.type === 'user' ? 'user' : 'assistant',
|
||||
content: msg.content,
|
||||
timestamp: new Date(msg.timestamp).getTime(),
|
||||
}),
|
||||
@@ -315,6 +434,265 @@ export class QwenAgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Read CLI JSONL session file and convert to ChatMessage[] for UI
|
||||
private async readJsonlMessages(filePath: string): Promise<ChatMessage[]> {
|
||||
const fs = await import('fs');
|
||||
const readline = await import('readline');
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return [];
|
||||
const fileStream = fs.createReadStream(filePath, { encoding: 'utf-8' });
|
||||
const rl = readline.createInterface({
|
||||
input: fileStream,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
const records: unknown[] = [];
|
||||
for await (const line of rl) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const obj = JSON.parse(trimmed);
|
||||
records.push(obj);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
// Simple linear reconstruction: filter user/assistant and sort by timestamp
|
||||
console.log(
|
||||
'[QwenAgentManager] JSONL records read:',
|
||||
records.length,
|
||||
filePath,
|
||||
);
|
||||
|
||||
// Include all types of records, not just user/assistant
|
||||
const allRecords = records
|
||||
.filter((r) => r && r.type && r.timestamp)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||
);
|
||||
|
||||
const msgs: ChatMessage[] = [];
|
||||
for (const r of allRecords) {
|
||||
// Handle user and assistant messages
|
||||
if ((r.type === 'user' || r.type === 'assistant') && r.message) {
|
||||
msgs.push({
|
||||
role:
|
||||
r.type === 'user' ? ('user' as const) : ('assistant' as const),
|
||||
content: this.contentToText(r.message),
|
||||
timestamp: new Date(r.timestamp).getTime(),
|
||||
});
|
||||
}
|
||||
// Handle tool call records that might have content we want to show
|
||||
else if (r.type === 'tool_call' || r.type === 'tool_call_update') {
|
||||
// Convert tool calls to messages if they have relevant content
|
||||
const toolContent = this.extractToolCallContent(r);
|
||||
if (toolContent) {
|
||||
msgs.push({
|
||||
role: 'assistant',
|
||||
content: toolContent,
|
||||
timestamp: new Date(r.timestamp).getTime(),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Handle tool result records
|
||||
else if (r.type === 'tool_result' && r.toolCallResult) {
|
||||
const toolResult = r.toolCallResult;
|
||||
const callId = toolResult.callId || 'unknown';
|
||||
const status = toolResult.status || 'unknown';
|
||||
const resultText = `Tool Result (${callId}): ${status}`;
|
||||
msgs.push({
|
||||
role: 'assistant',
|
||||
content: resultText,
|
||||
timestamp: new Date(r.timestamp).getTime(),
|
||||
});
|
||||
}
|
||||
// Handle system telemetry records
|
||||
else if (
|
||||
r.type === 'system' &&
|
||||
r.subtype === 'ui_telemetry' &&
|
||||
r.systemPayload?.uiEvent
|
||||
) {
|
||||
const uiEvent = r.systemPayload.uiEvent;
|
||||
let telemetryText = '';
|
||||
|
||||
if (
|
||||
uiEvent['event.name'] &&
|
||||
uiEvent['event.name'].includes('tool_call')
|
||||
) {
|
||||
const functionName = uiEvent.function_name || 'Unknown tool';
|
||||
const status = uiEvent.status || 'unknown';
|
||||
const duration = uiEvent.duration_ms
|
||||
? ` (${uiEvent.duration_ms}ms)`
|
||||
: '';
|
||||
telemetryText = `Tool Call: ${functionName} - ${status}${duration}`;
|
||||
} else if (
|
||||
uiEvent['event.name'] &&
|
||||
uiEvent['event.name'].includes('api_response')
|
||||
) {
|
||||
const statusCode = uiEvent.status_code || 'unknown';
|
||||
const duration = uiEvent.duration_ms
|
||||
? ` (${uiEvent.duration_ms}ms)`
|
||||
: '';
|
||||
telemetryText = `API Response: Status ${statusCode}${duration}`;
|
||||
} else {
|
||||
// Generic system telemetry
|
||||
const eventName = uiEvent['event.name'] || 'Unknown event';
|
||||
telemetryText = `System Event: ${eventName}`;
|
||||
}
|
||||
|
||||
if (telemetryText) {
|
||||
msgs.push({
|
||||
role: 'assistant',
|
||||
content: telemetryText,
|
||||
timestamp: new Date(r.timestamp).getTime(),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Handle plan entries
|
||||
else if (r.type === 'plan' && r.plan) {
|
||||
const planEntries = r.plan.entries || [];
|
||||
if (planEntries.length > 0) {
|
||||
const planText = planEntries
|
||||
.map(
|
||||
(entry: Record<string, unknown>, index: number) =>
|
||||
`${index + 1}. ${entry.description || entry.title || 'Unnamed step'}`,
|
||||
)
|
||||
.join('\n');
|
||||
msgs.push({
|
||||
role: 'assistant',
|
||||
content: `Plan:\n${planText}`,
|
||||
timestamp: new Date(r.timestamp).getTime(),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Handle other types if needed
|
||||
}
|
||||
|
||||
console.log(
|
||||
'[QwenAgentManager] JSONL messages reconstructed:',
|
||||
msgs.length,
|
||||
);
|
||||
return msgs;
|
||||
} catch (err) {
|
||||
console.warn('[QwenAgentManager] Failed to read JSONL messages:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Extract meaningful content from tool call records
|
||||
private extractToolCallContent(record: unknown): string | null {
|
||||
try {
|
||||
// Type guard for record
|
||||
if (typeof record !== 'object' || record === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cast to a more specific type for easier handling
|
||||
const typedRecord = record as Record<string, unknown>;
|
||||
|
||||
// If the tool call has a result or output, include it
|
||||
if ('toolCallResult' in typedRecord && typedRecord.toolCallResult) {
|
||||
return `Tool result: ${this.formatValue(typedRecord.toolCallResult)}`;
|
||||
}
|
||||
|
||||
// If the tool call has content, include it
|
||||
if ('content' in typedRecord && typedRecord.content) {
|
||||
return this.formatValue(typedRecord.content);
|
||||
}
|
||||
|
||||
// If the tool call has a title or name, include it
|
||||
if (
|
||||
('title' in typedRecord && typedRecord.title) ||
|
||||
('name' in typedRecord && typedRecord.name)
|
||||
) {
|
||||
return `Tool: ${typedRecord.title || typedRecord.name}`;
|
||||
}
|
||||
|
||||
// Handle tool_call records with more details
|
||||
if (
|
||||
typedRecord.type === 'tool_call' &&
|
||||
'toolCall' in typedRecord &&
|
||||
typedRecord.toolCall
|
||||
) {
|
||||
const toolCall = typedRecord.toolCall as Record<string, unknown>;
|
||||
if (
|
||||
('title' in toolCall && toolCall.title) ||
|
||||
('name' in toolCall && toolCall.name)
|
||||
) {
|
||||
return `Tool call: ${toolCall.title || toolCall.name}`;
|
||||
}
|
||||
if ('rawInput' in toolCall && toolCall.rawInput) {
|
||||
return `Tool input: ${this.formatValue(toolCall.rawInput)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tool_call_update records with status
|
||||
if (typedRecord.type === 'tool_call_update') {
|
||||
const status =
|
||||
('status' in typedRecord && typedRecord.status) || 'unknown';
|
||||
const title =
|
||||
('title' in typedRecord && typedRecord.title) ||
|
||||
('name' in typedRecord && typedRecord.name) ||
|
||||
'Unknown tool';
|
||||
return `Tool ${status}: ${title}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Format any value to a string for display
|
||||
private formatValue(value: unknown): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch (_e) {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
// Extract plain text from Content (genai Content)
|
||||
private contentToText(message: unknown): string {
|
||||
try {
|
||||
// Type guard for message
|
||||
if (typeof message !== 'object' || message === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Cast to a more specific type for easier handling
|
||||
const typedMessage = message as Record<string, unknown>;
|
||||
|
||||
const parts = Array.isArray(typedMessage.parts) ? typedMessage.parts : [];
|
||||
const texts: string[] = [];
|
||||
for (const p of parts) {
|
||||
// Type guard for part
|
||||
if (typeof p !== 'object' || p === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const typedPart = p as Record<string, unknown>;
|
||||
if (typeof typedPart.text === 'string') {
|
||||
texts.push(typedPart.text);
|
||||
} else if (typeof typedPart.data === 'string') {
|
||||
texts.push(typedPart.data);
|
||||
}
|
||||
}
|
||||
return texts.join('\n');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save session via /chat save command
|
||||
* Since CLI doesn't support session/save ACP method, we send /chat save command directly
|
||||
@@ -497,7 +875,10 @@ export class QwenAgentManager {
|
||||
* @param sessionId - Session ID
|
||||
* @returns Load response or error
|
||||
*/
|
||||
async loadSessionViaAcp(sessionId: string): Promise<unknown> {
|
||||
async loadSessionViaAcp(
|
||||
sessionId: string,
|
||||
cwdOverride?: string,
|
||||
): Promise<unknown> {
|
||||
// Check if CLI supports session/load method
|
||||
const cliContextManager = CliContextManager.getInstance();
|
||||
const supportsSessionLoad = cliContextManager.supportsSessionLoad();
|
||||
@@ -513,7 +894,10 @@ export class QwenAgentManager {
|
||||
'[QwenAgentManager] Attempting session/load via ACP for session:',
|
||||
sessionId,
|
||||
);
|
||||
const response = await this.connection.loadSession(sessionId);
|
||||
const response = await this.connection.loadSession(
|
||||
sessionId,
|
||||
cwdOverride,
|
||||
);
|
||||
console.log(
|
||||
'[QwenAgentManager] Session load succeeded. Response:',
|
||||
JSON.stringify(response).substring(0, 200),
|
||||
@@ -530,19 +914,24 @@ export class QwenAgentManager {
|
||||
console.error('[QwenAgentManager] Error message:', errorMessage);
|
||||
|
||||
// Check if error is from ACP response
|
||||
if (error && typeof error === 'object' && 'error' in error) {
|
||||
const acpError = error as {
|
||||
error?: { code?: number; message?: string };
|
||||
};
|
||||
if (acpError.error) {
|
||||
console.error(
|
||||
'[QwenAgentManager] ACP error code:',
|
||||
acpError.error.code,
|
||||
);
|
||||
console.error(
|
||||
'[QwenAgentManager] ACP error message:',
|
||||
acpError.error.message,
|
||||
);
|
||||
if (error && typeof error === 'object') {
|
||||
// Safely check if 'error' property exists
|
||||
if ('error' in error) {
|
||||
const acpError = error as {
|
||||
error?: { code?: number; message?: string };
|
||||
};
|
||||
if (acpError.error) {
|
||||
console.error(
|
||||
'[QwenAgentManager] ACP error code:',
|
||||
acpError.error.code,
|
||||
);
|
||||
console.error(
|
||||
'[QwenAgentManager] ACP error message:',
|
||||
acpError.error.message,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.error('[QwenAgentManager] Non-ACPIf error details:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -89,54 +89,10 @@ export class QwenConnectionHandler {
|
||||
}
|
||||
|
||||
// Try to restore existing session or create new session
|
||||
let sessionRestored = false;
|
||||
|
||||
// Try to get session from local files
|
||||
console.log('[QwenAgentManager] Reading local session files...');
|
||||
try {
|
||||
const sessions = await sessionReader.getAllSessions(workingDir);
|
||||
|
||||
if (sessions.length > 0) {
|
||||
console.log(
|
||||
'[QwenAgentManager] Found existing sessions:',
|
||||
sessions.length,
|
||||
);
|
||||
const lastSession = sessions[0]; // Already sorted by lastUpdated
|
||||
|
||||
try {
|
||||
await connection.switchSession(lastSession.sessionId);
|
||||
console.log(
|
||||
'[QwenAgentManager] Restored session:',
|
||||
lastSession.sessionId,
|
||||
);
|
||||
sessionRestored = true;
|
||||
|
||||
// Save auth state after successful session restore
|
||||
if (authStateManager) {
|
||||
console.log(
|
||||
'[QwenAgentManager] Saving auth state after successful session restore',
|
||||
);
|
||||
await authStateManager.saveAuthState(workingDir, authMethod);
|
||||
}
|
||||
} catch (switchError) {
|
||||
console.log(
|
||||
'[QwenAgentManager] session/switch not supported or failed:',
|
||||
switchError instanceof Error
|
||||
? switchError.message
|
||||
: String(switchError),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log('[QwenAgentManager] No existing sessions found');
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
console.log(
|
||||
'[QwenAgentManager] Failed to read local sessions:',
|
||||
errorMessage,
|
||||
);
|
||||
}
|
||||
// Note: Auto-restore on connect is disabled to avoid surprising loads
|
||||
// when user opens a "New Chat" tab. Restoration is now an explicit action
|
||||
// (session selector → session/load) or handled by higher-level flows.
|
||||
const sessionRestored = false;
|
||||
|
||||
// Create new session if unable to restore
|
||||
if (!sessionRestored) {
|
||||
@@ -203,7 +159,7 @@ export class QwenConnectionHandler {
|
||||
console.log('[QwenAgentManager] New session created successfully');
|
||||
|
||||
// Ensure auth state is saved (prevent repeated authentication)
|
||||
if (authStateManager && !hasValidAuth) {
|
||||
if (authStateManager) {
|
||||
console.log(
|
||||
'[QwenAgentManager] Saving auth state after successful session creation',
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ export interface PlanEntry {
|
||||
/** Entry content */
|
||||
content: string;
|
||||
/** Priority */
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
priority?: 'high' | 'medium' | 'low';
|
||||
/** Status */
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
}
|
||||
|
||||
@@ -6,9 +6,6 @@
|
||||
|
||||
import { CliDetector, type CliDetectionResult } from './cliDetector.js';
|
||||
|
||||
/**
|
||||
* Minimum CLI version that supports session/list and session/load ACP methods
|
||||
*/
|
||||
export const MIN_CLI_VERSION_FOR_SESSION_METHODS = '0.4.0';
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,8 +4,12 @@ import type { WebViewProvider } from '../webview/WebViewProvider.js';
|
||||
|
||||
type Logger = (message: string) => void;
|
||||
|
||||
export const runQwenCodeCommand = 'qwen-code.runQwenCode';
|
||||
export const showDiffCommand = 'qwenCode.showDiff';
|
||||
export const openChatCommand = 'qwenCode.openChat';
|
||||
export const openChatCommand = 'qwen-code.openChat';
|
||||
export const openNewChatTabCommand = 'qwenCode.openNewChatTab';
|
||||
export const loginCommand = 'qwen-code.login';
|
||||
export const clearAuthCacheCommand = 'qwen-code.clearAuthCache';
|
||||
|
||||
export function registerNewCommands(
|
||||
context: vscode.ExtensionContext,
|
||||
@@ -20,15 +24,15 @@ export function registerNewCommands(
|
||||
vscode.commands.registerCommand(openChatCommand, async () => {
|
||||
const config = vscode.workspace.getConfiguration('qwenCode');
|
||||
const useTerminal = config.get<boolean>('useTerminal', false);
|
||||
console.log('[Command] Using terminal mode:', useTerminal);
|
||||
|
||||
// Use terminal mode
|
||||
if (useTerminal) {
|
||||
// 使用终端模式
|
||||
await vscode.commands.executeCommand(
|
||||
'qwen-code.runQwenCode',
|
||||
vscode.TerminalLocation.Editor, // 在编辑器区域创建终端,
|
||||
runQwenCodeCommand,
|
||||
vscode.TerminalLocation.Editor, // create a terminal in the editor area,
|
||||
);
|
||||
} else {
|
||||
// 使用 WebView 模式
|
||||
// Use WebView mode
|
||||
const providers = getWebViewProviders();
|
||||
if (providers.length > 0) {
|
||||
await providers[providers.length - 1].show();
|
||||
@@ -44,7 +48,6 @@ export function registerNewCommands(
|
||||
vscode.commands.registerCommand(
|
||||
showDiffCommand,
|
||||
async (args: { path: string; oldText: string; newText: string }) => {
|
||||
log(`[Command] showDiff called for: ${args.path}`);
|
||||
try {
|
||||
let absolutePath = args.path;
|
||||
if (!args.path.startsWith('/') && !args.path.match(/^[a-zA-Z]:/)) {
|
||||
@@ -68,27 +71,20 @@ export function registerNewCommands(
|
||||
|
||||
// TODO: qwenCode.openNewChatTab (not contributed in package.json; used programmatically)
|
||||
disposables.push(
|
||||
vscode.commands.registerCommand('qwenCode.openNewChatTab', async () => {
|
||||
vscode.commands.registerCommand(openNewChatTabCommand, async () => {
|
||||
const provider = createWebViewProvider();
|
||||
// Suppress auto-restore for this newly created tab so it starts clean
|
||||
try {
|
||||
provider.suppressAutoRestoreOnce?.();
|
||||
} catch {
|
||||
// ignore if older provider does not implement the method
|
||||
}
|
||||
await provider.show();
|
||||
}),
|
||||
);
|
||||
|
||||
disposables.push(
|
||||
vscode.commands.registerCommand('qwenCode.clearAuthCache', async () => {
|
||||
const providers = getWebViewProviders();
|
||||
for (const provider of providers) {
|
||||
await provider.clearAuthCache();
|
||||
}
|
||||
vscode.window.showInformationMessage(
|
||||
'Qwen Code authentication cache cleared. You will need to login again on next connection.',
|
||||
);
|
||||
log('Auth cache cleared by user');
|
||||
}),
|
||||
);
|
||||
|
||||
disposables.push(
|
||||
vscode.commands.registerCommand('qwenCode.login', async () => {
|
||||
vscode.commands.registerCommand(loginCommand, async () => {
|
||||
const providers = getWebViewProviders();
|
||||
if (providers.length > 0) {
|
||||
await providers[providers.length - 1].forceReLogin();
|
||||
@@ -100,5 +96,18 @@ export function registerNewCommands(
|
||||
}),
|
||||
);
|
||||
|
||||
disposables.push(
|
||||
vscode.commands.registerCommand(clearAuthCacheCommand, async () => {
|
||||
const providers = getWebViewProviders();
|
||||
for (const provider of providers) {
|
||||
await provider.clearAuthCache();
|
||||
}
|
||||
vscode.window.showInformationMessage(
|
||||
'Qwen Code authentication cache cleared. You will need to login again on next connection.',
|
||||
);
|
||||
log('Auth cache cleared by user');
|
||||
}),
|
||||
);
|
||||
|
||||
context.subscriptions.push(...disposables);
|
||||
}
|
||||
|
||||
@@ -4,26 +4,6 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* ACP (Agent Communication Protocol) Method Definitions
|
||||
*
|
||||
* This file defines the protocol methods for communication between
|
||||
* the VSCode extension (Client) and the qwen CLI (Agent/Server).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Methods that the Agent (CLI) implements and receives from Client (VSCode)
|
||||
*
|
||||
* Status in qwen CLI:
|
||||
* ✅ initialize - Protocol initialization
|
||||
* ✅ authenticate - User authentication
|
||||
* ✅ session/new - Create new session
|
||||
* ✅ session/load - Load existing session (v0.2.4+)
|
||||
* ✅ session/list - List available sessions (v0.2.4+)
|
||||
* ✅ session/prompt - Send user message to agent
|
||||
* ✅ session/cancel - Cancel current generation
|
||||
* ✅ session/save - Save current session
|
||||
*/
|
||||
export const AGENT_METHODS = {
|
||||
authenticate: 'authenticate',
|
||||
initialize: 'initialize',
|
||||
@@ -35,15 +15,6 @@ export const AGENT_METHODS = {
|
||||
session_save: 'session/save',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Methods that the Client (VSCode) implements and receives from Agent (CLI)
|
||||
*
|
||||
* Status in VSCode extension:
|
||||
* ✅ fs/read_text_file - Read file content
|
||||
* ✅ fs/write_text_file - Write file content
|
||||
* ✅ session/request_permission - Request user permission for tool execution
|
||||
* ✅ session/update - Stream session updates (notification)
|
||||
*/
|
||||
export const CLIENT_METHODS = {
|
||||
fs_read_text_file: 'fs/read_text_file',
|
||||
fs_write_text_file: 'fs/write_text_file',
|
||||
|
||||
@@ -9,6 +9,7 @@ import React, {
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useLayoutEffect,
|
||||
} from 'react';
|
||||
import { useVSCode } from './hooks/useVSCode.js';
|
||||
@@ -21,15 +22,13 @@ import { useMessageSubmit } from './hooks/useMessageSubmit.js';
|
||||
import type {
|
||||
PermissionOption,
|
||||
ToolCall as PermissionToolCall,
|
||||
} from './components/PermissionRequest.js';
|
||||
} from './components/PermissionDrawer/PermissionRequest.js';
|
||||
import type { TextMessage } from './hooks/message/useMessageHandling.js';
|
||||
import type { ToolCallData } from './components/ToolCall.js';
|
||||
import { PermissionDrawer } from './components/PermissionDrawer.js';
|
||||
import { PermissionDrawer } from './components/PermissionDrawer/PermissionDrawer.js';
|
||||
import { ToolCall } from './components/ToolCall.js';
|
||||
import { hasToolCallOutput } from './components/toolcalls/shared/utils.js';
|
||||
// import { InProgressToolCall } from './components/InProgressToolCall.js';
|
||||
import { EmptyState } from './components/ui/EmptyState.js';
|
||||
import type { PlanEntry } from './components/PlanDisplay.js';
|
||||
import { type CompletionItem } from './types/CompletionTypes.js';
|
||||
import { useCompletionTrigger } from './hooks/useCompletionTrigger.js';
|
||||
import { InfoBanner } from './components/ui/InfoBanner.js';
|
||||
@@ -45,6 +44,7 @@ import { InputForm } from './components/InputForm.js';
|
||||
import { SessionSelector } from './components/session/SessionSelector.js';
|
||||
import { FileIcon, UserIcon } from './components/icons/index.js';
|
||||
import type { EditMode } from './types/toolCall.js';
|
||||
import type { PlanEntry } from '../agents/qwenTypes.js';
|
||||
|
||||
export const App: React.FC = () => {
|
||||
const vscode = useVSCode();
|
||||
@@ -488,12 +488,138 @@ export const App: React.FC = () => {
|
||||
setThinkingEnabled((prev) => !prev);
|
||||
};
|
||||
|
||||
// Create unified message array containing all types of messages and tool calls
|
||||
const allMessages = useMemo<
|
||||
Array<{
|
||||
type: 'message' | 'in-progress-tool-call' | 'completed-tool-call';
|
||||
data: TextMessage | ToolCallData;
|
||||
timestamp: number;
|
||||
}>
|
||||
>(() => {
|
||||
// Regular messages
|
||||
const regularMessages = messageHandling.messages.map((msg) => ({
|
||||
type: 'message' as const,
|
||||
data: msg,
|
||||
timestamp: msg.timestamp,
|
||||
}));
|
||||
|
||||
// In-progress tool calls
|
||||
const inProgressTools = inProgressToolCalls.map((toolCall) => ({
|
||||
type: 'in-progress-tool-call' as const,
|
||||
data: toolCall,
|
||||
timestamp: toolCall.timestamp || Date.now(),
|
||||
}));
|
||||
|
||||
// Completed tool calls
|
||||
const completedTools = completedToolCalls
|
||||
.filter(hasToolCallOutput)
|
||||
.map((toolCall) => ({
|
||||
type: 'completed-tool-call' as const,
|
||||
data: toolCall,
|
||||
timestamp: toolCall.timestamp || Date.now(),
|
||||
}));
|
||||
|
||||
// Merge and sort by timestamp to ensure messages and tool calls are interleaved
|
||||
return [...regularMessages, ...inProgressTools, ...completedTools].sort(
|
||||
(a, b) => (a.timestamp || 0) - (b.timestamp || 0),
|
||||
);
|
||||
}, [messageHandling.messages, inProgressToolCalls, completedToolCalls]);
|
||||
|
||||
console.log('[App] Rendering messages:', allMessages);
|
||||
|
||||
// Render all messages and tool calls
|
||||
const renderMessages = useCallback<() => React.ReactNode>(
|
||||
() =>
|
||||
allMessages.map((item, index) => {
|
||||
switch (item.type) {
|
||||
case 'message': {
|
||||
const msg = item.data as TextMessage;
|
||||
const handleFileClick = (path: string): void => {
|
||||
vscode.postMessage({
|
||||
type: 'openFile',
|
||||
data: { path },
|
||||
});
|
||||
};
|
||||
|
||||
if (msg.role === 'thinking') {
|
||||
return (
|
||||
<ThinkingMessage
|
||||
key={`message-${index}`}
|
||||
content={msg.content || ''}
|
||||
timestamp={msg.timestamp || 0}
|
||||
onFileClick={handleFileClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (msg.role === 'user') {
|
||||
return (
|
||||
<UserMessage
|
||||
key={`message-${index}`}
|
||||
content={msg.content || ''}
|
||||
timestamp={msg.timestamp || 0}
|
||||
onFileClick={handleFileClick}
|
||||
fileContext={msg.fileContext}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const content = (msg.content || '').trim();
|
||||
if (content === 'Interrupted' || content === 'Tool interrupted') {
|
||||
return (
|
||||
<InterruptedMessage key={`message-${index}`} text={content} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<AssistantMessage
|
||||
key={`message-${index}`}
|
||||
content={content}
|
||||
timestamp={msg.timestamp || 0}
|
||||
onFileClick={handleFileClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
case 'in-progress-tool-call':
|
||||
case 'completed-tool-call': {
|
||||
const prev = allMessages[index - 1];
|
||||
const next = allMessages[index + 1];
|
||||
const isToolCallType = (
|
||||
x: unknown,
|
||||
): x is { type: 'in-progress-tool-call' | 'completed-tool-call' } =>
|
||||
x &&
|
||||
typeof x === 'object' &&
|
||||
'type' in (x as Record<string, unknown>) &&
|
||||
((x as { type: string }).type === 'in-progress-tool-call' ||
|
||||
(x as { type: string }).type === 'completed-tool-call');
|
||||
const isFirst = !isToolCallType(prev);
|
||||
const isLast = !isToolCallType(next);
|
||||
return (
|
||||
<ToolCall
|
||||
key={`toolcall-${(item.data as ToolCallData).toolCallId}-${item.type}`}
|
||||
toolCall={item.data as ToolCallData}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
[allMessages, vscode],
|
||||
);
|
||||
|
||||
const hasContent =
|
||||
messageHandling.messages.length > 0 ||
|
||||
messageHandling.isStreaming ||
|
||||
inProgressToolCalls.length > 0 ||
|
||||
completedToolCalls.length > 0 ||
|
||||
planEntries.length > 0;
|
||||
planEntries.length > 0 ||
|
||||
allMessages.length > 0;
|
||||
|
||||
return (
|
||||
<div className="chat-container">
|
||||
@@ -508,6 +634,9 @@ export const App: React.FC = () => {
|
||||
sessionManagement.setSessionSearchQuery('');
|
||||
}}
|
||||
onClose={() => sessionManagement.setShowSessionSelector(false)}
|
||||
hasMore={sessionManagement.hasMore}
|
||||
isLoading={sessionManagement.isLoading}
|
||||
onLoadMore={sessionManagement.handleLoadMoreSessions}
|
||||
/>
|
||||
|
||||
<ChatHeader
|
||||
@@ -525,122 +654,8 @@ export const App: React.FC = () => {
|
||||
<EmptyState />
|
||||
) : (
|
||||
<>
|
||||
{/* Create unified message array containing all types of messages and tool calls */}
|
||||
{(() => {
|
||||
// Regular messages
|
||||
const regularMessages = messageHandling.messages.map((msg) => ({
|
||||
type: 'message' as const,
|
||||
data: msg,
|
||||
timestamp: msg.timestamp,
|
||||
}));
|
||||
|
||||
// In-progress tool calls
|
||||
const inProgressTools = inProgressToolCalls.map((toolCall) => ({
|
||||
type: 'in-progress-tool-call' as const,
|
||||
data: toolCall,
|
||||
timestamp: toolCall.timestamp || Date.now(),
|
||||
}));
|
||||
|
||||
// Completed tool calls
|
||||
const completedTools = completedToolCalls
|
||||
.filter(hasToolCallOutput)
|
||||
.map((toolCall) => ({
|
||||
type: 'completed-tool-call' as const,
|
||||
data: toolCall,
|
||||
timestamp: toolCall.timestamp || Date.now(),
|
||||
}));
|
||||
|
||||
// Merge and sort by timestamp to ensure messages and tool calls are interleaved
|
||||
const allMessages = [
|
||||
...regularMessages,
|
||||
...inProgressTools,
|
||||
...completedTools,
|
||||
].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
|
||||
|
||||
console.log('[App] allMessages:', allMessages);
|
||||
|
||||
return allMessages.map((item, index) => {
|
||||
switch (item.type) {
|
||||
case 'message': {
|
||||
const msg = item.data as TextMessage;
|
||||
const handleFileClick = (path: string) => {
|
||||
vscode.postMessage({
|
||||
type: 'openFile',
|
||||
data: { path },
|
||||
});
|
||||
};
|
||||
|
||||
if (msg.role === 'thinking') {
|
||||
return (
|
||||
<ThinkingMessage
|
||||
key={`message-${index}`}
|
||||
content={msg.content || ''}
|
||||
timestamp={msg.timestamp || 0}
|
||||
onFileClick={handleFileClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (msg.role === 'user') {
|
||||
return (
|
||||
<UserMessage
|
||||
key={`message-${index}`}
|
||||
content={msg.content || ''}
|
||||
timestamp={msg.timestamp || 0}
|
||||
onFileClick={handleFileClick}
|
||||
fileContext={msg.fileContext}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const content = (msg.content || '').trim();
|
||||
if (
|
||||
content === 'Interrupted' ||
|
||||
content === 'Tool interrupted'
|
||||
) {
|
||||
return (
|
||||
<InterruptedMessage
|
||||
key={`message-${index}`}
|
||||
text={content}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<AssistantMessage
|
||||
key={`message-${index}`}
|
||||
content={content}
|
||||
timestamp={msg.timestamp || 0}
|
||||
onFileClick={handleFileClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// case 'in-progress-tool-call':
|
||||
// return (
|
||||
// <InProgressToolCall
|
||||
// key={`in-progress-${(item.data as ToolCallData).toolCallId}`}
|
||||
// toolCall={item.data as ToolCallData}
|
||||
// // onFileClick={handleFileClick}
|
||||
// />
|
||||
// );
|
||||
|
||||
case 'in-progress-tool-call':
|
||||
case 'completed-tool-call':
|
||||
return (
|
||||
<ToolCall
|
||||
key={`completed-${(item.data as ToolCallData).toolCallId}`}
|
||||
toolCall={item.data as ToolCallData}
|
||||
// onFileClick={handleFileClick}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
})()}
|
||||
{/* Render all messages and tool calls */}
|
||||
{renderMessages()}
|
||||
|
||||
{/* Changed to push each plan as a historical toolcall in useWebViewMessages to avoid duplicate display of the latest block */}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { WebViewContent } from '../webview/WebViewContent.js';
|
||||
import { CliInstaller } from '../cli/cliInstaller.js';
|
||||
import { getFileName } from './utils/webviewUtils.js';
|
||||
import { authMethod } from '../auth/index.js';
|
||||
import { runQwenCodeCommand } from '../commands/index.js';
|
||||
|
||||
export class WebViewProvider {
|
||||
private panelManager: PanelManager;
|
||||
@@ -25,6 +26,8 @@ export class WebViewProvider {
|
||||
private authStateManager: AuthStateManager;
|
||||
private disposables: vscode.Disposable[] = [];
|
||||
private agentInitialized = false; // Track if agent has been initialized
|
||||
// Control whether to auto-restore last session on the very first connect of this panel
|
||||
private autoRestoreOnFirstConnect = true;
|
||||
|
||||
constructor(
|
||||
context: vscode.ExtensionContext,
|
||||
@@ -239,6 +242,13 @@ export class WebViewProvider {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppress auto-restore once for this panel (used by "New Chat Tab").
|
||||
*/
|
||||
suppressAutoRestoreOnce(): void {
|
||||
this.autoRestoreOnFirstConnect = false;
|
||||
}
|
||||
|
||||
async show(): Promise<void> {
|
||||
const panel = this.panelManager.getPanel();
|
||||
|
||||
@@ -587,6 +597,14 @@ export class WebViewProvider {
|
||||
'[WebViewProvider] Force re-login completed successfully',
|
||||
);
|
||||
|
||||
// Ensure auth state is saved after successful re-login
|
||||
if (this.authStateManager) {
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
||||
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
|
||||
await this.authStateManager.saveAuthState(workingDir, authMethod);
|
||||
console.log('[WebViewProvider] Auth state saved after re-login');
|
||||
}
|
||||
|
||||
// Send success notification to WebView
|
||||
this.sendMessageToWebView({
|
||||
type: 'loginSuccess',
|
||||
@@ -681,53 +699,139 @@ export class WebViewProvider {
|
||||
authMethod,
|
||||
);
|
||||
if (hasValidAuth) {
|
||||
console.log(
|
||||
'[WebViewProvider] Found valid cached auth, attempting session restoration',
|
||||
);
|
||||
const allowAutoRestore = this.autoRestoreOnFirstConnect;
|
||||
// Reset for subsequent connects (only once per panel lifecycle unless set again)
|
||||
this.autoRestoreOnFirstConnect = true;
|
||||
|
||||
if (allowAutoRestore) {
|
||||
console.log(
|
||||
'[WebViewProvider] Valid auth found, attempting auto-restore of last session...',
|
||||
);
|
||||
try {
|
||||
const page = await this.agentManager.getSessionListPaged({
|
||||
size: 1,
|
||||
});
|
||||
const item = page.sessions[0] as
|
||||
| { sessionId?: string; id?: string; cwd?: string }
|
||||
| undefined;
|
||||
if (item && (item.sessionId || item.id)) {
|
||||
const targetId = (item.sessionId || item.id) as string;
|
||||
await this.agentManager.loadSessionViaAcp(
|
||||
targetId,
|
||||
(item.cwd as string | undefined) ?? workingDir,
|
||||
);
|
||||
|
||||
this.messageHandler.setCurrentConversationId(targetId);
|
||||
const messages =
|
||||
await this.agentManager.getSessionMessages(targetId);
|
||||
|
||||
// Even if messages array is empty, we should still switch to the session
|
||||
// This ensures we don't lose the session context
|
||||
this.sendMessageToWebView({
|
||||
type: 'qwenSessionSwitched',
|
||||
data: { sessionId: targetId, messages },
|
||||
});
|
||||
console.log(
|
||||
'[WebViewProvider] Auto-restored last session:',
|
||||
targetId,
|
||||
);
|
||||
|
||||
// Ensure auth state is saved after successful session restore
|
||||
if (this.authStateManager) {
|
||||
await this.authStateManager.saveAuthState(
|
||||
workingDir,
|
||||
authMethod,
|
||||
);
|
||||
console.log(
|
||||
'[WebViewProvider] Auth state saved after session restore',
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
'[WebViewProvider] No sessions to auto-restore, creating new session',
|
||||
);
|
||||
} catch (restoreError) {
|
||||
console.warn(
|
||||
'[WebViewProvider] Auto-restore failed, will create a new session:',
|
||||
restoreError,
|
||||
);
|
||||
|
||||
// Try to get session messages anyway, even if loadSessionViaAcp failed
|
||||
// This can happen if the session exists locally but failed to load in the CLI
|
||||
try {
|
||||
const page = await this.agentManager.getSessionListPaged({
|
||||
size: 1,
|
||||
});
|
||||
const item = page.sessions[0] as
|
||||
| { sessionId?: string; id?: string }
|
||||
| undefined;
|
||||
if (item && (item.sessionId || item.id)) {
|
||||
const targetId = (item.sessionId || item.id) as string;
|
||||
const messages =
|
||||
await this.agentManager.getSessionMessages(targetId);
|
||||
|
||||
// Switch to the session with whatever messages we could get
|
||||
this.messageHandler.setCurrentConversationId(targetId);
|
||||
this.sendMessageToWebView({
|
||||
type: 'qwenSessionSwitched',
|
||||
data: { sessionId: targetId, messages },
|
||||
});
|
||||
console.log(
|
||||
'[WebViewProvider] Partially restored last session:',
|
||||
targetId,
|
||||
);
|
||||
|
||||
// Ensure auth state is saved after partial session restore
|
||||
if (this.authStateManager) {
|
||||
await this.authStateManager.saveAuthState(
|
||||
workingDir,
|
||||
authMethod,
|
||||
);
|
||||
console.log(
|
||||
'[WebViewProvider] Auth state saved after partial session restore',
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
console.warn(
|
||||
'[WebViewProvider] Fallback session restore also failed:',
|
||||
fallbackError,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
'[WebViewProvider] Auto-restore suppressed for this panel',
|
||||
);
|
||||
}
|
||||
|
||||
// Create a fresh ACP session (no auto-restore or restore failed)
|
||||
try {
|
||||
// Try to create a session (this will use cached auth)
|
||||
const sessionId = await this.agentManager.createNewSession(
|
||||
await this.agentManager.createNewSession(
|
||||
workingDir,
|
||||
this.authStateManager,
|
||||
);
|
||||
console.log('[WebViewProvider] ACP session created successfully');
|
||||
|
||||
if (sessionId) {
|
||||
// Ensure auth state is saved after successful session creation
|
||||
if (this.authStateManager) {
|
||||
await this.authStateManager.saveAuthState(workingDir, authMethod);
|
||||
console.log(
|
||||
'[WebViewProvider] ACP session restored successfully with ID:',
|
||||
sessionId,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
'[WebViewProvider] ACP session restoration returned no session ID',
|
||||
'[WebViewProvider] Auth state saved after session creation',
|
||||
);
|
||||
}
|
||||
} catch (restoreError) {
|
||||
console.warn(
|
||||
'[WebViewProvider] Failed to restore ACP session:',
|
||||
restoreError,
|
||||
} catch (sessionError) {
|
||||
console.error(
|
||||
'[WebViewProvider] Failed to create ACP session:',
|
||||
sessionError,
|
||||
);
|
||||
vscode.window.showWarningMessage(
|
||||
`Failed to create ACP session: ${sessionError}. You may need to authenticate first.`,
|
||||
);
|
||||
// Clear invalid auth cache
|
||||
await this.authStateManager.clearAuthState();
|
||||
|
||||
// Fall back to creating a new session
|
||||
try {
|
||||
await this.agentManager.createNewSession(
|
||||
workingDir,
|
||||
this.authStateManager,
|
||||
);
|
||||
console.log(
|
||||
'[WebViewProvider] ACP session created successfully after restore failure',
|
||||
);
|
||||
} catch (sessionError) {
|
||||
console.error(
|
||||
'[WebViewProvider] Failed to create ACP session:',
|
||||
sessionError,
|
||||
);
|
||||
vscode.window.showWarningMessage(
|
||||
`Failed to create ACP session: ${sessionError}. You may need to authenticate first.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
@@ -1067,7 +1171,7 @@ export class WebViewProvider {
|
||||
if (useTerminal) {
|
||||
// In terminal mode, execute the runQwenCode command to open a new terminal
|
||||
try {
|
||||
await vscode.commands.executeCommand('qwen-code.runQwenCode');
|
||||
await vscode.commands.executeCommand(runQwenCodeCommand);
|
||||
console.log('[WebViewProvider] Opened new terminal session');
|
||||
} catch (error) {
|
||||
console.error(
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* In-progress tool call component - displays active tool calls with Claude Code style
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { ToolCallData } from './toolcalls/shared/types.js';
|
||||
import { FileLink } from './ui/FileLink.js';
|
||||
import { useVSCode } from '../hooks/useVSCode.js';
|
||||
import { handleOpenDiff } from '../utils/diffUtils.js';
|
||||
|
||||
interface InProgressToolCallProps {
|
||||
toolCall: ToolCallData;
|
||||
onFileClick?: (path: string, line?: number | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the kind name to a readable label
|
||||
*/
|
||||
const formatKind = (kind: string): string => {
|
||||
const kindMap: Record<string, string> = {
|
||||
read: 'Read',
|
||||
write: 'Write',
|
||||
edit: 'Edit',
|
||||
execute: 'Execute',
|
||||
bash: 'Execute',
|
||||
command: 'Execute',
|
||||
search: 'Search',
|
||||
grep: 'Search',
|
||||
glob: 'Search',
|
||||
find: 'Search',
|
||||
think: 'Think',
|
||||
thinking: 'Think',
|
||||
fetch: 'Fetch',
|
||||
delete: 'Delete',
|
||||
move: 'Move',
|
||||
};
|
||||
|
||||
return kindMap[kind.toLowerCase()] || 'Tool Call';
|
||||
};
|
||||
|
||||
/**
|
||||
* Get file name from path
|
||||
*/
|
||||
const getFileName = (path: string): string => path.split('/').pop() || path;
|
||||
|
||||
/**
|
||||
* Component to display in-progress tool calls with Claude Code styling
|
||||
* Shows kind, file name, and file locations
|
||||
*/
|
||||
export const InProgressToolCall: React.FC<InProgressToolCallProps> = ({
|
||||
toolCall,
|
||||
onFileClick: _onFileClick,
|
||||
}) => {
|
||||
const { kind, title, locations, content } = toolCall;
|
||||
const vscode = useVSCode();
|
||||
|
||||
// Format the kind label
|
||||
const kindLabel = formatKind(kind);
|
||||
|
||||
// Map tool kind to a Tailwind text color class (Claude-like palette)
|
||||
const kindColorClass = React.useMemo(() => {
|
||||
const k = kind.toLowerCase();
|
||||
if (k === 'read') {
|
||||
return 'text-[#4ec9b0]';
|
||||
}
|
||||
if (k === 'write' || k === 'edit') {
|
||||
return 'text-[#e5c07b]';
|
||||
}
|
||||
if (k === 'execute' || k === 'bash' || k === 'command') {
|
||||
return 'text-[#c678dd]';
|
||||
}
|
||||
if (k === 'search' || k === 'grep' || k === 'glob' || k === 'find') {
|
||||
return 'text-[#61afef]';
|
||||
}
|
||||
if (k === 'think' || k === 'thinking') {
|
||||
return 'text-[#98c379]';
|
||||
}
|
||||
return 'text-[var(--app-primary-foreground)]';
|
||||
}, [kind]);
|
||||
|
||||
// Get file name from locations or title
|
||||
let fileName: string | null = null;
|
||||
let filePath: string | null = null;
|
||||
let fileLine: number | null = null;
|
||||
|
||||
if (locations && locations.length > 0) {
|
||||
fileName = getFileName(locations[0].path);
|
||||
filePath = locations[0].path;
|
||||
fileLine = locations[0].line || null;
|
||||
} else if (typeof title === 'string') {
|
||||
fileName = title;
|
||||
}
|
||||
|
||||
// Extract content text from content array
|
||||
let contentText: string | null = null;
|
||||
// Extract first diff (if present)
|
||||
let diffData: {
|
||||
path?: string;
|
||||
oldText?: string | null;
|
||||
newText?: string;
|
||||
} | null = null;
|
||||
if (content && content.length > 0) {
|
||||
// Look for text content
|
||||
for (const item of content) {
|
||||
if (item.type === 'content' && item.content?.text) {
|
||||
contentText = item.content.text;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If no text content found, look for other content types
|
||||
if (!contentText) {
|
||||
for (const item of content) {
|
||||
if (item.type === 'content' && item.content) {
|
||||
contentText = JSON.stringify(item.content, null, 2);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Look for diff content
|
||||
for (const item of content) {
|
||||
if (
|
||||
item.type === 'diff' &&
|
||||
(item.oldText !== undefined || item.newText !== undefined)
|
||||
) {
|
||||
diffData = {
|
||||
path: item.path,
|
||||
oldText: item.oldText ?? null,
|
||||
newText: item.newText,
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle open diff
|
||||
const handleOpenDiffInternal = () => {
|
||||
if (!diffData) {
|
||||
return;
|
||||
}
|
||||
const path = diffData.path || filePath || '';
|
||||
handleOpenDiff(vscode, path, diffData.oldText, diffData.newText);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative pl-[30px] py-2 select-text toolcall-container in-progress-toolcall">
|
||||
<div className="toolcall-content-wrapper flex flex-col gap-1 min-w-0 max-w-full">
|
||||
<div className="flex items-center gap-2 relative min-w-0 toolcall-header">
|
||||
<span
|
||||
className={`text-[14px] leading-none font-bold ${kindColorClass}`}
|
||||
>
|
||||
{kindLabel}
|
||||
</span>
|
||||
{filePath && (
|
||||
<FileLink
|
||||
path={filePath}
|
||||
line={fileLine ?? undefined}
|
||||
showFullPath={false}
|
||||
className="text-[14px]"
|
||||
/>
|
||||
)}
|
||||
{!filePath && fileName && (
|
||||
<span className="text-[14px] leading-none text-[var(--app-secondary-foreground)]">
|
||||
{fileName}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{diffData && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenDiffInternal}
|
||||
className="text-[11px] px-2 py-0.5 border border-[var(--app-input-border)] rounded-small text-[var(--app-primary-foreground)] bg-transparent hover:bg-[var(--app-ghost-button-hover-background)] cursor-pointer"
|
||||
>
|
||||
Open Diff
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{contentText && (
|
||||
<div className="text-[var(--app-secondary-foreground)]">
|
||||
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1">
|
||||
<span className="flex-shrink-0 relative top-[-0.1em]">⎿</span>
|
||||
<span className="toolcall-content-text flex-shrink-0 w-full">
|
||||
{contentText}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -135,7 +135,8 @@
|
||||
border: 1px solid var(--app-primary-border-color);
|
||||
border-radius: var(--corner-radius-small, 4px);
|
||||
padding: 0.2em 0.4em;
|
||||
white-space: nowrap;
|
||||
white-space: pre-wrap; /* 支持自动换行 */
|
||||
word-break: break-word; /* 在必要时断词 */
|
||||
}
|
||||
|
||||
.markdown-content pre {
|
||||
@@ -207,7 +208,8 @@
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
white-space: pre;
|
||||
white-space: pre-wrap; /* 支持自动换行 */
|
||||
word-break: break-word; /* 在必要时断词 */
|
||||
}
|
||||
|
||||
.markdown-content .file-path-link {
|
||||
|
||||
@@ -19,11 +19,12 @@ interface MarkdownRendererProps {
|
||||
/**
|
||||
* Regular expressions for parsing content
|
||||
*/
|
||||
// Match absolute file paths like: /path/to/file.ts or C:\path\to\file.ts
|
||||
const FILE_PATH_REGEX =
|
||||
/([a-zA-Z]:)?([/\\][\w\-. ]+)+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)/gi;
|
||||
// Match file paths with optional line numbers like: path/file.ts#7-14 or path/file.ts#7
|
||||
/(?:[a-zA-Z]:)?[/\\](?:[\w\-. ]+[/\\])+[\w\-. ]+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)/gi;
|
||||
// Match file paths with optional line numbers like: /path/to/file.ts#7-14 or C:\path\to\file.ts#7
|
||||
const FILE_PATH_WITH_LINES_REGEX =
|
||||
/([a-zA-Z]:)?([/\\][\w\-. ]+)+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)#(\d+)(?:-(\d+))?/gi;
|
||||
/(?:[a-zA-Z]:)?[/\\](?:[\w\-. ]+[/\\])+[\w\-. ]+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)#(\d+)(?:-(\d+))?/gi;
|
||||
|
||||
/**
|
||||
* MarkdownRenderer component - renders markdown content with enhanced features
|
||||
@@ -166,9 +167,22 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||
const href = a.getAttribute('href') || '';
|
||||
const text = (a.textContent || '').trim();
|
||||
|
||||
// Helper function to check if a string looks like a code reference
|
||||
const isCodeReference = (str: string): boolean => {
|
||||
// Check if it looks like a code reference (e.g., module.property)
|
||||
// Patterns like "vscode.contribution", "module.submodule.function"
|
||||
const codeRefPattern = /^[a-zA-Z_$][\w$]*(\.[a-zA-Z_$][\w$]*)+$/;
|
||||
return codeRefPattern.test(str);
|
||||
};
|
||||
|
||||
// If linkify turned a bare filename into http://<filename>, convert it back
|
||||
const httpMatch = href.match(/^https?:\/\/(.+)$/i);
|
||||
if (httpMatch && BARE_FILE_REGEX.test(text) && httpMatch[1] === text) {
|
||||
// Skip if it looks like a code reference
|
||||
if (isCodeReference(text)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Treat as a file link instead of external URL
|
||||
const filePath = text; // no leading slash
|
||||
a.classList.add('file-path-link');
|
||||
@@ -182,6 +196,12 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||
if (/^(https?|mailto|ftp|data):/i.test(href)) return;
|
||||
|
||||
const candidate = href || text;
|
||||
|
||||
// Skip if it looks like a code reference
|
||||
if (isCodeReference(candidate)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
FILE_PATH_WITH_LINES_NO_G.test(candidate) ||
|
||||
FILE_PATH_NO_G.test(candidate)
|
||||
@@ -194,6 +214,14 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to check if a string looks like a code reference
|
||||
const isCodeReference = (str: string): boolean => {
|
||||
// Check if it looks like a code reference (e.g., module.property)
|
||||
// Patterns like "vscode.contribution", "module.submodule.function"
|
||||
const codeRefPattern = /^[a-zA-Z_$][\w$]*(\.[a-zA-Z_$][\w$]*)+$/;
|
||||
return codeRefPattern.test(str);
|
||||
};
|
||||
|
||||
const walk = (node: Node) => {
|
||||
// Do not transform inside existing anchors
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
@@ -218,6 +246,20 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||
while ((m = union.exec(text))) {
|
||||
const matchText = m[0];
|
||||
const idx = m.index;
|
||||
|
||||
// Skip if it looks like a code reference
|
||||
if (isCodeReference(matchText)) {
|
||||
// Just add the text as-is without creating a link
|
||||
if (idx > lastIndex) {
|
||||
frag.appendChild(
|
||||
document.createTextNode(text.slice(lastIndex, idx)),
|
||||
);
|
||||
}
|
||||
frag.appendChild(document.createTextNode(matchText));
|
||||
lastIndex = idx + matchText.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (idx > lastIndex) {
|
||||
frag.appendChild(
|
||||
document.createTextNode(text.slice(lastIndex, idx)),
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import type { PermissionOption, ToolCall } from './PermissionRequest.js';
|
||||
|
||||
interface PermissionDrawerProps {
|
||||
isOpen: boolean;
|
||||
options: PermissionOption[];
|
||||
toolCall: ToolCall;
|
||||
onResponse: (optionId: string) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission drawer component - Claude Code style bottom sheet
|
||||
*/
|
||||
export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
|
||||
isOpen,
|
||||
options,
|
||||
toolCall,
|
||||
onResponse,
|
||||
onClose,
|
||||
}) => {
|
||||
const [focusedIndex, setFocusedIndex] = useState(0);
|
||||
const [customMessage, setCustomMessage] = useState('');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// 将自定义输入的 ref 类型修正为 HTMLInputElement,避免后续强转
|
||||
const customInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
console.log('PermissionDrawer rendered with isOpen:', isOpen, toolCall);
|
||||
// Prefer file name from locations, fall back to content[].path if present
|
||||
const getAffectedFileName = (): string => {
|
||||
const fromLocations = toolCall.locations?.[0]?.path;
|
||||
if (fromLocations) {
|
||||
return fromLocations.split('/').pop() || fromLocations;
|
||||
}
|
||||
// Some tool calls (e.g. write/edit with diff content) only include path in content
|
||||
const fromContent = Array.isArray(toolCall.content)
|
||||
? (
|
||||
toolCall.content.find(
|
||||
(c: unknown) =>
|
||||
typeof c === 'object' &&
|
||||
c !== null &&
|
||||
'path' in (c as Record<string, unknown>),
|
||||
) as { path?: unknown } | undefined
|
||||
)?.path
|
||||
: undefined;
|
||||
if (typeof fromContent === 'string' && fromContent.length > 0) {
|
||||
return fromContent.split('/').pop() || fromContent;
|
||||
}
|
||||
return 'file';
|
||||
};
|
||||
|
||||
// Get the title for the permission request
|
||||
const getTitle = () => {
|
||||
if (toolCall.kind === 'edit' || toolCall.kind === 'write') {
|
||||
const fileName = getAffectedFileName();
|
||||
return (
|
||||
<>
|
||||
Make this edit to{' '}
|
||||
<span className="font-mono text-[var(--app-primary-foreground)]">
|
||||
{fileName}
|
||||
</span>
|
||||
?
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (toolCall.kind === 'execute' || toolCall.kind === 'bash') {
|
||||
return 'Allow this bash command?';
|
||||
}
|
||||
if (toolCall.kind === 'read') {
|
||||
const fileName = getAffectedFileName();
|
||||
return (
|
||||
<>
|
||||
Allow read from{' '}
|
||||
<span className="font-mono text-[var(--app-primary-foreground)]">
|
||||
{fileName}
|
||||
</span>
|
||||
?
|
||||
</>
|
||||
);
|
||||
}
|
||||
return toolCall.title || 'Permission Required';
|
||||
};
|
||||
|
||||
// Handle keyboard navigation
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Number keys 1-9 for quick select
|
||||
const numMatch = e.key.match(/^[1-9]$/);
|
||||
if (
|
||||
numMatch &&
|
||||
!customInputRef.current?.contains(document.activeElement)
|
||||
) {
|
||||
const index = parseInt(e.key, 10) - 1;
|
||||
if (index < options.length) {
|
||||
e.preventDefault();
|
||||
onResponse(options[index].optionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Arrow keys for navigation
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const totalItems = options.length + 1; // +1 for custom input
|
||||
if (e.key === 'ArrowDown') {
|
||||
setFocusedIndex((prev) => (prev + 1) % totalItems);
|
||||
} else {
|
||||
setFocusedIndex((prev) => (prev - 1 + totalItems) % totalItems);
|
||||
}
|
||||
}
|
||||
|
||||
// Enter to select
|
||||
if (
|
||||
e.key === 'Enter' &&
|
||||
!customInputRef.current?.contains(document.activeElement)
|
||||
) {
|
||||
e.preventDefault();
|
||||
if (focusedIndex < options.length) {
|
||||
onResponse(options[focusedIndex].optionId);
|
||||
}
|
||||
}
|
||||
|
||||
// Escape to cancel permission and close (align with CLI/Claude behavior)
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
const rejectOptionId =
|
||||
options.find((o) => o.kind.includes('reject'))?.optionId ||
|
||||
options.find((o) => o.optionId === 'cancel')?.optionId ||
|
||||
'cancel';
|
||||
onResponse(rejectOptionId);
|
||||
if (onClose) onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, options, onResponse, onClose, focusedIndex]);
|
||||
|
||||
// Focus container when opened
|
||||
useEffect(() => {
|
||||
if (isOpen && containerRef.current) {
|
||||
containerRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Reset focus to the first option when the drawer opens or the options change
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setFocusedIndex(0);
|
||||
}
|
||||
}, [isOpen, options.length]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-x-0 bottom-0 z-[1000] p-2">
|
||||
{/* Main container */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative flex flex-col rounded-large border p-2 outline-none animate-slide-up"
|
||||
style={{
|
||||
backgroundColor: 'var(--app-input-secondary-background)',
|
||||
borderColor: 'var(--app-input-border)',
|
||||
}}
|
||||
tabIndex={0}
|
||||
data-focused-index={focusedIndex}
|
||||
>
|
||||
{/* Background layer */}
|
||||
<div
|
||||
className="p-2 absolute inset-0 rounded-large"
|
||||
style={{ backgroundColor: 'var(--app-input-background)' }}
|
||||
/>
|
||||
|
||||
{/* Title + Description (from toolCall.title) */}
|
||||
<div className="relative z-[1] px-1 text-[1.1em] text-[var(--app-primary-foreground)] flex flex-col min-h-0">
|
||||
<div className="font-bold text-[var(--app-primary-foreground)] mb-0.5">
|
||||
{getTitle()}
|
||||
</div>
|
||||
{(toolCall.kind === 'edit' ||
|
||||
toolCall.kind === 'write' ||
|
||||
toolCall.kind === 'read' ||
|
||||
toolCall.kind === 'execute' ||
|
||||
toolCall.kind === 'bash') &&
|
||||
toolCall.title && (
|
||||
<div
|
||||
/* 13px,常规字重;正常空白折行 + 长词断行;最多 3 行溢出省略 */
|
||||
className="text-[13px] font-normal text-[var(--app-secondary-foreground)] opacity-90 font-mono whitespace-normal break-words q-line-clamp-3 mb-2"
|
||||
title={toolCall.title}
|
||||
>
|
||||
{toolCall.title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="relative z-[1] flex flex-col gap-1 px-1 pb-1">
|
||||
{options.map((option, index) => {
|
||||
const isFocused = focusedIndex === index;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.optionId}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 text-left w-full box-border rounded-[4px] border-0 shadow-[inset_0_0_0_1px_var(--app-transparent-inner-border)] transition-colors duration-150 text-[var(--app-primary-foreground)] hover:bg-[var(--app-list-hover-background)] ${
|
||||
isFocused
|
||||
? 'text-[var(--app-list-active-foreground)] bg-[var(--app-list-active-background)] hover:text-[var(--app-button-foreground)] hover:font-bold hover:relative hover:border-0'
|
||||
: 'hover:bg-[var(--app-button-background)] hover:text-[var(--app-button-foreground)] hover:font-bold hover:relative hover:border-0'
|
||||
}`}
|
||||
onClick={() => onResponse(option.optionId)}
|
||||
onMouseEnter={() => setFocusedIndex(index)}
|
||||
>
|
||||
{/* Number badge */}
|
||||
{/* Plain number badge without hover background */}
|
||||
<span className="inline-flex items-center justify-center min-w-[10px] h-5 font-semibold">
|
||||
{index + 1}
|
||||
</span>
|
||||
{/* Option text */}
|
||||
<span className="font-semibold">{option.name}</span>
|
||||
|
||||
{/* Always badge */}
|
||||
{/* {isAlways && <span className="text-sm">⚡</span>} */}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Custom message input (extracted component) */}
|
||||
{(() => {
|
||||
const isFocused = focusedIndex === options.length;
|
||||
const rejectOptionId = options.find((o) =>
|
||||
o.kind.includes('reject'),
|
||||
)?.optionId;
|
||||
return (
|
||||
<CustomMessageInputRow
|
||||
isFocused={isFocused}
|
||||
customMessage={customMessage}
|
||||
setCustomMessage={setCustomMessage}
|
||||
onFocusRow={() => setFocusedIndex(options.length)}
|
||||
onSubmitReject={() => {
|
||||
if (rejectOptionId) onResponse(rejectOptionId);
|
||||
}}
|
||||
inputRef={customInputRef}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Moved slide-up keyframes to Tailwind theme (tailwind.config.js) */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* CustomMessageInputRow: 复用的自定义输入行组件(无 hooks)
|
||||
*/
|
||||
interface CustomMessageInputRowProps {
|
||||
isFocused: boolean;
|
||||
customMessage: string;
|
||||
setCustomMessage: (val: string) => void;
|
||||
onFocusRow: () => void; // 鼠标移入或输入框 focus 时设置焦点
|
||||
onSubmitReject: () => void; // Enter 提交时触发(选择 reject 选项)
|
||||
inputRef: React.RefObject<HTMLInputElement>;
|
||||
}
|
||||
|
||||
const CustomMessageInputRow: React.FC<CustomMessageInputRowProps> = ({
|
||||
isFocused,
|
||||
customMessage,
|
||||
setCustomMessage,
|
||||
onFocusRow,
|
||||
onSubmitReject,
|
||||
inputRef,
|
||||
}) => (
|
||||
<div
|
||||
// 无过渡:hover 样式立即生效;输入行不加 hover 背景,也不加粗文字
|
||||
className={`flex items-center gap-2 px-2 py-1.5 text-left w-full box-border rounded-[4px] border-0 shadow-[inset_0_0_0_1px_var(--app-transparent-inner-border)] cursor-text text-[var(--app-primary-foreground)] ${
|
||||
isFocused ? 'text-[var(--app-list-active-foreground)]' : ''
|
||||
}`}
|
||||
onMouseEnter={onFocusRow}
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
{/* 输入行不显示序号徽标 */}
|
||||
{/* Input field */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Tell Qwen what to do instead"
|
||||
spellCheck={false}
|
||||
className="flex-1 bg-transparent border-0 outline-none text-sm placeholder:opacity-70"
|
||||
style={{ color: 'var(--app-input-foreground)' }}
|
||||
value={customMessage}
|
||||
onChange={(e) => setCustomMessage(e.target.value)}
|
||||
onFocus={onFocusRow}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && customMessage.trim()) {
|
||||
e.preventDefault();
|
||||
onSubmitReject();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export interface PermissionOption {
|
||||
name: string;
|
||||
kind: string;
|
||||
optionId: string;
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
title?: string;
|
||||
kind?: string;
|
||||
toolCallId?: string;
|
||||
rawInput?: {
|
||||
command?: string;
|
||||
description?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
content?: Array<{
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
locations?: Array<{
|
||||
path: string;
|
||||
line?: number | null;
|
||||
}>;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface PermissionRequestProps {
|
||||
options: PermissionOption[];
|
||||
toolCall: ToolCall;
|
||||
onResponse: (optionId: string) => void;
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export interface PermissionOption {
|
||||
name: string;
|
||||
kind: string;
|
||||
optionId: string;
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
title?: string;
|
||||
kind?: string;
|
||||
toolCallId?: string;
|
||||
rawInput?: {
|
||||
command?: string;
|
||||
description?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
content?: Array<{
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
locations?: Array<{
|
||||
path: string;
|
||||
line?: number | null;
|
||||
}>;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface PermissionRequestProps {
|
||||
options: PermissionOption[];
|
||||
toolCall: ToolCall;
|
||||
onResponse: (optionId: string) => void;
|
||||
}
|
||||
|
||||
// export const PermissionRequest: React.FC<PermissionRequestProps> = ({
|
||||
// options,
|
||||
// toolCall,
|
||||
// onResponse,
|
||||
// }) => {
|
||||
// const [selected, setSelected] = useState<string | null>(null);
|
||||
// const [isResponding, setIsResponding] = useState(false);
|
||||
// const [hasResponded, setHasResponded] = useState(false);
|
||||
|
||||
// const getToolInfo = () => {
|
||||
// if (!toolCall) {
|
||||
// return {
|
||||
// title: 'Permission Request',
|
||||
// description: 'Agent is requesting permission',
|
||||
// icon: '🔐',
|
||||
// };
|
||||
// }
|
||||
|
||||
// const displayTitle =
|
||||
// toolCall.title || toolCall.rawInput?.description || 'Permission Request';
|
||||
|
||||
// const kindIcons: Record<string, string> = {
|
||||
// edit: '✏️',
|
||||
// read: '📖',
|
||||
// fetch: '🌐',
|
||||
// execute: '⚡',
|
||||
// delete: '🗑️',
|
||||
// move: '📦',
|
||||
// search: '🔍',
|
||||
// think: '💭',
|
||||
// other: '🔧',
|
||||
// };
|
||||
|
||||
// return {
|
||||
// title: displayTitle,
|
||||
// icon: kindIcons[toolCall.kind || 'other'] || '🔧',
|
||||
// };
|
||||
// };
|
||||
|
||||
// const { title, icon } = getToolInfo();
|
||||
|
||||
// const handleConfirm = async () => {
|
||||
// if (hasResponded || !selected) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// setIsResponding(true);
|
||||
// try {
|
||||
// await onResponse(selected);
|
||||
// setHasResponded(true);
|
||||
// } catch (error) {
|
||||
// console.error('Error confirming permission:', error);
|
||||
// } finally {
|
||||
// setIsResponding(false);
|
||||
// }
|
||||
// };
|
||||
|
||||
// if (!toolCall) {
|
||||
// return null;
|
||||
// }
|
||||
|
||||
// return (
|
||||
// <div className="permission-request-card">
|
||||
// <div className="permission-card-body">
|
||||
// {/* Header with icon and title */}
|
||||
// <div className="permission-header">
|
||||
// <div className="permission-icon-wrapper">
|
||||
// <span className="permission-icon">{icon}</span>
|
||||
// </div>
|
||||
// <div className="permission-info">
|
||||
// <div className="permission-title">{title}</div>
|
||||
// <div className="permission-subtitle">Waiting for your approval</div>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// {/* Show command if available */}
|
||||
// {(toolCall.rawInput?.command || toolCall.title) && (
|
||||
// <div className="permission-command-section">
|
||||
// <div className="permission-command-header">
|
||||
// <div className="permission-command-status">
|
||||
// <span className="permission-command-dot">●</span>
|
||||
// <span className="permission-command-label">COMMAND</span>
|
||||
// </div>
|
||||
// </div>
|
||||
// <div className="permission-command-content">
|
||||
// <div className="permission-command-input-section">
|
||||
// <span className="permission-command-io-label">IN</span>
|
||||
// <code className="permission-command-code">
|
||||
// {toolCall.rawInput?.command || toolCall.title}
|
||||
// </code>
|
||||
// </div>
|
||||
// {toolCall.rawInput?.description && (
|
||||
// <div className="permission-command-description">
|
||||
// {toolCall.rawInput.description}
|
||||
// </div>
|
||||
// )}
|
||||
// </div>
|
||||
// </div>
|
||||
// )}
|
||||
|
||||
// {/* Show file locations if available */}
|
||||
// {toolCall.locations && toolCall.locations.length > 0 && (
|
||||
// <div className="permission-locations-section">
|
||||
// <div className="permission-locations-label">Affected Files</div>
|
||||
// {toolCall.locations.map((location, index) => (
|
||||
// <div key={index} className="permission-location-item">
|
||||
// <span className="permission-location-icon">📄</span>
|
||||
// <span className="permission-location-path">
|
||||
// {location.path}
|
||||
// </span>
|
||||
// {location.line !== null && location.line !== undefined && (
|
||||
// <span className="permission-location-line">
|
||||
// ::{location.line}
|
||||
// </span>
|
||||
// )}
|
||||
// </div>
|
||||
// ))}
|
||||
// </div>
|
||||
// )}
|
||||
|
||||
// {/* Options */}
|
||||
// {!hasResponded && (
|
||||
// <div className="permission-options-section">
|
||||
// <div className="permission-options-label">Choose an action:</div>
|
||||
// <div className="permission-options-list">
|
||||
// {options && options.length > 0 ? (
|
||||
// options.map((option, index) => {
|
||||
// const isSelected = selected === option.optionId;
|
||||
// const isAllow = option.kind.includes('allow');
|
||||
// const isAlways = option.kind.includes('always');
|
||||
|
||||
// return (
|
||||
// <label
|
||||
// key={option.optionId}
|
||||
// className={`permission-option ${isSelected ? 'selected' : ''} ${
|
||||
// isAllow ? 'allow' : 'reject'
|
||||
// } ${isAlways ? 'always' : ''}`}
|
||||
// >
|
||||
// <input
|
||||
// type="radio"
|
||||
// name="permission"
|
||||
// value={option.optionId}
|
||||
// checked={isSelected}
|
||||
// onChange={() => setSelected(option.optionId)}
|
||||
// className="permission-radio"
|
||||
// />
|
||||
// <span className="permission-option-content">
|
||||
// <span className="permission-option-number">
|
||||
// {index + 1}
|
||||
// </span>
|
||||
// {isAlways && (
|
||||
// <span className="permission-always-badge">⚡</span>
|
||||
// )}
|
||||
// {option.name}
|
||||
// </span>
|
||||
// </label>
|
||||
// );
|
||||
// })
|
||||
// ) : (
|
||||
// <div className="permission-no-options">
|
||||
// No options available
|
||||
// </div>
|
||||
// )}
|
||||
// </div>
|
||||
// <div className="permission-actions">
|
||||
// <button
|
||||
// className="permission-confirm-button"
|
||||
// disabled={!selected || isResponding}
|
||||
// onClick={handleConfirm}
|
||||
// >
|
||||
// {isResponding ? 'Processing...' : 'Confirm'}
|
||||
// </button>
|
||||
// </div>
|
||||
// </div>
|
||||
// )}
|
||||
|
||||
// {/* Success message */}
|
||||
// {hasResponded && (
|
||||
// <div className="permission-success">
|
||||
// <span className="permission-success-icon">✓</span>
|
||||
// <span className="permission-success-text">
|
||||
// Response sent successfully
|
||||
// </span>
|
||||
// </div>
|
||||
// )}
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// };
|
||||
@@ -35,4 +35,8 @@ export type { ToolCallContent } from './toolcalls/shared/types.js';
|
||||
*/
|
||||
export const ToolCall: React.FC<{
|
||||
toolCall: import('./toolcalls/shared/types.js').ToolCallData;
|
||||
}> = ({ toolCall }) => <ToolCallRouter toolCall={toolCall} />;
|
||||
isFirst?: boolean;
|
||||
isLast?: boolean;
|
||||
}> = ({ toolCall, isFirst, isLast }) => (
|
||||
<ToolCallRouter toolCall={toolCall} isFirst={isFirst} isLast={isLast} />
|
||||
);
|
||||
|
||||
@@ -68,12 +68,12 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
|
||||
paddingLeft: '30px',
|
||||
userSelect: 'text',
|
||||
position: 'relative',
|
||||
paddingTop: '8px',
|
||||
paddingBottom: '8px',
|
||||
// paddingTop: '8px',
|
||||
// paddingBottom: '8px',
|
||||
}}
|
||||
>
|
||||
<span style={{ width: '100%' }}>
|
||||
<p
|
||||
<div
|
||||
style={{
|
||||
margin: 0,
|
||||
width: '100%',
|
||||
@@ -83,7 +83,7 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
|
||||
}}
|
||||
>
|
||||
<MessageContent content={content} onFileClick={onFileClick} />
|
||||
</p>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -67,7 +67,7 @@ export const UserMessage: React.FC<UserMessageProps> = ({
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="mr inline-flex items-center py-0 pl-1 pr-2 ml-1 gap-1 rounded-sm cursor-pointer relative opacity-50 hover:opacity-100"
|
||||
className="mr inline-flex items-center py-0 pl-1 pr-2 ml-1 gap-1 rounded-sm cursor-pointer relative opacity-50"
|
||||
onClick={() => fileContext && onFileClick?.(fileContext.filePath)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
|
||||
@@ -10,4 +10,3 @@ export { ThinkingMessage } from './ThinkingMessage.js';
|
||||
export { StreamingMessage } from './StreamingMessage.js';
|
||||
export { WaitingMessage } from './Waiting/WaitingMessage.js';
|
||||
export { InterruptedMessage } from './Waiting/InterruptedMessage.js';
|
||||
export { PlanDisplay } from '../PlanDisplay.js';
|
||||
|
||||
@@ -71,9 +71,9 @@ export const CheckboxDisplay: React.FC<CheckboxDisplayProps> = ({
|
||||
aria-hidden
|
||||
className={[
|
||||
'absolute inline-block',
|
||||
'left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2',
|
||||
'left-1/2 top-[10px] -translate-x-1/2 -translate-y-1/2',
|
||||
// Use a literal star; no icon font needed
|
||||
'text-[11px] leading-none text-[#e1c08d] select-none',
|
||||
'text-[16px] leading-none text-[#e1c08d] select-none',
|
||||
].join(' ')}
|
||||
>
|
||||
*
|
||||
|
||||
@@ -64,7 +64,7 @@ export class AuthMessageHandler extends BaseMessageHandler {
|
||||
vscode.window.showInformationMessage(
|
||||
'Please wait while we connect to Qwen Code...',
|
||||
);
|
||||
await vscode.commands.executeCommand('qwenCode.login');
|
||||
await vscode.commands.executeCommand('qwen-code.login');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AuthMessageHandler] Login failed:', error);
|
||||
|
||||
@@ -9,17 +9,8 @@
|
||||
export default {
|
||||
content: [
|
||||
// Progressive adoption strategy: Only scan newly created Tailwind components
|
||||
// './src/webview/App.tsx',
|
||||
'./src/webview/**/*.{js,jsx,ts,tsx}',
|
||||
// './src/webview/components/messages/**/*.{js,jsx,ts,tsx}',
|
||||
// './src/webview/components/toolcalls/**/*.{js,jsx,ts,tsx}',
|
||||
// './src/webview/components/InProgressToolCall.tsx',
|
||||
// './src/webview/components/MessageContent.tsx',
|
||||
// './src/webview/components/InputForm.tsx',
|
||||
// './src/webview/components/PermissionDrawer.tsx',
|
||||
// './src/webview/components/PlanDisplay.tsx',
|
||||
// './src/webview/components/session/SessionSelector.tsx',
|
||||
// './src/webview/components/messages/UserMessage.tsx',
|
||||
'./src/webview/**/**/*.{js,jsx,ts,tsx}',
|
||||
'./src/webview/components/ui/CheckboxDisplay.tsx',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
|
||||
Reference in New Issue
Block a user