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:
yiliang114
2025-12-06 21:46:14 +08:00
parent ad79b9bcab
commit 7cd26f728d
21 changed files with 1190 additions and 785 deletions

View File

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

View File

@@ -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',
);

View File

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

View File

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

View File

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

View File

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

View 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 */}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
);

View File

@@ -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;
}

View File

@@ -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>
// );
// };

View File

@@ -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} />
);

View File

@@ -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>
);

View File

@@ -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 === ' ') {

View File

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

View File

@@ -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(' ')}
>
*

View File

@@ -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);