mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
refactor(vscode-ide-companion): migrate session save to CLI /chat save command
- Replace manual checkpoint file writing with CLI's native /chat save command - Add saveCheckpointViaCommand method to use CLI's built-in save functionality - Deprecate saveSessionViaAcp as CLI doesn't support session/save ACP method - Update saveCheckpoint to delegate to CLI command for complete context preservation - Enhanced error logging in acpSessionManager session load - Mark saveSessionViaAcp as deprecated with fallback to command-based save - Fix ESLint errors: remove unused imports and catch variables, wrap case block declarations This ensures checkpoints are saved with complete session context including tool calls, leveraging CLI's native save functionality instead of manual file operations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -50,6 +50,14 @@ export class WebViewProvider {
|
||||
|
||||
// Setup agent callbacks
|
||||
this.agentManager.onStreamChunk((chunk: string) => {
|
||||
// Ignore stream chunks from background /chat save commands
|
||||
if (this.messageHandler.getIsSavingCheckpoint()) {
|
||||
console.log(
|
||||
'[WebViewProvider] Ignoring stream chunk from /chat save command',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.messageHandler.appendStreamContent(chunk);
|
||||
this.sendMessageToWebView({
|
||||
type: 'streamChunk',
|
||||
@@ -59,6 +67,14 @@ export class WebViewProvider {
|
||||
|
||||
// Setup thought chunk handler
|
||||
this.agentManager.onThoughtChunk((chunk: string) => {
|
||||
// Ignore thought chunks from background /chat save commands
|
||||
if (this.messageHandler.getIsSavingCheckpoint()) {
|
||||
console.log(
|
||||
'[WebViewProvider] Ignoring thought chunk from /chat save command',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.messageHandler.appendStreamContent(chunk);
|
||||
this.sendMessageToWebView({
|
||||
type: 'thoughtChunk',
|
||||
@@ -69,11 +85,36 @@ export class WebViewProvider {
|
||||
// Note: Tool call updates are handled in handleSessionUpdate within QwenAgentManager
|
||||
// and sent via onStreamChunk callback
|
||||
this.agentManager.onToolCall((update) => {
|
||||
// Ignore tool calls from background /chat save commands
|
||||
if (this.messageHandler.getIsSavingCheckpoint()) {
|
||||
console.log(
|
||||
'[WebViewProvider] Ignoring tool call from /chat save command',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cast update to access sessionUpdate property
|
||||
const updateData = update as unknown as Record<string, unknown>;
|
||||
|
||||
// Determine message type from sessionUpdate field
|
||||
// If sessionUpdate is missing, infer from content:
|
||||
// - If has kind/title/rawInput, it's likely initial tool_call
|
||||
// - If only has status/content updates, it's tool_call_update
|
||||
let messageType = updateData.sessionUpdate as string | undefined;
|
||||
if (!messageType) {
|
||||
// Infer type: if has kind or title, assume initial call; otherwise update
|
||||
if (updateData.kind || updateData.title || updateData.rawInput) {
|
||||
messageType = 'tool_call';
|
||||
} else {
|
||||
messageType = 'tool_call_update';
|
||||
}
|
||||
}
|
||||
|
||||
this.sendMessageToWebView({
|
||||
type: 'toolCall',
|
||||
data: {
|
||||
type: 'tool_call',
|
||||
...(update as unknown as Record<string, unknown>),
|
||||
type: messageType,
|
||||
...updateData,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -248,20 +248,46 @@ export class AcpSessionManager {
|
||||
pendingRequests: Map<number, PendingRequest<unknown>>,
|
||||
nextRequestId: { value: number },
|
||||
): Promise<AcpResponse> {
|
||||
console.log('[ACP] Loading session:', sessionId);
|
||||
const response = await this.sendRequest<AcpResponse>(
|
||||
AGENT_METHODS.session_load,
|
||||
{
|
||||
sessionId,
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
},
|
||||
child,
|
||||
pendingRequests,
|
||||
nextRequestId,
|
||||
);
|
||||
console.log('[ACP] Session load response:', response);
|
||||
return response;
|
||||
console.log('[ACP] Sending session/load request for session:', sessionId);
|
||||
console.log('[ACP] Request parameters:', {
|
||||
sessionId,
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await this.sendRequest<AcpResponse>(
|
||||
AGENT_METHODS.session_load,
|
||||
{
|
||||
sessionId,
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
},
|
||||
child,
|
||||
pendingRequests,
|
||||
nextRequestId,
|
||||
);
|
||||
|
||||
console.log(
|
||||
'[ACP] Session load response:',
|
||||
JSON.stringify(response).substring(0, 500),
|
||||
);
|
||||
|
||||
// Check if response contains an error
|
||||
if (response.error) {
|
||||
console.error('[ACP] Session load returned error:', response.error);
|
||||
} else {
|
||||
console.log('[ACP] Session load succeeded');
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[ACP] Session load request failed with exception:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,7 +23,6 @@ import type {
|
||||
} from './qwenTypes.js';
|
||||
import { QwenConnectionHandler } from './qwenConnectionHandler.js';
|
||||
import { QwenSessionUpdateHandler } from './qwenSessionUpdateHandler.js';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
export type { ChatMessage, PlanEntry, ToolCallUpdateData };
|
||||
|
||||
@@ -163,48 +162,155 @@ export class QwenAgentManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 ACP session/save 方法保存会话
|
||||
* 通过发送 /chat save 命令保存会话
|
||||
* 由于 CLI 不支持 session/save ACP 方法,我们直接发送 /chat save 命令
|
||||
*
|
||||
* @param sessionId - 会话ID
|
||||
* @param tag - 保存标签
|
||||
* @returns 保存响应
|
||||
*/
|
||||
async saveSessionViaCommand(
|
||||
sessionId: string,
|
||||
tag: string,
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
try {
|
||||
console.log(
|
||||
'[QwenAgentManager] Saving session via /chat save command:',
|
||||
sessionId,
|
||||
'with tag:',
|
||||
tag,
|
||||
);
|
||||
|
||||
// Send /chat save command as a prompt
|
||||
// The CLI will handle this as a special command
|
||||
await this.connection.sendPrompt(`/chat save "${tag}"`);
|
||||
|
||||
console.log('[QwenAgentManager] /chat save command sent successfully');
|
||||
return {
|
||||
success: true,
|
||||
message: `Session saved with tag: ${tag}`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[QwenAgentManager] /chat save command failed:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 ACP session/save 方法保存会话 (已废弃,CLI 不支持)
|
||||
*
|
||||
* @deprecated Use saveSessionViaCommand instead
|
||||
* @param sessionId - 会话ID
|
||||
* @param tag - 保存标签
|
||||
* @returns 保存响应
|
||||
*/
|
||||
async saveSessionViaAcp(
|
||||
sessionId: string,
|
||||
tag: string,
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
// Fallback to command-based save since CLI doesn't support session/save ACP method
|
||||
console.warn(
|
||||
'[QwenAgentManager] saveSessionViaAcp is deprecated, using command-based save instead',
|
||||
);
|
||||
return this.saveSessionViaCommand(sessionId, tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过发送 /chat save 命令保存会话(CLI 方式)
|
||||
* 这会调用 CLI 的原生保存功能,确保保存的内容完整
|
||||
*
|
||||
* @param tag - Checkpoint 标签
|
||||
* @returns 保存结果
|
||||
*/
|
||||
async saveCheckpointViaCommand(
|
||||
tag: string,
|
||||
): Promise<{ success: boolean; tag?: string; message?: string }> {
|
||||
try {
|
||||
console.log(
|
||||
'[QwenAgentManager] Saving session via ACP:',
|
||||
sessionId,
|
||||
'with tag:',
|
||||
tag,
|
||||
'[QwenAgentManager] ===== SAVING VIA /chat save COMMAND =====',
|
||||
);
|
||||
const response = await this.connection.saveSession(tag);
|
||||
console.log('[QwenAgentManager] Session save response:', response);
|
||||
// Extract message from response result or error
|
||||
let message = '';
|
||||
if (response?.result) {
|
||||
if (typeof response.result === 'string') {
|
||||
message = response.result;
|
||||
} else if (
|
||||
typeof response.result === 'object' &&
|
||||
response.result !== null
|
||||
) {
|
||||
// Try to get message from result object
|
||||
message =
|
||||
(response.result as { message?: string }).message ||
|
||||
JSON.stringify(response.result);
|
||||
} else {
|
||||
message = String(response.result);
|
||||
}
|
||||
} else if (response?.error) {
|
||||
message = response.error.message;
|
||||
}
|
||||
console.log('[QwenAgentManager] Tag:', tag);
|
||||
|
||||
return { success: true, message };
|
||||
// Send /chat save command as a prompt
|
||||
// The CLI will handle this as a special command and save the checkpoint
|
||||
const command = `/chat save "${tag}"`;
|
||||
console.log('[QwenAgentManager] Sending command:', command);
|
||||
|
||||
await this.connection.sendPrompt(command);
|
||||
|
||||
console.log(
|
||||
'[QwenAgentManager] Command sent, checkpoint should be saved by CLI',
|
||||
);
|
||||
|
||||
// Wait a bit for CLI to process the command
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
tag,
|
||||
message: `Checkpoint saved via CLI: ${tag}`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[QwenAgentManager] Session save via ACP failed:', error);
|
||||
console.error('[QwenAgentManager] /chat save command failed:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存会话为 checkpoint(使用 CLI 的格式)
|
||||
* 保存到 ~/.qwen/tmp/{projectHash}/checkpoint-{tag}.json
|
||||
* 同时用 sessionId 和 conversationId 保存两份,确保可以通过任一 ID 恢复
|
||||
*
|
||||
* @param messages - 当前会话消息
|
||||
* @param conversationId - Conversation ID (from VSCode extension)
|
||||
* @returns 保存结果
|
||||
*/
|
||||
async saveCheckpoint(
|
||||
messages: ChatMessage[],
|
||||
conversationId: string,
|
||||
): Promise<{ success: boolean; tag?: string; message?: string }> {
|
||||
try {
|
||||
console.log('[QwenAgentManager] ===== CHECKPOINT SAVE START =====');
|
||||
console.log('[QwenAgentManager] Conversation ID:', conversationId);
|
||||
console.log('[QwenAgentManager] Message count:', messages.length);
|
||||
console.log(
|
||||
'[QwenAgentManager] Current working dir:',
|
||||
this.currentWorkingDir,
|
||||
);
|
||||
console.log(
|
||||
'[QwenAgentManager] Current session ID (from CLI):',
|
||||
this.currentSessionId,
|
||||
);
|
||||
|
||||
// Use CLI's /chat save command instead of manually writing files
|
||||
// This ensures we save the complete session context including tool calls
|
||||
if (this.currentSessionId) {
|
||||
console.log(
|
||||
'[QwenAgentManager] Using CLI /chat save command for complete save',
|
||||
);
|
||||
return await this.saveCheckpointViaCommand(this.currentSessionId);
|
||||
} else {
|
||||
console.warn(
|
||||
'[QwenAgentManager] No current session ID, cannot use /chat save',
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
message: 'No active CLI session',
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[QwenAgentManager] ===== CHECKPOINT SAVE FAILED =====');
|
||||
console.error('[QwenAgentManager] Error:', error);
|
||||
console.error(
|
||||
'[QwenAgentManager] Error stack:',
|
||||
error instanceof Error ? error.stack : 'N/A',
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
@@ -223,37 +329,9 @@ export class QwenAgentManager {
|
||||
messages: ChatMessage[],
|
||||
sessionName: string,
|
||||
): Promise<{ success: boolean; sessionId?: string; message?: string }> {
|
||||
try {
|
||||
console.log('[QwenAgentManager] Saving session directly:', sessionName);
|
||||
|
||||
// 转换消息格式
|
||||
const qwenMessages = messages.map((msg) => ({
|
||||
id: crypto.randomUUID(),
|
||||
timestamp: new Date(msg.timestamp).toISOString(),
|
||||
type: msg.role === 'user' ? ('user' as const) : ('qwen' as const),
|
||||
content: msg.content,
|
||||
}));
|
||||
|
||||
// 保存会话
|
||||
const sessionId = await this.sessionManager.saveSession(
|
||||
qwenMessages,
|
||||
sessionName,
|
||||
this.currentWorkingDir,
|
||||
);
|
||||
|
||||
console.log('[QwenAgentManager] Session saved directly:', sessionId);
|
||||
return {
|
||||
success: true,
|
||||
sessionId,
|
||||
message: `会话已保存: ${sessionName}`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[QwenAgentManager] Session save directly failed:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
// Use checkpoint format instead of session format
|
||||
// This matches CLI's /chat save behavior
|
||||
return this.saveCheckpoint(messages, sessionName);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -266,14 +344,42 @@ export class QwenAgentManager {
|
||||
async loadSessionViaAcp(sessionId: string): Promise<unknown> {
|
||||
try {
|
||||
console.log(
|
||||
'[QwenAgentManager] Testing session/load via ACP for:',
|
||||
'[QwenAgentManager] Attempting session/load via ACP for session:',
|
||||
sessionId,
|
||||
);
|
||||
const response = await this.connection.loadSession(sessionId);
|
||||
console.log('[QwenAgentManager] Session load response:', response);
|
||||
console.log(
|
||||
'[QwenAgentManager] Session load succeeded. Response:',
|
||||
JSON.stringify(response).substring(0, 200),
|
||||
);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('[QwenAgentManager] Session load via ACP failed:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
console.error(
|
||||
'[QwenAgentManager] Session load via ACP failed for session:',
|
||||
sessionId,
|
||||
);
|
||||
console.error('[QwenAgentManager] Error type:', error?.constructor?.name);
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,244 @@ export class QwenSessionManager {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current conversation as a checkpoint (matching CLI's /chat save format)
|
||||
* Creates checkpoint with BOTH conversationId and sessionId as tags for compatibility
|
||||
*
|
||||
* @param messages - Current conversation messages
|
||||
* @param conversationId - Conversation ID (from VSCode extension)
|
||||
* @param sessionId - Session ID (from CLI tmp session file, optional)
|
||||
* @param workingDir - Current working directory
|
||||
* @returns Checkpoint tag
|
||||
*/
|
||||
async saveCheckpoint(
|
||||
messages: QwenMessage[],
|
||||
conversationId: string,
|
||||
workingDir: string,
|
||||
sessionId?: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
console.log('[QwenSessionManager] ===== SAVEPOINT START =====');
|
||||
console.log('[QwenSessionManager] Conversation ID:', conversationId);
|
||||
console.log(
|
||||
'[QwenSessionManager] Session ID:',
|
||||
sessionId || 'not provided',
|
||||
);
|
||||
console.log('[QwenSessionManager] Working dir:', workingDir);
|
||||
console.log('[QwenSessionManager] Message count:', messages.length);
|
||||
|
||||
// Get project directory (parent of chats directory)
|
||||
const projectHash = this.getProjectHash(workingDir);
|
||||
console.log('[QwenSessionManager] Project hash:', projectHash);
|
||||
|
||||
const projectDir = path.join(this.qwenDir, 'tmp', projectHash);
|
||||
console.log('[QwenSessionManager] Project dir:', projectDir);
|
||||
|
||||
if (!fs.existsSync(projectDir)) {
|
||||
console.log('[QwenSessionManager] Creating project directory...');
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
console.log('[QwenSessionManager] Directory created');
|
||||
} else {
|
||||
console.log('[QwenSessionManager] Project directory already exists');
|
||||
}
|
||||
|
||||
// Convert messages to checkpoint format (Gemini-style messages)
|
||||
console.log(
|
||||
'[QwenSessionManager] Converting messages to checkpoint format...',
|
||||
);
|
||||
const checkpointMessages = messages.map((msg, index) => {
|
||||
console.log(
|
||||
`[QwenSessionManager] Message ${index}: type=${msg.type}, contentLength=${msg.content?.length || 0}`,
|
||||
);
|
||||
return {
|
||||
role: msg.type === 'user' ? 'user' : 'model',
|
||||
parts: [
|
||||
{
|
||||
text: msg.content,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
console.log(
|
||||
'[QwenSessionManager] Converted',
|
||||
checkpointMessages.length,
|
||||
'messages',
|
||||
);
|
||||
|
||||
const jsonContent = JSON.stringify(checkpointMessages, null, 2);
|
||||
console.log(
|
||||
'[QwenSessionManager] JSON content length:',
|
||||
jsonContent.length,
|
||||
);
|
||||
|
||||
// Save with conversationId as primary tag
|
||||
const convFilename = `checkpoint-${conversationId}.json`;
|
||||
const convFilePath = path.join(projectDir, convFilename);
|
||||
console.log(
|
||||
'[QwenSessionManager] Saving checkpoint with conversationId:',
|
||||
convFilePath,
|
||||
);
|
||||
fs.writeFileSync(convFilePath, jsonContent, 'utf-8');
|
||||
|
||||
// Also save with sessionId if provided (for compatibility with CLI session/load)
|
||||
if (sessionId) {
|
||||
const sessionFilename = `checkpoint-${sessionId}.json`;
|
||||
const sessionFilePath = path.join(projectDir, sessionFilename);
|
||||
console.log(
|
||||
'[QwenSessionManager] Also saving checkpoint with sessionId:',
|
||||
sessionFilePath,
|
||||
);
|
||||
fs.writeFileSync(sessionFilePath, jsonContent, 'utf-8');
|
||||
}
|
||||
|
||||
// Verify primary file exists
|
||||
if (fs.existsSync(convFilePath)) {
|
||||
const stats = fs.statSync(convFilePath);
|
||||
console.log(
|
||||
'[QwenSessionManager] Primary checkpoint verified, size:',
|
||||
stats.size,
|
||||
);
|
||||
} else {
|
||||
console.error(
|
||||
'[QwenSessionManager] ERROR: Primary checkpoint does not exist after write!',
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[QwenSessionManager] ===== CHECKPOINT SAVED =====');
|
||||
console.log('[QwenSessionManager] Primary path:', convFilePath);
|
||||
if (sessionId) {
|
||||
console.log(
|
||||
'[QwenSessionManager] Secondary path (sessionId):',
|
||||
path.join(projectDir, `checkpoint-${sessionId}.json`),
|
||||
);
|
||||
}
|
||||
return conversationId;
|
||||
} catch (error) {
|
||||
console.error('[QwenSessionManager] ===== CHECKPOINT SAVE FAILED =====');
|
||||
console.error('[QwenSessionManager] Error:', error);
|
||||
console.error(
|
||||
'[QwenSessionManager] Error stack:',
|
||||
error instanceof Error ? error.stack : 'N/A',
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find checkpoint file for a given sessionId
|
||||
* Tries both checkpoint-{sessionId}.json and searches session files for matching sessionId
|
||||
*
|
||||
* @param sessionId - Session ID to find checkpoint for
|
||||
* @param workingDir - Current working directory
|
||||
* @returns Checkpoint tag if found, null otherwise
|
||||
*/
|
||||
async findCheckpointTag(
|
||||
sessionId: string,
|
||||
workingDir: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const projectHash = this.getProjectHash(workingDir);
|
||||
const projectDir = path.join(this.qwenDir, 'tmp', projectHash);
|
||||
|
||||
// First, try direct checkpoint with sessionId
|
||||
const directCheckpoint = path.join(
|
||||
projectDir,
|
||||
`checkpoint-${sessionId}.json`,
|
||||
);
|
||||
if (fs.existsSync(directCheckpoint)) {
|
||||
console.log(
|
||||
'[QwenSessionManager] Found direct checkpoint:',
|
||||
directCheckpoint,
|
||||
);
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
// Second, look for session file with this sessionId to get conversationId
|
||||
const sessionDir = path.join(projectDir, 'chats');
|
||||
if (fs.existsSync(sessionDir)) {
|
||||
const files = fs.readdirSync(sessionDir);
|
||||
for (const file of files) {
|
||||
if (file.startsWith('session-') && file.endsWith('.json')) {
|
||||
try {
|
||||
const filePath = path.join(sessionDir, file);
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const session = JSON.parse(content) as QwenSession;
|
||||
|
||||
if (session.sessionId === sessionId) {
|
||||
console.log(
|
||||
'[QwenSessionManager] Found matching session file:',
|
||||
file,
|
||||
);
|
||||
// Now check if there's a checkpoint with this conversationId
|
||||
// We need to store conversationId in session files or use another strategy
|
||||
// For now, return null and let it fallback
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid files
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
'[QwenSessionManager] No checkpoint found for sessionId:',
|
||||
sessionId,
|
||||
);
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('[QwenSessionManager] Error finding checkpoint:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a checkpoint by tag
|
||||
*
|
||||
* @param tag - Checkpoint tag
|
||||
* @param workingDir - Current working directory
|
||||
* @returns Loaded checkpoint messages or null if not found
|
||||
*/
|
||||
async loadCheckpoint(
|
||||
tag: string,
|
||||
workingDir: string,
|
||||
): Promise<QwenMessage[] | null> {
|
||||
try {
|
||||
const projectHash = this.getProjectHash(workingDir);
|
||||
const projectDir = path.join(this.qwenDir, 'tmp', projectHash);
|
||||
const filename = `checkpoint-${tag}.json`;
|
||||
const filePath = path.join(projectDir, filename);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.log(
|
||||
`[QwenSessionManager] Checkpoint file not found: ${filePath}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const checkpointMessages = JSON.parse(content) as Array<{
|
||||
role: 'user' | 'model';
|
||||
parts: Array<{ text: string }>;
|
||||
}>;
|
||||
|
||||
// Convert back to QwenMessage format
|
||||
const messages: QwenMessage[] = checkpointMessages.map((msg) => ({
|
||||
id: crypto.randomUUID(),
|
||||
timestamp: new Date().toISOString(),
|
||||
type: msg.role === 'user' ? ('user' as const) : ('qwen' as const),
|
||||
content: msg.parts[0]?.text || '',
|
||||
}));
|
||||
|
||||
console.log(`[QwenSessionManager] Checkpoint loaded: ${filePath}`);
|
||||
return messages;
|
||||
} catch (error) {
|
||||
console.error('[QwenSessionManager] Failed to load checkpoint:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current conversation as a named session (checkpoint-like functionality)
|
||||
*
|
||||
@@ -66,16 +304,25 @@ export class QwenSessionManager {
|
||||
fs.mkdirSync(sessionDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Generate session ID and filename
|
||||
// Generate session ID and filename using CLI's naming convention
|
||||
const sessionId = this.generateSessionId();
|
||||
const filename = `session-${sessionId}.json`;
|
||||
const shortId = sessionId.split('-')[0]; // First part of UUID (8 chars)
|
||||
const now = new Date();
|
||||
const isoDate = now.toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
const isoTime = now
|
||||
.toISOString()
|
||||
.split('T')[1]
|
||||
.split(':')
|
||||
.slice(0, 2)
|
||||
.join('-'); // HH-MM
|
||||
const filename = `session-${isoDate}T${isoTime}-${shortId}.json`;
|
||||
const filePath = path.join(sessionDir, filename);
|
||||
|
||||
// Create session object
|
||||
const session: QwenSession = {
|
||||
sessionId,
|
||||
projectHash: this.getProjectHash(workingDir),
|
||||
startTime: new Date().toISOString(),
|
||||
startTime: messages[0]?.timestamp || new Date().toISOString(),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
messages,
|
||||
};
|
||||
|
||||
@@ -233,87 +233,6 @@ button {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
Tool Call Card Styles (Grid Layout)
|
||||
=========================== */
|
||||
.tool-call-card {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: var(--app-spacing-medium);
|
||||
background: var(--app-input-background);
|
||||
border: 1px solid var(--app-input-border);
|
||||
border-radius: var(--corner-radius-medium);
|
||||
padding: var(--app-spacing-large);
|
||||
margin: var(--app-spacing-medium) 0;
|
||||
animation: fadeIn 0.2s ease-in;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.tool-call-icon {
|
||||
font-size: 20px;
|
||||
grid-row: 1;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.tool-call-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--app-spacing-medium);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tool-call-row {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 1fr;
|
||||
gap: var(--app-spacing-medium);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tool-call-label {
|
||||
font-size: 12px;
|
||||
color: var(--app-secondary-foreground);
|
||||
font-weight: 500;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.tool-call-value {
|
||||
color: var(--app-primary-foreground);
|
||||
min-width: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tool-call-status-indicator {
|
||||
display: inline-block;
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tool-call-status-indicator::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.tool-call-status-indicator.pending::before {
|
||||
background: #ffc107;
|
||||
}
|
||||
|
||||
.tool-call-status-indicator.in_progress::before {
|
||||
background: #2196f3;
|
||||
}
|
||||
|
||||
.tool-call-status-indicator.completed::before {
|
||||
background: #4caf50;
|
||||
}
|
||||
|
||||
.tool-call-status-indicator.failed::before {
|
||||
background: #f44336;
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
In-Progress Tool Call Styles (Claude Code style)
|
||||
=========================== */
|
||||
|
||||
@@ -580,10 +580,29 @@ export const App: React.FC = () => {
|
||||
// Handle removing context attachment
|
||||
const handleToolCallUpdate = React.useCallback(
|
||||
(update: ToolCallUpdate) => {
|
||||
console.log('[App] handleToolCallUpdate:', {
|
||||
type: update.type,
|
||||
toolCallId: update.toolCallId,
|
||||
kind: update.kind,
|
||||
title: update.title,
|
||||
status: update.status,
|
||||
});
|
||||
|
||||
setToolCalls((prevToolCalls) => {
|
||||
const newMap = new Map(prevToolCalls);
|
||||
const existing = newMap.get(update.toolCallId);
|
||||
|
||||
console.log(
|
||||
'[App] existing tool call:',
|
||||
existing
|
||||
? {
|
||||
kind: existing.kind,
|
||||
title: existing.title,
|
||||
status: existing.status,
|
||||
}
|
||||
: 'not found',
|
||||
);
|
||||
|
||||
// Helper function to safely convert title to string
|
||||
const safeTitle = (title: unknown): string => {
|
||||
if (typeof title === 'string') {
|
||||
@@ -627,13 +646,17 @@ export const App: React.FC = () => {
|
||||
: undefined;
|
||||
|
||||
if (existing) {
|
||||
// Update existing tool call
|
||||
// Update existing tool call - merge content arrays
|
||||
const mergedContent = updatedContent
|
||||
? [...(existing.content || []), ...updatedContent]
|
||||
: existing.content;
|
||||
|
||||
newMap.set(update.toolCallId, {
|
||||
...existing,
|
||||
...(update.kind && { kind: update.kind }),
|
||||
...(update.title && { title: safeTitle(update.title) }),
|
||||
...(update.status && { status: update.status }),
|
||||
...(updatedContent && { content: updatedContent }),
|
||||
content: mergedContent,
|
||||
...(update.locations && { locations: update.locations }),
|
||||
});
|
||||
} else {
|
||||
@@ -641,7 +664,7 @@ export const App: React.FC = () => {
|
||||
newMap.set(update.toolCallId, {
|
||||
toolCallId: update.toolCallId,
|
||||
kind: update.kind || 'other',
|
||||
title: safeTitle(update.title),
|
||||
title: update.title ? safeTitle(update.title) : '',
|
||||
status: update.status || 'pending',
|
||||
rawInput: update.rawInput as string | object | undefined,
|
||||
content: updatedContent,
|
||||
@@ -840,11 +863,16 @@ export const App: React.FC = () => {
|
||||
break;
|
||||
|
||||
case 'toolCall':
|
||||
case 'toolCallUpdate':
|
||||
case 'toolCallUpdate': {
|
||||
// Handle tool call updates
|
||||
handleToolCallUpdate(message.data);
|
||||
// Convert sessionUpdate to type if needed
|
||||
const toolCallData = message.data;
|
||||
if (toolCallData.sessionUpdate && !toolCallData.type) {
|
||||
toolCallData.type = toolCallData.sessionUpdate;
|
||||
}
|
||||
handleToolCallUpdate(toolCallData);
|
||||
break;
|
||||
|
||||
}
|
||||
case 'qwenSessionList': {
|
||||
const sessions = message.data.sessions || [];
|
||||
setQwenSessions(sessions);
|
||||
|
||||
@@ -282,84 +282,6 @@
|
||||
--app-warning-foreground: var(--vscode-editorWarning-foreground, #ffcc00);
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
Tool Call Card (from Claude Code .Ne)
|
||||
=========================== */
|
||||
.tool-call-card {
|
||||
border: 0.5px solid var(--app-input-border);
|
||||
border-radius: 5px;
|
||||
background: var(--app-tool-background);
|
||||
margin: 8px 0;
|
||||
max-width: 100%;
|
||||
font-size: 1em;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* Tool Call Grid Layout (from Claude Code .Ke) */
|
||||
.tool-call-grid {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
}
|
||||
|
||||
/* Tool Call Row (from Claude Code .no) */
|
||||
.tool-call-row {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
border-top: 0.5px solid var(--app-input-border);
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.tool-call-row:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
/* Tool Call Label (from Claude Code .Je) */
|
||||
.tool-call-label {
|
||||
grid-column: 1;
|
||||
color: var(--app-secondary-foreground);
|
||||
text-align: left;
|
||||
opacity: 0.5;
|
||||
padding: 4px 8px 4px 4px;
|
||||
font-family: var(--app-monospace-font-family);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* Tool Call Value (from Claude Code .m) */
|
||||
.tool-call-value {
|
||||
grid-column: 2;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.tool-call-value:not(.expanded) {
|
||||
max-height: 60px;
|
||||
mask-image: linear-gradient(to bottom, var(--app-primary-background) 40px, transparent 60px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tool-call-value pre {
|
||||
margin-block: 0;
|
||||
overflow: hidden;
|
||||
font-family: var(--app-monospace-font-family);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.tool-call-value code {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: var(--app-monospace-font-family);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* Tool Call Icon (from Claude Code .to) */
|
||||
.tool-call-icon {
|
||||
margin: 8px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Code Block (from Claude Code ._e) */
|
||||
.code-block {
|
||||
background-color: var(--app-code-background);
|
||||
@@ -432,37 +354,3 @@
|
||||
color: var(--app-secondary-foreground);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* Status indicators for tool calls */
|
||||
.tool-call-status-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tool-call-status-indicator::before {
|
||||
content: "●";
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.tool-call-status-indicator.pending::before {
|
||||
color: var(--app-secondary-foreground);
|
||||
}
|
||||
|
||||
.tool-call-status-indicator.in-progress::before {
|
||||
color: #e1c08d;
|
||||
animation: blink 1s linear infinite;
|
||||
}
|
||||
|
||||
.tool-call-status-indicator.completed::before {
|
||||
color: #74c991;
|
||||
}
|
||||
|
||||
.tool-call-status-indicator.failed::before {
|
||||
color: #c74e39;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ export class MessageHandler {
|
||||
private loginHandler?: () => Promise<void>;
|
||||
// 待发送消息(登录后自动重发)
|
||||
private pendingMessage: string | null = null;
|
||||
// 标记是否正在执行后台保存命令
|
||||
private isSavingCheckpoint = false;
|
||||
|
||||
constructor(
|
||||
private agentManager: QwenAgentManager,
|
||||
@@ -46,6 +48,13 @@ export class MessageHandler {
|
||||
this.loginHandler = handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否正在后台保存 checkpoint
|
||||
*/
|
||||
getIsSavingCheckpoint(): boolean {
|
||||
return this.isSavingCheckpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前对话 ID
|
||||
*/
|
||||
@@ -459,6 +468,77 @@ export class MessageHandler {
|
||||
data: { timestamp: Date.now() },
|
||||
});
|
||||
console.log('[MessageHandler] Stream end sent');
|
||||
|
||||
// Auto-save session after response completes
|
||||
// Use CLI's /chat save command for complete checkpoint with tool calls
|
||||
if (this.currentConversationId) {
|
||||
console.log(
|
||||
'[MessageHandler] ===== STARTING AUTO-SAVE CHECKPOINT =====',
|
||||
);
|
||||
console.log(
|
||||
'[MessageHandler] Session ID (will be used as checkpoint tag):',
|
||||
this.currentConversationId,
|
||||
);
|
||||
|
||||
try {
|
||||
// Get conversation messages
|
||||
const conversation = await this.conversationStore.getConversation(
|
||||
this.currentConversationId,
|
||||
);
|
||||
console.log(
|
||||
'[MessageHandler] Conversation loaded, message count:',
|
||||
conversation?.messages.length,
|
||||
);
|
||||
|
||||
// Save via CLI /chat save command (will trigger a response we need to ignore)
|
||||
const messages = conversation?.messages || [];
|
||||
console.log(
|
||||
'[MessageHandler] Calling saveCheckpoint with',
|
||||
messages.length,
|
||||
'messages',
|
||||
);
|
||||
|
||||
// Set flag to ignore the upcoming response from /chat save
|
||||
this.isSavingCheckpoint = true;
|
||||
console.log('[MessageHandler] Set isSavingCheckpoint = true');
|
||||
|
||||
const result = await this.agentManager.saveCheckpoint(
|
||||
messages,
|
||||
this.currentConversationId,
|
||||
);
|
||||
|
||||
console.log('[MessageHandler] Checkpoint save result:', result);
|
||||
|
||||
// Reset flag after a delay (in case the command response comes late)
|
||||
setTimeout(() => {
|
||||
this.isSavingCheckpoint = false;
|
||||
console.log('[MessageHandler] Reset isSavingCheckpoint = false');
|
||||
}, 2000);
|
||||
|
||||
if (result.success) {
|
||||
console.log(
|
||||
'[MessageHandler] ===== CHECKPOINT SAVE SUCCESSFUL =====',
|
||||
);
|
||||
console.log('[MessageHandler] Checkpoint tag:', result.tag);
|
||||
} else {
|
||||
console.error(
|
||||
'[MessageHandler] ===== CHECKPOINT SAVE FAILED =====',
|
||||
);
|
||||
console.error('[MessageHandler] Error:', result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[MessageHandler] ===== CHECKPOINT SAVE EXCEPTION =====',
|
||||
);
|
||||
console.error('[MessageHandler] Exception details:', error);
|
||||
this.isSavingCheckpoint = false;
|
||||
// Don't show error to user - this is a background operation
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
'[MessageHandler] Skipping checkpoint save: no current conversation ID',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MessageHandler] Error sending message:', error);
|
||||
|
||||
@@ -563,10 +643,42 @@ export class MessageHandler {
|
||||
|
||||
/**
|
||||
* 处理新建 Qwen 会话请求
|
||||
* 在创建新 session 前,先保存当前 session
|
||||
*/
|
||||
private async handleNewQwenSession(): Promise<void> {
|
||||
try {
|
||||
console.log('[MessageHandler] Creating new Qwen session...');
|
||||
|
||||
// Save current session as checkpoint before switching to a new one
|
||||
if (this.currentConversationId && this.agentManager.isConnected) {
|
||||
try {
|
||||
console.log(
|
||||
'[MessageHandler] Auto-saving current session as checkpoint before creating new:',
|
||||
this.currentConversationId,
|
||||
);
|
||||
|
||||
const conversation = await this.conversationStore.getConversation(
|
||||
this.currentConversationId,
|
||||
);
|
||||
const messages = conversation?.messages || [];
|
||||
|
||||
// Save as checkpoint using sessionId as tag
|
||||
await this.agentManager.saveCheckpoint(
|
||||
messages,
|
||||
this.currentConversationId,
|
||||
);
|
||||
console.log(
|
||||
'[MessageHandler] Current session checkpoint saved successfully before creating new session',
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[MessageHandler] Failed to auto-save current session checkpoint:',
|
||||
error,
|
||||
);
|
||||
// Don't block new session creation if save fails
|
||||
}
|
||||
}
|
||||
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
||||
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
|
||||
|
||||
@@ -588,17 +700,45 @@ export class MessageHandler {
|
||||
|
||||
/**
|
||||
* 处理切换 Qwen 会话请求
|
||||
* 优先使用 CLI 的 checkpoint (session/load) 能力从保存的完整会话恢复
|
||||
*/
|
||||
private async handleSwitchQwenSession(sessionId: string): Promise<void> {
|
||||
try {
|
||||
console.log('[MessageHandler] Switching to Qwen session:', sessionId);
|
||||
|
||||
// Get session messages from local files
|
||||
const messages = await this.agentManager.getSessionMessages(sessionId);
|
||||
console.log(
|
||||
'[MessageHandler] Loaded messages from session:',
|
||||
messages.length,
|
||||
);
|
||||
// Save current session as checkpoint before switching
|
||||
if (
|
||||
this.currentConversationId &&
|
||||
this.currentConversationId !== sessionId &&
|
||||
this.agentManager.isConnected
|
||||
) {
|
||||
try {
|
||||
console.log(
|
||||
'[MessageHandler] Auto-saving current session as checkpoint before switching:',
|
||||
this.currentConversationId,
|
||||
);
|
||||
|
||||
const conversation = await this.conversationStore.getConversation(
|
||||
this.currentConversationId,
|
||||
);
|
||||
const messages = conversation?.messages || [];
|
||||
|
||||
// Save as checkpoint using sessionId as tag
|
||||
await this.agentManager.saveCheckpoint(
|
||||
messages,
|
||||
this.currentConversationId,
|
||||
);
|
||||
console.log(
|
||||
'[MessageHandler] Current session checkpoint saved successfully before switching',
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[MessageHandler] Failed to auto-save current session checkpoint:',
|
||||
error,
|
||||
);
|
||||
// Don't block session switching if save fails
|
||||
}
|
||||
}
|
||||
|
||||
// Get session details for the header
|
||||
let sessionDetails = null;
|
||||
@@ -612,12 +752,16 @@ export class MessageHandler {
|
||||
console.log('[MessageHandler] Could not get session details:', err);
|
||||
}
|
||||
|
||||
// Try to load session via ACP first, fallback to creating new session
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
||||
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
|
||||
|
||||
// Try to load session via ACP checkpoint (session/load) first
|
||||
// This will restore the full session with all tool calls and context
|
||||
try {
|
||||
console.log('[MessageHandler] Testing session/load via ACP...');
|
||||
console.log(
|
||||
'[MessageHandler] Loading session via CLI checkpoint (session/load):',
|
||||
sessionId,
|
||||
);
|
||||
const loadResponse =
|
||||
await this.agentManager.loadSessionViaAcp(sessionId);
|
||||
console.log('[MessageHandler] session/load succeeded:', loadResponse);
|
||||
@@ -628,13 +772,39 @@ export class MessageHandler {
|
||||
'[MessageHandler] Set currentConversationId to loaded session:',
|
||||
sessionId,
|
||||
);
|
||||
} catch (_loadError) {
|
||||
|
||||
// Get session messages for display from loaded session
|
||||
// This will now have complete tool call information
|
||||
const messages = await this.agentManager.getSessionMessages(sessionId);
|
||||
console.log(
|
||||
'[MessageHandler] session/load not supported, creating new session',
|
||||
'[MessageHandler] Loaded complete messages from checkpoint:',
|
||||
messages.length,
|
||||
);
|
||||
|
||||
// Send messages and session details to WebView
|
||||
this.sendToWebView({
|
||||
type: 'qwenSessionSwitched',
|
||||
data: { sessionId, messages, session: sessionDetails },
|
||||
});
|
||||
} catch (loadError) {
|
||||
const errorMessage =
|
||||
loadError instanceof Error ? loadError.message : String(loadError);
|
||||
console.warn(
|
||||
'[MessageHandler] session/load failed, falling back to file-based restore.',
|
||||
);
|
||||
console.warn('[MessageHandler] Load error details:', errorMessage);
|
||||
console.warn(
|
||||
'[MessageHandler] This may happen if the session was not saved via /chat save.',
|
||||
);
|
||||
|
||||
// Fallback: Load messages from local files (may be incomplete)
|
||||
// and create a new ACP session for continuation
|
||||
const messages = await this.agentManager.getSessionMessages(sessionId);
|
||||
console.log(
|
||||
'[MessageHandler] Loaded messages from local files:',
|
||||
messages.length,
|
||||
);
|
||||
|
||||
// Fallback: CLI doesn't support loading old sessions
|
||||
// So we create a NEW ACP session for continuation
|
||||
try {
|
||||
const newAcpSessionId =
|
||||
await this.agentManager.createNewSession(workingDir);
|
||||
@@ -649,24 +819,28 @@ export class MessageHandler {
|
||||
'[MessageHandler] Set currentConversationId (ACP) to:',
|
||||
newAcpSessionId,
|
||||
);
|
||||
|
||||
// Send messages and session details to WebView
|
||||
// Note: These messages may be incomplete (no tool calls)
|
||||
this.sendToWebView({
|
||||
type: 'qwenSessionSwitched',
|
||||
data: { sessionId, messages, session: sessionDetails },
|
||||
});
|
||||
|
||||
vscode.window.showWarningMessage(
|
||||
'Session restored from local cache. Some context may be incomplete. Save sessions regularly for full restoration.',
|
||||
);
|
||||
} catch (createError) {
|
||||
console.error(
|
||||
'[MessageHandler] Failed to create new ACP session:',
|
||||
createError,
|
||||
);
|
||||
vscode.window.showWarningMessage(
|
||||
'Could not switch to session. Created new session instead.',
|
||||
vscode.window.showErrorMessage(
|
||||
'Could not switch to session. Please try again.',
|
||||
);
|
||||
throw createError;
|
||||
}
|
||||
}
|
||||
|
||||
// Send messages and session details to WebView
|
||||
// The historical messages are display-only, not sent to CLI
|
||||
this.sendToWebView({
|
||||
type: 'qwenSessionSwitched',
|
||||
data: { sessionId, messages, session: sessionDetails },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[MessageHandler] Failed to switch session:', error);
|
||||
this.sendToWebView({
|
||||
|
||||
@@ -157,7 +157,6 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
|
||||
{/* Options */}
|
||||
<div className="relative z-[1] flex flex-col gap-1 px-1 pb-1">
|
||||
{options.map((option, index) => {
|
||||
const isAlways = option.kind.includes('always');
|
||||
const isFocused = focusedIndex === index;
|
||||
|
||||
return (
|
||||
@@ -165,13 +164,15 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
|
||||
key={option.optionId}
|
||||
className={`flex items-center gap-2 px-3 py-2 text-left rounded-small border transition-colors duration-150 ${
|
||||
isFocused
|
||||
? 'bg-[var(--app-list-active-background)] text-[var(--app-list-active-foreground)] border-transparent'
|
||||
: 'hover:bg-[var(--app-list-hover-background)] border-transparent'
|
||||
? 'bg-[var(--app-list-active-background)] text-[var(--app-list-active-foreground)]'
|
||||
: 'hover:bg-[var(--app-list-hover-background)]'
|
||||
}`}
|
||||
style={{
|
||||
color: isFocused
|
||||
? 'var(--app-list-active-foreground)'
|
||||
: 'var(--app-primary-foreground)',
|
||||
borderColor:
|
||||
'color-mix(in srgb, var(--app-secondary-foreground) 70%, transparent)',
|
||||
}}
|
||||
onClick={() => onResponse(option.optionId)}
|
||||
onMouseEnter={() => setFocusedIndex(index)}
|
||||
@@ -181,7 +182,7 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
|
||||
className={`inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 text-xs font-semibold rounded ${
|
||||
isFocused
|
||||
? 'bg-white/20 text-inherit'
|
||||
: 'bg-[var(--app-list-hover-background)] text-[var(--app-secondary-foreground)]'
|
||||
: 'bg-[var(--app-list-hover-background)]'
|
||||
}`}
|
||||
>
|
||||
{index + 1}
|
||||
@@ -191,49 +192,44 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
|
||||
<span className="text-sm">{option.name}</span>
|
||||
|
||||
{/* Always badge */}
|
||||
{isAlways && <span className="text-sm">⚡</span>}
|
||||
{/* {isAlways && <span className="text-sm">⚡</span>} */}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Custom message input */}
|
||||
<div
|
||||
className={`rounded-small border transition-colors duration-150 ${
|
||||
<input
|
||||
ref={customInputRef as React.RefObject<HTMLInputElement>}
|
||||
type="text"
|
||||
placeholder="Tell Qwen what to do instead"
|
||||
spellCheck={false}
|
||||
className={`w-full px-3 py-2 text-sm rounded-small border transition-colors duration-150 ${
|
||||
focusedIndex === options.length
|
||||
? 'bg-[var(--app-list-hover-background)] border-transparent'
|
||||
: 'border-transparent'
|
||||
? 'bg-[var(--app-list-hover-background)]'
|
||||
: 'bg-transparent'
|
||||
}`}
|
||||
style={{
|
||||
color: 'var(--app-input-foreground)',
|
||||
outline: 'none',
|
||||
borderColor:
|
||||
'color-mix(in srgb, var(--app-secondary-foreground) 70%, transparent)',
|
||||
}}
|
||||
value={customMessage}
|
||||
onChange={(e) => setCustomMessage(e.target.value)}
|
||||
onFocus={() => setFocusedIndex(options.length)}
|
||||
onMouseEnter={() => setFocusedIndex(options.length)}
|
||||
>
|
||||
<div
|
||||
className="px-3 py-2 text-sm"
|
||||
style={{ color: 'var(--app-secondary-foreground)' }}
|
||||
>
|
||||
Tell Qwen what to do instead
|
||||
</div>
|
||||
<div
|
||||
ref={customInputRef}
|
||||
contentEditable="plaintext-only"
|
||||
spellCheck={false}
|
||||
className="px-3 pb-2 text-sm outline-none bg-transparent min-h-[1.5em]"
|
||||
style={{ color: 'var(--app-input-foreground)' }}
|
||||
onInput={(e) => {
|
||||
const target = e.target as HTMLDivElement;
|
||||
setCustomMessage(target.textContent || '');
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && customMessage.trim()) {
|
||||
e.preventDefault();
|
||||
const rejectOption = options.find((o) =>
|
||||
o.kind.includes('reject'),
|
||||
);
|
||||
if (rejectOption) {
|
||||
onResponse(rejectOption.optionId);
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && customMessage.trim()) {
|
||||
e.preventDefault();
|
||||
const rejectOption = options.find((o) =>
|
||||
o.kind.includes('reject'),
|
||||
);
|
||||
if (rejectOption) {
|
||||
onResponse(rejectOption.optionId);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -111,14 +111,6 @@
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在工具调用卡片中的样式调整
|
||||
*/
|
||||
.tool-call-card .file-link {
|
||||
/* 在工具调用中略微缩小字体 */
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在代码块中的样式调整
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Edit tool call component - specialized for file editing operations
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useState } from 'react';
|
||||
import type { BaseToolCallProps } from './shared/types.js';
|
||||
import { ToolCallContainer } from './shared/LayoutComponents.js';
|
||||
import { DiffDisplay } from './shared/DiffDisplay.js';
|
||||
import { groupContent } from './shared/utils.js';
|
||||
import { useVSCode } from '../../hooks/useVSCode.js';
|
||||
import { FileLink } from '../shared/FileLink.js';
|
||||
|
||||
/**
|
||||
* Calculate diff summary (added/removed lines)
|
||||
*/
|
||||
const getDiffSummary = (
|
||||
oldText: string | null | undefined,
|
||||
newText: string | undefined,
|
||||
): string => {
|
||||
const oldLines = oldText ? oldText.split('\n').length : 0;
|
||||
const newLines = newText ? newText.split('\n').length : 0;
|
||||
const diff = newLines - oldLines;
|
||||
|
||||
if (diff > 0) {
|
||||
return `+${diff} lines`;
|
||||
} else if (diff < 0) {
|
||||
return `${diff} lines`;
|
||||
} else {
|
||||
return 'Modified';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Specialized component for Edit tool calls
|
||||
* Optimized for displaying file editing operations with diffs
|
||||
*/
|
||||
export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
const { content, locations, toolCallId } = toolCall;
|
||||
const vscode = useVSCode();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// Group content by type
|
||||
const { errors, diffs } = groupContent(content);
|
||||
|
||||
const handleOpenDiff = (
|
||||
path: string | undefined,
|
||||
oldText: string | null | undefined,
|
||||
newText: string | undefined,
|
||||
) => {
|
||||
if (path) {
|
||||
vscode.postMessage({
|
||||
type: 'openDiff',
|
||||
data: { path, oldText: oldText || '', newText: newText || '' },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Extract filename from path
|
||||
const getFileName = (path: string): string => path.split('/').pop() || path;
|
||||
|
||||
// Error case: show error
|
||||
if (errors.length > 0) {
|
||||
const path = diffs[0]?.path || locations?.[0]?.path || '';
|
||||
const fileName = path ? getFileName(path) : '';
|
||||
return (
|
||||
<ToolCallContainer
|
||||
label={fileName ? `Edit ${fileName}` : 'Edit'}
|
||||
status="error"
|
||||
toolCallId={toolCallId}
|
||||
>
|
||||
{errors.join('\n')}
|
||||
</ToolCallContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Success case with diff: show collapsible format
|
||||
if (diffs.length > 0) {
|
||||
const firstDiff = diffs[0];
|
||||
const path = firstDiff.path || (locations && locations[0]?.path) || '';
|
||||
const fileName = path ? getFileName(path) : '';
|
||||
const summary = getDiffSummary(firstDiff.oldText, firstDiff.newText);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="relative py-2 select-text cursor-pointer hover:bg-[var(--app-input-background)]"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<span className="absolute left-2 top-[10px] text-[10px] text-[#74c991]">
|
||||
●
|
||||
</span>
|
||||
<div className="flex flex-col gap-1 pl-[30px]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-medium text-[var(--app-primary-foreground)]">
|
||||
Edit {fileName}
|
||||
</span>
|
||||
{toolCallId && (
|
||||
<span className="text-[10px] opacity-30">
|
||||
[{toolCallId.slice(-8)}]
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs opacity-60 mr-2">
|
||||
{expanded ? '▼' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 flex-row items-start w-full gap-1">
|
||||
<span className="flex-shrink-0 relative top-[-0.1em]">⎿</span>
|
||||
<span className="flex-shrink-0 w-full">{summary}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="ml-[30px] mt-1">
|
||||
{diffs.map(
|
||||
(
|
||||
item: import('./shared/types.js').ToolCallContent,
|
||||
idx: number,
|
||||
) => (
|
||||
<DiffDisplay
|
||||
key={`diff-${idx}`}
|
||||
path={item.path}
|
||||
oldText={item.oldText}
|
||||
newText={item.newText}
|
||||
onOpenDiff={() =>
|
||||
handleOpenDiff(item.path, item.oldText, item.newText)
|
||||
}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Success case without diff: show file in compact format
|
||||
if (locations && locations.length > 0) {
|
||||
const fileName = getFileName(locations[0].path);
|
||||
return (
|
||||
<ToolCallContainer
|
||||
label={`Edited ${fileName}`}
|
||||
status="success"
|
||||
toolCallId={toolCallId}
|
||||
>
|
||||
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 flex-row items-start w-full gap-1">
|
||||
<span className="flex-shrink-0 relative top-[-0.1em]">⎿</span>
|
||||
<FileLink
|
||||
path={locations[0].path}
|
||||
line={locations[0].line}
|
||||
showFullPath={true}
|
||||
/>
|
||||
</div>
|
||||
</ToolCallContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// No output, don't show anything
|
||||
return null;
|
||||
};
|
||||
@@ -8,82 +8,100 @@
|
||||
|
||||
import type React from 'react';
|
||||
import type { BaseToolCallProps } from './shared/types.js';
|
||||
import { ToolCallCard, ToolCallRow } from './shared/LayoutComponents.js';
|
||||
import { ToolCallContainer } from './shared/LayoutComponents.js';
|
||||
import { safeTitle, groupContent } from './shared/utils.js';
|
||||
|
||||
/**
|
||||
* Specialized component for Execute tool calls
|
||||
* Optimized for displaying command execution with stdout/stderr
|
||||
* Shows command + output (if any) or error
|
||||
* Specialized component for Execute/Bash tool calls
|
||||
* Shows: Bash bullet + description + IN/OUT card
|
||||
*/
|
||||
export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
const { title, content } = toolCall;
|
||||
const { title, content, rawInput, toolCallId } = toolCall;
|
||||
const commandText = safeTitle(title);
|
||||
|
||||
// Group content by type
|
||||
const { textOutputs, errors } = groupContent(content);
|
||||
|
||||
// Error case: show command + error
|
||||
// Extract command from rawInput if available
|
||||
let inputCommand = commandText;
|
||||
if (rawInput && typeof rawInput === 'object') {
|
||||
const inputObj = rawInput as { command?: string };
|
||||
inputCommand = inputObj.command || commandText;
|
||||
} else if (typeof rawInput === 'string') {
|
||||
inputCommand = rawInput;
|
||||
}
|
||||
|
||||
// Error case
|
||||
if (errors.length > 0) {
|
||||
return (
|
||||
<ToolCallCard icon="⚡">
|
||||
<ToolCallRow label="Command">
|
||||
<div style={{ fontFamily: 'var(--app-monospace-font-family)' }}>
|
||||
{commandText}
|
||||
<ToolCallContainer label="Bash" status="error" toolCallId={toolCallId}>
|
||||
<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="flex-shrink-0 w-full">{commandText}</span>
|
||||
</div>
|
||||
<div className="bg-[var(--app-input-background)] border border-[var(--app-input-border)] rounded-md p-3 flex flex-col gap-3">
|
||||
<div className="grid grid-cols-[80px_1fr] gap-3">
|
||||
<div className="text-xs text-[var(--app-secondary-foreground)] font-medium pt-[2px]">
|
||||
IN
|
||||
</div>
|
||||
<div className="text-[var(--app-primary-foreground)] font-mono text-[13px] break-words">
|
||||
{inputCommand}
|
||||
</div>
|
||||
</div>
|
||||
</ToolCallRow>
|
||||
<ToolCallRow label="Error">
|
||||
<div
|
||||
style={{
|
||||
color: '#c74e39',
|
||||
fontWeight: 500,
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{errors.join('\n')}
|
||||
<div className="grid grid-cols-[80px_1fr] gap-3">
|
||||
<div className="text-xs text-[var(--app-secondary-foreground)] font-medium pt-[2px]">
|
||||
Error
|
||||
</div>
|
||||
<div className="text-[#c74e39] font-mono text-[13px] whitespace-pre-wrap break-words">
|
||||
{errors.join('\n')}
|
||||
</div>
|
||||
</div>
|
||||
</ToolCallRow>
|
||||
</ToolCallCard>
|
||||
</div>
|
||||
</ToolCallContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Success with output: show command + output (limited)
|
||||
// Success with output
|
||||
if (textOutputs.length > 0) {
|
||||
const output = textOutputs.join('\n');
|
||||
const truncatedOutput =
|
||||
output.length > 500 ? output.substring(0, 500) + '...' : output;
|
||||
|
||||
return (
|
||||
<ToolCallCard icon="⚡">
|
||||
<ToolCallRow label="Command">
|
||||
<div style={{ fontFamily: 'var(--app-monospace-font-family)' }}>
|
||||
{commandText}
|
||||
<ToolCallContainer label="Bash" status="success" toolCallId={toolCallId}>
|
||||
<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="flex-shrink-0 w-full">{commandText}</span>
|
||||
</div>
|
||||
<div className="bg-[var(--app-input-background)] border border-[var(--app-input-border)] rounded-md p-3 flex flex-col gap-3">
|
||||
<div className="grid grid-cols-[80px_1fr] gap-3">
|
||||
<div className="text-xs text-[var(--app-secondary-foreground)] font-medium pt-[2px]">
|
||||
IN
|
||||
</div>
|
||||
<div className="text-[var(--app-primary-foreground)] font-mono text-[13px] break-words">
|
||||
{inputCommand}
|
||||
</div>
|
||||
</div>
|
||||
</ToolCallRow>
|
||||
<ToolCallRow label="Output">
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--app-monospace-font-family)',
|
||||
fontSize: '13px',
|
||||
whiteSpace: 'pre-wrap',
|
||||
opacity: 0.9,
|
||||
}}
|
||||
>
|
||||
{truncatedOutput}
|
||||
<div className="grid grid-cols-[80px_1fr] gap-3">
|
||||
<div className="text-xs text-[var(--app-secondary-foreground)] font-medium pt-[2px]">
|
||||
OUT
|
||||
</div>
|
||||
<div className="text-[var(--app-primary-foreground)] font-mono text-[13px] whitespace-pre-wrap opacity-90 break-words">
|
||||
{truncatedOutput}
|
||||
</div>
|
||||
</div>
|
||||
</ToolCallRow>
|
||||
</ToolCallCard>
|
||||
</div>
|
||||
</ToolCallContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Success without output: show command only
|
||||
// Success without output: show command with branch connector
|
||||
return (
|
||||
<ToolCallCard icon="⚡">
|
||||
<ToolCallRow label="Executed">
|
||||
<div style={{ fontFamily: 'var(--app-monospace-font-family)' }}>
|
||||
{commandText}
|
||||
</div>
|
||||
</ToolCallRow>
|
||||
</ToolCallCard>
|
||||
<ToolCallContainer label="Bash" status="success" toolCallId={toolCallId}>
|
||||
<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="flex-shrink-0 w-full">{commandText}</span>
|
||||
</div>
|
||||
</ToolCallContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import type React from 'react';
|
||||
import type { BaseToolCallProps } from './shared/types.js';
|
||||
import {
|
||||
ToolCallContainer,
|
||||
ToolCallCard,
|
||||
ToolCallRow,
|
||||
LocationsList,
|
||||
@@ -23,7 +24,7 @@ import { useVSCode } from '../../hooks/useVSCode.js';
|
||||
* Minimal display: show description and outcome
|
||||
*/
|
||||
export const GenericToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
const { kind, title, content, locations } = toolCall;
|
||||
const { kind, title, content, locations, toolCallId } = toolCall;
|
||||
const operationText = safeTitle(title);
|
||||
const vscode = useVSCode();
|
||||
|
||||
@@ -43,7 +44,7 @@ export const GenericToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Error case: show operation + error
|
||||
// Error case: show operation + error in card layout
|
||||
if (errors.length > 0) {
|
||||
return (
|
||||
<ToolCallCard icon="🔧">
|
||||
@@ -51,15 +52,13 @@ export const GenericToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
<div>{operationText}</div>
|
||||
</ToolCallRow>
|
||||
<ToolCallRow label="Error">
|
||||
<div style={{ color: '#c74e39', fontWeight: 500 }}>
|
||||
{errors.join('\n')}
|
||||
</div>
|
||||
<div className="text-[#c74e39] font-medium">{errors.join('\n')}</div>
|
||||
</ToolCallRow>
|
||||
</ToolCallCard>
|
||||
);
|
||||
}
|
||||
|
||||
// Success with diff: show diff
|
||||
// Success with diff: show diff in card layout
|
||||
if (diffs.length > 0) {
|
||||
return (
|
||||
<ToolCallCard icon="🔧">
|
||||
@@ -81,44 +80,54 @@ export const GenericToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Success with output: show operation + output (truncated)
|
||||
// Success with output: use card for long output, compact for short
|
||||
if (textOutputs.length > 0) {
|
||||
const output = textOutputs.join('\n');
|
||||
const truncatedOutput =
|
||||
output.length > 300 ? output.substring(0, 300) + '...' : output;
|
||||
const isLong = output.length > 150;
|
||||
|
||||
if (isLong) {
|
||||
const truncatedOutput =
|
||||
output.length > 300 ? output.substring(0, 300) + '...' : output;
|
||||
|
||||
return (
|
||||
<ToolCallCard icon="🔧">
|
||||
<ToolCallRow label={kind}>
|
||||
<div>{operationText}</div>
|
||||
</ToolCallRow>
|
||||
<ToolCallRow label="Output">
|
||||
<div className="whitespace-pre-wrap font-mono text-[13px] opacity-90">
|
||||
{truncatedOutput}
|
||||
</div>
|
||||
</ToolCallRow>
|
||||
</ToolCallCard>
|
||||
);
|
||||
}
|
||||
|
||||
// Short output - compact format
|
||||
return (
|
||||
<ToolCallCard icon="🔧">
|
||||
<ToolCallRow label={kind}>
|
||||
<div>{operationText}</div>
|
||||
</ToolCallRow>
|
||||
<ToolCallRow label="Output">
|
||||
<div
|
||||
style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontFamily: 'var(--app-monospace-font-family)',
|
||||
fontSize: '13px',
|
||||
opacity: 0.9,
|
||||
}}
|
||||
>
|
||||
{truncatedOutput}
|
||||
</div>
|
||||
</ToolCallRow>
|
||||
</ToolCallCard>
|
||||
<ToolCallContainer label={kind} status="success" toolCallId={toolCallId}>
|
||||
{operationText || output}
|
||||
</ToolCallContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Success with files: show operation + file list
|
||||
// Success with files: show operation + file list in compact format
|
||||
if (locations && locations.length > 0) {
|
||||
return (
|
||||
<ToolCallCard icon="🔧">
|
||||
<ToolCallRow label={kind}>
|
||||
<LocationsList locations={locations} />
|
||||
</ToolCallRow>
|
||||
</ToolCallCard>
|
||||
<ToolCallContainer label={kind} status="success" toolCallId={toolCallId}>
|
||||
<LocationsList locations={locations} />
|
||||
</ToolCallContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// No output - show just the operation
|
||||
if (operationText) {
|
||||
return (
|
||||
<ToolCallContainer label={kind} status="success" toolCallId={toolCallId}>
|
||||
{operationText}
|
||||
</ToolCallContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// No output
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -8,45 +8,47 @@
|
||||
|
||||
import type React from 'react';
|
||||
import type { BaseToolCallProps } from './shared/types.js';
|
||||
import {
|
||||
ToolCallCard,
|
||||
ToolCallRow,
|
||||
LocationsList,
|
||||
} from './shared/LayoutComponents.js';
|
||||
import { ToolCallContainer } from './shared/LayoutComponents.js';
|
||||
import { groupContent } from './shared/utils.js';
|
||||
|
||||
/**
|
||||
* Specialized component for Read tool calls
|
||||
* Optimized for displaying file reading operations
|
||||
* Minimal display: just show file name, hide content (too verbose)
|
||||
* Shows: Read filename (no content preview)
|
||||
*/
|
||||
export const ReadToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
const { content, locations } = toolCall;
|
||||
const { content, locations, toolCallId } = toolCall;
|
||||
|
||||
// Group content by type
|
||||
const { errors } = groupContent(content);
|
||||
|
||||
// Error case: show error with operation label
|
||||
// Extract filename from path
|
||||
const getFileName = (path: string): string => path.split('/').pop() || path;
|
||||
|
||||
// Error case: show error
|
||||
if (errors.length > 0) {
|
||||
const path = locations?.[0]?.path || '';
|
||||
const fileName = path ? getFileName(path) : '';
|
||||
return (
|
||||
<ToolCallCard icon="📖">
|
||||
<ToolCallRow label="Read">
|
||||
<div style={{ color: '#c74e39', fontWeight: 500 }}>
|
||||
{errors.join('\n')}
|
||||
</div>
|
||||
</ToolCallRow>
|
||||
</ToolCallCard>
|
||||
<ToolCallContainer
|
||||
label={fileName ? `Read ${fileName}` : 'Read'}
|
||||
status="error"
|
||||
toolCallId={toolCallId}
|
||||
>
|
||||
{errors.join('\n')}
|
||||
</ToolCallContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Success case: show which file was read
|
||||
// Success case: show which file was read with filename in label
|
||||
if (locations && locations.length > 0) {
|
||||
const fileName = getFileName(locations[0].path);
|
||||
return (
|
||||
<ToolCallCard icon="📖">
|
||||
<ToolCallRow label="Read">
|
||||
<LocationsList locations={locations} />
|
||||
</ToolCallRow>
|
||||
</ToolCallCard>
|
||||
<ToolCallContainer
|
||||
label={`Read ${fileName}`}
|
||||
status="success"
|
||||
toolCallId={toolCallId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import type React from 'react';
|
||||
import type { BaseToolCallProps } from './shared/types.js';
|
||||
import {
|
||||
ToolCallContainer,
|
||||
ToolCallCard,
|
||||
ToolCallRow,
|
||||
LocationsList,
|
||||
@@ -27,19 +28,15 @@ export const SearchToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
// Group content by type
|
||||
const { errors } = groupContent(content);
|
||||
|
||||
// Error case: show search query + error
|
||||
// Error case: show search query + error in card layout
|
||||
if (errors.length > 0) {
|
||||
return (
|
||||
<ToolCallCard icon="🔍">
|
||||
<ToolCallRow label="Search">
|
||||
<div style={{ fontFamily: 'var(--app-monospace-font-family)' }}>
|
||||
{queryText}
|
||||
</div>
|
||||
<div className="font-mono">{queryText}</div>
|
||||
</ToolCallRow>
|
||||
<ToolCallRow label="Error">
|
||||
<div style={{ color: '#c74e39', fontWeight: 500 }}>
|
||||
{errors.join('\n')}
|
||||
</div>
|
||||
<div className="text-[#c74e39] font-medium">{errors.join('\n')}</div>
|
||||
</ToolCallRow>
|
||||
</ToolCallCard>
|
||||
);
|
||||
@@ -47,20 +44,37 @@ export const SearchToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
|
||||
// Success case with results: show search query + file list
|
||||
if (locations && locations.length > 0) {
|
||||
// If multiple results, use card layout; otherwise use compact format
|
||||
if (locations.length > 1) {
|
||||
return (
|
||||
<ToolCallCard icon="🔍">
|
||||
<ToolCallRow label="Search">
|
||||
<div className="font-mono">{queryText}</div>
|
||||
</ToolCallRow>
|
||||
<ToolCallRow label={`Found (${locations.length})`}>
|
||||
<LocationsList locations={locations} />
|
||||
</ToolCallRow>
|
||||
</ToolCallCard>
|
||||
);
|
||||
}
|
||||
// Single result - compact format
|
||||
return (
|
||||
<ToolCallCard icon="🔍">
|
||||
<ToolCallRow label="Search">
|
||||
<div style={{ fontFamily: 'var(--app-monospace-font-family)' }}>
|
||||
{queryText}
|
||||
</div>
|
||||
</ToolCallRow>
|
||||
<ToolCallRow label={`Found (${locations.length})`}>
|
||||
<LocationsList locations={locations} />
|
||||
</ToolCallRow>
|
||||
</ToolCallCard>
|
||||
<ToolCallContainer label="Search" status="success">
|
||||
<span className="font-mono">{queryText}</span>
|
||||
<span className="mx-2 opacity-50">→</span>
|
||||
<LocationsList locations={locations} />
|
||||
</ToolCallContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// No results - show query only
|
||||
if (queryText) {
|
||||
return (
|
||||
<ToolCallContainer label="Search" status="success">
|
||||
<span className="font-mono">{queryText}</span>
|
||||
</ToolCallContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// No results
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -8,7 +8,11 @@
|
||||
|
||||
import type React from 'react';
|
||||
import type { BaseToolCallProps } from './shared/types.js';
|
||||
import { ToolCallCard, ToolCallRow } from './shared/LayoutComponents.js';
|
||||
import {
|
||||
ToolCallContainer,
|
||||
ToolCallCard,
|
||||
ToolCallRow,
|
||||
} from './shared/LayoutComponents.js';
|
||||
import { groupContent } from './shared/utils.js';
|
||||
|
||||
/**
|
||||
@@ -25,36 +29,37 @@ export const ThinkToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
// Error case (rare for thinking)
|
||||
if (errors.length > 0) {
|
||||
return (
|
||||
<ToolCallCard icon="💭">
|
||||
<ToolCallRow label="Error">
|
||||
<div style={{ color: '#c74e39', fontWeight: 500 }}>
|
||||
{errors.join('\n')}
|
||||
</div>
|
||||
</ToolCallRow>
|
||||
</ToolCallCard>
|
||||
<ToolCallContainer label="Thinking" status="error">
|
||||
{errors.join('\n')}
|
||||
</ToolCallContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Show thoughts with label
|
||||
// Show thoughts - use card for long content, compact for short
|
||||
if (textOutputs.length > 0) {
|
||||
const thoughts = textOutputs.join('\n\n');
|
||||
const truncatedThoughts =
|
||||
thoughts.length > 500 ? thoughts.substring(0, 500) + '...' : thoughts;
|
||||
const isLong = thoughts.length > 200;
|
||||
|
||||
if (isLong) {
|
||||
const truncatedThoughts =
|
||||
thoughts.length > 500 ? thoughts.substring(0, 500) + '...' : thoughts;
|
||||
|
||||
return (
|
||||
<ToolCallCard icon="💭">
|
||||
<ToolCallRow label="Thinking">
|
||||
<div className="italic opacity-90 leading-relaxed">
|
||||
{truncatedThoughts}
|
||||
</div>
|
||||
</ToolCallRow>
|
||||
</ToolCallCard>
|
||||
);
|
||||
}
|
||||
|
||||
// Short thoughts - compact format
|
||||
return (
|
||||
<ToolCallCard icon="💭">
|
||||
<ToolCallRow label="Thinking">
|
||||
<div
|
||||
style={{
|
||||
fontStyle: 'italic',
|
||||
opacity: 0.9,
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
{truncatedThoughts}
|
||||
</div>
|
||||
</ToolCallRow>
|
||||
</ToolCallCard>
|
||||
<ToolCallContainer label="Thinking" status="default">
|
||||
<span className="italic opacity-90">{thoughts}</span>
|
||||
</ToolCallContainer>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* TodoWrite tool call component - specialized for todo list operations
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import type { BaseToolCallProps } from './shared/types.js';
|
||||
import { ToolCallContainer } from './shared/LayoutComponents.js';
|
||||
import { groupContent } from './shared/utils.js';
|
||||
|
||||
/**
|
||||
* Specialized component for TodoWrite tool calls
|
||||
* Optimized for displaying todo list update operations
|
||||
*/
|
||||
export const TodoWriteToolCall: React.FC<BaseToolCallProps> = ({
|
||||
toolCall,
|
||||
}) => {
|
||||
const { content } = toolCall;
|
||||
|
||||
// Group content by type
|
||||
const { errors, textOutputs } = groupContent(content);
|
||||
|
||||
// Error case: show error
|
||||
if (errors.length > 0) {
|
||||
return (
|
||||
<ToolCallContainer label="Update Todos" status="error">
|
||||
{errors.join('\n')}
|
||||
</ToolCallContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Success case: show simple confirmation
|
||||
const outputText =
|
||||
textOutputs.length > 0 ? textOutputs.join(' ') : 'Todos updated';
|
||||
|
||||
// Truncate if too long
|
||||
const displayText =
|
||||
outputText.length > 100 ? outputText.substring(0, 100) + '...' : outputText;
|
||||
|
||||
return (
|
||||
<ToolCallContainer label="Update Todos" status="success">
|
||||
{displayText}
|
||||
</ToolCallContainer>
|
||||
);
|
||||
};
|
||||
@@ -3,89 +3,93 @@
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Write/Edit tool call component - specialized for file writing and editing operations
|
||||
* Write tool call component - specialized for file writing operations
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import type { BaseToolCallProps } from './shared/types.js';
|
||||
import {
|
||||
ToolCallCard,
|
||||
ToolCallRow,
|
||||
LocationsList,
|
||||
} from './shared/LayoutComponents.js';
|
||||
import { DiffDisplay } from './shared/DiffDisplay.js';
|
||||
import { ToolCallContainer } from './shared/LayoutComponents.js';
|
||||
import { groupContent } from './shared/utils.js';
|
||||
import { useVSCode } from '../../hooks/useVSCode.js';
|
||||
|
||||
/**
|
||||
* Specialized component for Write/Edit tool calls
|
||||
* Optimized for displaying file writing and editing operations with diffs
|
||||
* Follows minimal display principle: only show what matters
|
||||
* Specialized component for Write tool calls
|
||||
* Shows: Write filename + error message + content preview
|
||||
*/
|
||||
export const WriteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
const { kind, status: _status, content, locations } = toolCall;
|
||||
const isEdit = kind.toLowerCase() === 'edit';
|
||||
const vscode = useVSCode();
|
||||
const { content, locations, rawInput, toolCallId } = toolCall;
|
||||
|
||||
// Group content by type
|
||||
const { errors, diffs } = groupContent(content);
|
||||
const { errors, textOutputs } = groupContent(content);
|
||||
|
||||
const handleOpenDiff = (
|
||||
path: string | undefined,
|
||||
oldText: string | null | undefined,
|
||||
newText: string | undefined,
|
||||
) => {
|
||||
if (path) {
|
||||
vscode.postMessage({
|
||||
type: 'openDiff',
|
||||
data: { path, oldText: oldText || '', newText: newText || '' },
|
||||
});
|
||||
}
|
||||
};
|
||||
// Extract filename from path
|
||||
const getFileName = (path: string): string => path.split('/').pop() || path;
|
||||
|
||||
// Error case: show error with operation label
|
||||
// Extract content to write from rawInput
|
||||
let writeContent = '';
|
||||
if (rawInput && typeof rawInput === 'object') {
|
||||
const inputObj = rawInput as { content?: string };
|
||||
writeContent = inputObj.content || '';
|
||||
} else if (typeof rawInput === 'string') {
|
||||
writeContent = rawInput;
|
||||
}
|
||||
|
||||
// Error case: show filename + error message + content preview
|
||||
if (errors.length > 0) {
|
||||
const path = locations?.[0]?.path || '';
|
||||
const fileName = path ? getFileName(path) : '';
|
||||
const errorMessage = errors.join('\n');
|
||||
|
||||
// Truncate content preview
|
||||
const truncatedContent =
|
||||
writeContent.length > 200
|
||||
? writeContent.substring(0, 200) + '...'
|
||||
: writeContent;
|
||||
|
||||
return (
|
||||
<ToolCallCard icon="✏️">
|
||||
<ToolCallRow label={isEdit ? 'Edit' : 'Write'}>
|
||||
<div style={{ color: '#c74e39', fontWeight: 500 }}>
|
||||
{errors.join('\n')}
|
||||
<ToolCallContainer
|
||||
label={fileName ? `Write ${fileName}` : 'Write'}
|
||||
status="error"
|
||||
toolCallId={toolCallId}
|
||||
>
|
||||
<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="flex-shrink-0 w-full">{errorMessage}</span>
|
||||
</div>
|
||||
{truncatedContent && (
|
||||
<div className="bg-[var(--app-input-background)] border border-[var(--app-input-border)] rounded-md p-3 mt-1">
|
||||
<pre className="font-mono text-[13px] whitespace-pre-wrap break-words text-[var(--app-primary-foreground)] opacity-90">
|
||||
{truncatedContent}
|
||||
</pre>
|
||||
</div>
|
||||
</ToolCallRow>
|
||||
</ToolCallCard>
|
||||
);
|
||||
}
|
||||
|
||||
// Success case with diff: show diff (already has file path)
|
||||
if (diffs.length > 0) {
|
||||
return (
|
||||
<ToolCallCard icon="✏️">
|
||||
{diffs.map(
|
||||
(item: import('./shared/types.js').ToolCallContent, idx: number) => (
|
||||
<div key={`diff-${idx}`} style={{ gridColumn: '1 / -1' }}>
|
||||
<DiffDisplay
|
||||
path={item.path}
|
||||
oldText={item.oldText}
|
||||
newText={item.newText}
|
||||
onOpenDiff={() =>
|
||||
handleOpenDiff(item.path, item.oldText, item.newText)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</ToolCallCard>
|
||||
</ToolCallContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Success case without diff: show operation + file
|
||||
// Success case: show filename + line count
|
||||
if (locations && locations.length > 0) {
|
||||
const fileName = getFileName(locations[0].path);
|
||||
const lineCount = writeContent.split('\n').length;
|
||||
return (
|
||||
<ToolCallCard icon="✏️">
|
||||
<ToolCallRow label={isEdit ? 'Edited' : 'Created'}>
|
||||
<LocationsList locations={locations} />
|
||||
</ToolCallRow>
|
||||
</ToolCallCard>
|
||||
<ToolCallContainer
|
||||
label={`Created ${fileName}`}
|
||||
status="success"
|
||||
toolCallId={toolCallId}
|
||||
>
|
||||
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 flex-row items-start w-full gap-1">
|
||||
<span className="flex-shrink-0 relative top-[-0.1em]">⎿</span>
|
||||
<span className="flex-shrink-0 w-full">{lineCount} lines</span>
|
||||
</div>
|
||||
</ToolCallContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback: show generic success
|
||||
if (textOutputs.length > 0) {
|
||||
return (
|
||||
<ToolCallContainer label="Write" status="success" toolCallId={toolCallId}>
|
||||
{textOutputs.join('\n')}
|
||||
</ToolCallContainer>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,9 +12,11 @@ import { shouldShowToolCall } from './shared/utils.js';
|
||||
import { GenericToolCall } from './GenericToolCall.js';
|
||||
import { ReadToolCall } from './ReadToolCall.js';
|
||||
import { WriteToolCall } from './WriteToolCall.js';
|
||||
import { EditToolCall } from './EditToolCall.js';
|
||||
import { ExecuteToolCall } from './ExecuteToolCall.js';
|
||||
import { SearchToolCall } from './SearchToolCall.js';
|
||||
import { ThinkToolCall } from './ThinkToolCall.js';
|
||||
import { TodoWriteToolCall } from './TodoWriteToolCall.js';
|
||||
|
||||
/**
|
||||
* Factory function that returns the appropriate tool call component based on kind
|
||||
@@ -30,9 +32,11 @@ export const getToolCallComponent = (
|
||||
return ReadToolCall;
|
||||
|
||||
case 'write':
|
||||
case 'edit':
|
||||
return WriteToolCall;
|
||||
|
||||
case 'edit':
|
||||
return EditToolCall;
|
||||
|
||||
case 'execute':
|
||||
case 'bash':
|
||||
case 'command':
|
||||
@@ -48,6 +52,11 @@ export const getToolCallComponent = (
|
||||
case 'thinking':
|
||||
return ThinkToolCall;
|
||||
|
||||
case 'todowrite':
|
||||
case 'todo_write':
|
||||
case 'update_todos':
|
||||
return TodoWriteToolCall;
|
||||
|
||||
// Add more specialized components as needed
|
||||
// case 'fetch':
|
||||
// return FetchToolCall;
|
||||
|
||||
@@ -4,13 +4,81 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Shared layout components for tool call UI
|
||||
* Uses Claude Code style: bullet point + label + content
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { FileLink } from '../../shared/FileLink.js';
|
||||
|
||||
/**
|
||||
* Props for ToolCallCard wrapper
|
||||
* Props for ToolCallContainer - Claude Code style layout
|
||||
*/
|
||||
interface ToolCallContainerProps {
|
||||
/** Operation label (e.g., "Read", "Write", "Search") */
|
||||
label: string;
|
||||
/** Status for bullet color: 'success' | 'error' | 'warning' | 'loading' | 'default' */
|
||||
status?: 'success' | 'error' | 'warning' | 'loading' | 'default';
|
||||
/** Main content to display */
|
||||
children: React.ReactNode;
|
||||
/** Tool call ID for debugging */
|
||||
toolCallId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bullet point color classes based on status
|
||||
*/
|
||||
const getBulletColorClass = (
|
||||
status: 'success' | 'error' | 'warning' | 'loading' | 'default',
|
||||
): string => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'text-[#74c991]';
|
||||
case 'error':
|
||||
return 'text-[#c74e39]';
|
||||
case 'warning':
|
||||
return 'text-[#e1c08d]';
|
||||
case 'loading':
|
||||
return 'text-[var(--app-secondary-foreground)] animate-pulse';
|
||||
default:
|
||||
return 'text-[var(--app-secondary-foreground)]';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Main container with Claude Code style bullet point
|
||||
*/
|
||||
export const ToolCallContainer: React.FC<ToolCallContainerProps> = ({
|
||||
label,
|
||||
status = 'success',
|
||||
children,
|
||||
toolCallId,
|
||||
}) => (
|
||||
<div className="relative pl-[30px] py-2 select-text">
|
||||
<span
|
||||
className={`absolute left-2 top-[10px] text-[10px] ${getBulletColorClass(status)}`}
|
||||
>
|
||||
●
|
||||
</span>
|
||||
<div className="flex flex-col gap-1 pl-[30px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-medium text-[var(--app-primary-foreground)]">
|
||||
{label}
|
||||
</span>
|
||||
{toolCallId && (
|
||||
<span className="text-[10px] opacity-30">
|
||||
[{toolCallId.slice(-8)}]
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{children && (
|
||||
<div className="text-[var(--app-secondary-foreground)]">{children}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Props for ToolCallCard wrapper (legacy - for complex layouts)
|
||||
*/
|
||||
interface ToolCallCardProps {
|
||||
icon: string;
|
||||
@@ -18,15 +86,14 @@ interface ToolCallCardProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Main card wrapper with icon
|
||||
* Legacy card wrapper - kept for backward compatibility with complex layouts like diffs
|
||||
*/
|
||||
export const ToolCallCard: React.FC<ToolCallCardProps> = ({
|
||||
icon: _icon,
|
||||
children,
|
||||
}) => (
|
||||
<div className="tool-call-card">
|
||||
{/* <div className="tool-call-icon">{icon}</div> */}
|
||||
<div className="tool-call-grid">{children}</div>
|
||||
<div className="ml-[30px] grid grid-cols-[auto_1fr] gap-medium bg-[var(--app-input-background)] border border-[var(--app-input-border)] rounded-medium p-large my-medium items-start animate-[fadeIn_0.2s_ease-in]">
|
||||
<div className="flex flex-col gap-medium min-w-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -39,15 +106,19 @@ interface ToolCallRowProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* A single row in the tool call grid
|
||||
* A single row in the tool call grid (legacy - for complex layouts)
|
||||
*/
|
||||
export const ToolCallRow: React.FC<ToolCallRowProps> = ({
|
||||
label,
|
||||
children,
|
||||
}) => (
|
||||
<div className="tool-call-row">
|
||||
<div className="tool-call-label">{label}</div>
|
||||
<div className="tool-call-value">{children}</div>
|
||||
<div className="grid grid-cols-[80px_1fr] gap-medium min-w-0">
|
||||
<div className="text-xs text-[var(--app-secondary-foreground)] font-medium pt-[2px]">
|
||||
{label}
|
||||
</div>
|
||||
<div className="text-[var(--app-primary-foreground)] min-w-0 break-words">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -59,6 +130,26 @@ interface StatusIndicatorProps {
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status color class
|
||||
*/
|
||||
const getStatusColorClass = (
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed',
|
||||
): string => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'bg-[#ffc107]';
|
||||
case 'in_progress':
|
||||
return 'bg-[#2196f3]';
|
||||
case 'completed':
|
||||
return 'bg-[#4caf50]';
|
||||
case 'failed':
|
||||
return 'bg-[#f44336]';
|
||||
default:
|
||||
return 'bg-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Status indicator with colored dot
|
||||
*/
|
||||
@@ -66,7 +157,10 @@ export const StatusIndicator: React.FC<StatusIndicatorProps> = ({
|
||||
status,
|
||||
text,
|
||||
}) => (
|
||||
<div className={`tool-call-status-indicator ${status}`} title={status}>
|
||||
<div className="inline-block font-medium relative" title={status}>
|
||||
<span
|
||||
className={`inline-block w-1.5 h-1.5 rounded-full mr-1.5 align-middle ${getStatusColorClass(status)}`}
|
||||
/>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
@@ -82,7 +176,9 @@ interface CodeBlockProps {
|
||||
* Code block for displaying formatted code or output
|
||||
*/
|
||||
export const CodeBlock: React.FC<CodeBlockProps> = ({ children }) => (
|
||||
<pre className="code-block">{children}</pre>
|
||||
<pre className="font-mono text-[var(--app-monospace-font-size)] bg-[var(--app-primary-background)] border border-[var(--app-input-border)] rounded-small p-medium overflow-x-auto mt-1 whitespace-pre-wrap break-words max-h-[300px] overflow-y-auto">
|
||||
{children}
|
||||
</pre>
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -99,7 +195,7 @@ interface LocationsListProps {
|
||||
* List of file locations with clickable links
|
||||
*/
|
||||
export const LocationsList: React.FC<LocationsListProps> = ({ locations }) => (
|
||||
<div className="locations-list">
|
||||
<div className="flex flex-col gap-1 pl-[30px]">
|
||||
{locations.map((loc, idx) => (
|
||||
<FileLink key={idx} path={loc.path} line={loc.line} showFullPath={true} />
|
||||
))}
|
||||
|
||||
@@ -39,15 +39,16 @@ export const formatValue = (value: unknown): string => {
|
||||
|
||||
/**
|
||||
* Safely convert title to string, handling object types
|
||||
* Returns empty string if no meaningful title
|
||||
*/
|
||||
export const safeTitle = (title: unknown): string => {
|
||||
if (typeof title === 'string') {
|
||||
if (typeof title === 'string' && title.trim()) {
|
||||
return title;
|
||||
}
|
||||
if (title && typeof title === 'object') {
|
||||
return JSON.stringify(title);
|
||||
}
|
||||
return 'Tool Call';
|
||||
return '';
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -88,6 +89,19 @@ export const hasToolCallOutput = (
|
||||
return true;
|
||||
}
|
||||
|
||||
// Always show execute/bash/command tool calls (they show the command in title)
|
||||
const kind = toolCall.kind.toLowerCase();
|
||||
if (kind === 'execute' || kind === 'bash' || kind === 'command') {
|
||||
// But only if they have a title
|
||||
if (
|
||||
toolCall.title &&
|
||||
typeof toolCall.title === 'string' &&
|
||||
toolCall.title.trim()
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Show if there are locations (file paths)
|
||||
if (toolCall.locations && toolCall.locations.length > 0) {
|
||||
return true;
|
||||
@@ -107,6 +121,15 @@ export const hasToolCallOutput = (
|
||||
}
|
||||
}
|
||||
|
||||
// Show if there's a meaningful title for generic tool calls
|
||||
if (
|
||||
toolCall.title &&
|
||||
typeof toolCall.title === 'string' &&
|
||||
toolCall.title.trim()
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// No output, don't show
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ module.exports = {
|
||||
'./src/webview/App.tsx',
|
||||
'./src/webview/components/ui/**/*.{js,jsx,ts,tsx}',
|
||||
'./src/webview/components/messages/**/*.{js,jsx,ts,tsx}',
|
||||
'./src/webview/components/toolcalls/**/*.{js,jsx,ts,tsx}',
|
||||
'./src/webview/components/MessageContent.tsx',
|
||||
'./src/webview/components/InfoBanner.tsx',
|
||||
'./src/webview/components/InputForm.tsx',
|
||||
|
||||
Reference in New Issue
Block a user