mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-05 15:31:27 +00:00
Merge branch 'main' into feat/image-attachment
This commit is contained in:
commit
56030f9291
609 changed files with 26677 additions and 12343 deletions
|
|
@ -21,9 +21,11 @@ import {
|
|||
MINIMUM_MAX_HEIGHT,
|
||||
} from '../components/shared/MaxSizedBox.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { createDebugLogger } from '@qwen-code/qwen-code-core';
|
||||
|
||||
// Configure theming and parsing utilities.
|
||||
const lowlight = createLowlight(common);
|
||||
const debugLogger = createDebugLogger('CODE_COLORIZER');
|
||||
|
||||
function renderHastNode(
|
||||
node: Root | Element | HastText | RootContent,
|
||||
|
|
@ -188,7 +190,7 @@ export function colorizeCode(
|
|||
</MaxSizedBox>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
debugLogger.error(
|
||||
`[colorizeCode] Error highlighting code for language "${language}":`,
|
||||
error,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,71 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import util from 'node:util';
|
||||
import type { ConsoleMessageItem } from '../types.js';
|
||||
|
||||
interface ConsolePatcherParams {
|
||||
onNewMessage?: (message: Omit<ConsoleMessageItem, 'id'>) => void;
|
||||
debugMode: boolean;
|
||||
stderr?: boolean;
|
||||
}
|
||||
|
||||
export class ConsolePatcher {
|
||||
private originalConsoleLog = console.log;
|
||||
private originalConsoleWarn = console.warn;
|
||||
private originalConsoleError = console.error;
|
||||
private originalConsoleDebug = console.debug;
|
||||
private originalConsoleInfo = console.info;
|
||||
|
||||
private params: ConsolePatcherParams;
|
||||
|
||||
constructor(params: ConsolePatcherParams) {
|
||||
this.params = params;
|
||||
}
|
||||
|
||||
patch() {
|
||||
console.log = this.patchConsoleMethod('log', this.originalConsoleLog);
|
||||
console.warn = this.patchConsoleMethod('warn', this.originalConsoleWarn);
|
||||
console.error = this.patchConsoleMethod('error', this.originalConsoleError);
|
||||
console.debug = this.patchConsoleMethod('debug', this.originalConsoleDebug);
|
||||
console.info = this.patchConsoleMethod('info', this.originalConsoleInfo);
|
||||
}
|
||||
|
||||
cleanup = () => {
|
||||
console.log = this.originalConsoleLog;
|
||||
console.warn = this.originalConsoleWarn;
|
||||
console.error = this.originalConsoleError;
|
||||
console.debug = this.originalConsoleDebug;
|
||||
console.info = this.originalConsoleInfo;
|
||||
};
|
||||
|
||||
private formatArgs = (args: unknown[]): string => util.format(...args);
|
||||
|
||||
private patchConsoleMethod =
|
||||
(
|
||||
type: 'log' | 'warn' | 'error' | 'debug' | 'info',
|
||||
originalMethod: (...args: unknown[]) => void,
|
||||
) =>
|
||||
(...args: unknown[]) => {
|
||||
if (this.params.stderr) {
|
||||
if (type !== 'debug' || this.params.debugMode) {
|
||||
this.originalConsoleError(this.formatArgs(args));
|
||||
}
|
||||
} else {
|
||||
if (this.params.debugMode) {
|
||||
originalMethod.apply(console, args);
|
||||
}
|
||||
|
||||
if (type !== 'debug' || this.params.debugMode) {
|
||||
this.params.onNewMessage?.({
|
||||
type,
|
||||
content: this.formatArgs(args),
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import React from 'react';
|
|||
import { Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import stringWidth from 'string-width';
|
||||
import { createDebugLogger } from '@qwen-code/qwen-code-core';
|
||||
|
||||
// Constants for Markdown parsing
|
||||
const BOLD_MARKER_LENGTH = 2; // For "**"
|
||||
|
|
@ -17,6 +18,8 @@ const INLINE_CODE_MARKER_LENGTH = 1; // For "`"
|
|||
const UNDERLINE_TAG_START_LENGTH = 3; // For "<u>"
|
||||
const UNDERLINE_TAG_END_LENGTH = 4; // For "</u>"
|
||||
|
||||
const debugLogger = createDebugLogger('INLINE_MARKDOWN');
|
||||
|
||||
interface RenderInlineProps {
|
||||
text: string;
|
||||
textColor?: string;
|
||||
|
|
@ -143,7 +146,7 @@ const RenderInlineInternal: React.FC<RenderInlineProps> = ({
|
|||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing inline markdown part:', fullMatch, e);
|
||||
debugLogger.error('Error parsing inline markdown part:', fullMatch, e);
|
||||
renderedNode = null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@
|
|||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { ClipboardManager } from '@teddyzhu/clipboard';
|
||||
import { createDebugLogger } from '@qwen-code/qwen-code-core';
|
||||
|
||||
const debugLogger = createDebugLogger('CLIPBOARD_UTILS');
|
||||
|
||||
/**
|
||||
* Checks if the system clipboard contains an image
|
||||
|
|
@ -17,9 +20,7 @@ export async function clipboardHasImage(): Promise<boolean> {
|
|||
const clipboard = new ClipboardManager();
|
||||
return clipboard.hasFormat('image');
|
||||
} catch (error) {
|
||||
if (process.env['DEBUG']) {
|
||||
console.error('Error checking clipboard for image:', error);
|
||||
}
|
||||
debugLogger.error('Error checking clipboard for image:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -61,9 +62,7 @@ export async function saveClipboardImage(
|
|||
|
||||
return tempFilePath;
|
||||
} catch (error) {
|
||||
if (process.env['DEBUG']) {
|
||||
console.error('Error saving clipboard image:', error);
|
||||
}
|
||||
debugLogger.error('Error saving clipboard image:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
import type { SpawnOptions } from 'node:child_process';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createDebugLogger } from '@qwen-code/qwen-code-core';
|
||||
|
||||
/**
|
||||
* Common Windows console code pages (CP) used for encoding conversions.
|
||||
|
|
@ -61,6 +62,8 @@ export const isSlashCommand = (query: string): boolean => {
|
|||
return true;
|
||||
};
|
||||
|
||||
const debugLogger = createDebugLogger('COMMAND_UTILS');
|
||||
|
||||
// Copies a string snippet to the clipboard for different platforms
|
||||
export const copyToClipboard = async (text: string): Promise<void> => {
|
||||
const run = (cmd: string, args: string[], options?: SpawnOptions) =>
|
||||
|
|
@ -162,7 +165,7 @@ export const getUrlOpenCommand = (): string => {
|
|||
default:
|
||||
// Default to xdg-open, which appears to be supported for the less popular operating systems.
|
||||
openCmd = 'xdg-open';
|
||||
console.warn(
|
||||
debugLogger.warn(
|
||||
`Unknown platform: ${process.platform}. Attempting to open URLs with: ${openCmd}.`,
|
||||
);
|
||||
break;
|
||||
|
|
|
|||
266
packages/cli/src/ui/utils/export/collect.ts
Normal file
266
packages/cli/src/ui/utils/export/collect.ts
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Config, ChatRecord } from '@qwen-code/qwen-code-core';
|
||||
import type { SessionContext } from '../../../acp-integration/session/types.js';
|
||||
import type * as acp from '../../../acp-integration/acp.js';
|
||||
import { HistoryReplayer } from '../../../acp-integration/session/HistoryReplayer.js';
|
||||
import type { ExportMessage, ExportSessionData } from './types.js';
|
||||
|
||||
/**
|
||||
* Export session context that captures session updates into export messages.
|
||||
* Implements SessionContext to work with HistoryReplayer.
|
||||
*/
|
||||
class ExportSessionContext implements SessionContext {
|
||||
readonly sessionId: string;
|
||||
readonly config: Config;
|
||||
private messages: ExportMessage[] = [];
|
||||
private currentMessage: {
|
||||
type: 'user' | 'assistant';
|
||||
role: 'user' | 'assistant' | 'thinking';
|
||||
parts: Array<{ text: string }>;
|
||||
timestamp: number;
|
||||
} | null = null;
|
||||
private activeRecordId: string | null = null;
|
||||
private activeRecordTimestamp: string | null = null;
|
||||
private toolCallMap: Map<string, ExportMessage['toolCall']> = new Map();
|
||||
|
||||
constructor(sessionId: string, config: Config) {
|
||||
this.sessionId = sessionId;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
async sendUpdate(update: acp.SessionUpdate): Promise<void> {
|
||||
switch (update.sessionUpdate) {
|
||||
case 'user_message_chunk':
|
||||
this.handleMessageChunk('user', update.content);
|
||||
break;
|
||||
case 'agent_message_chunk':
|
||||
this.handleMessageChunk('assistant', update.content);
|
||||
break;
|
||||
case 'agent_thought_chunk':
|
||||
this.handleMessageChunk('assistant', update.content, 'thinking');
|
||||
break;
|
||||
case 'tool_call':
|
||||
this.flushCurrentMessage();
|
||||
this.handleToolCallStart(update);
|
||||
break;
|
||||
case 'tool_call_update':
|
||||
this.handleToolCallUpdate(update);
|
||||
break;
|
||||
case 'plan':
|
||||
this.flushCurrentMessage();
|
||||
this.handlePlanUpdate(update);
|
||||
break;
|
||||
default:
|
||||
// Ignore other update types
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setActiveRecordId(recordId: string | null, timestamp?: string): void {
|
||||
this.activeRecordId = recordId;
|
||||
this.activeRecordTimestamp = timestamp ?? null;
|
||||
}
|
||||
|
||||
private getMessageTimestamp(): string {
|
||||
return this.activeRecordTimestamp ?? new Date().toISOString();
|
||||
}
|
||||
|
||||
private getMessageUuid(): string {
|
||||
return this.activeRecordId ?? randomUUID();
|
||||
}
|
||||
|
||||
private handleMessageChunk(
|
||||
role: 'user' | 'assistant',
|
||||
content: { type: string; text?: string },
|
||||
messageRole: 'user' | 'assistant' | 'thinking' = role,
|
||||
): void {
|
||||
if (content.type !== 'text' || !content.text) return;
|
||||
|
||||
// If we're starting a new message type, flush the previous one
|
||||
if (
|
||||
this.currentMessage &&
|
||||
(this.currentMessage.type !== role ||
|
||||
this.currentMessage.role !== messageRole)
|
||||
) {
|
||||
this.flushCurrentMessage();
|
||||
}
|
||||
|
||||
// Add to current message or create new one
|
||||
if (
|
||||
this.currentMessage &&
|
||||
this.currentMessage.type === role &&
|
||||
this.currentMessage.role === messageRole
|
||||
) {
|
||||
this.currentMessage.parts.push({ text: content.text });
|
||||
} else {
|
||||
this.currentMessage = {
|
||||
type: role,
|
||||
role: messageRole,
|
||||
parts: [{ text: content.text }],
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private handleToolCallStart(update: acp.ToolCall): void {
|
||||
const toolCall: ExportMessage['toolCall'] = {
|
||||
toolCallId: update.toolCallId,
|
||||
kind: update.kind || 'other',
|
||||
title:
|
||||
typeof update.title === 'string' ? update.title : update.title || '',
|
||||
status: update.status || 'pending',
|
||||
rawInput: update.rawInput as string | object | undefined,
|
||||
locations: update.locations,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.toolCallMap.set(update.toolCallId, toolCall);
|
||||
|
||||
// Immediately add tool call to messages to preserve order
|
||||
const uuid = this.getMessageUuid();
|
||||
this.messages.push({
|
||||
uuid,
|
||||
sessionId: this.sessionId,
|
||||
timestamp: this.getMessageTimestamp(),
|
||||
type: 'tool_call',
|
||||
toolCall,
|
||||
});
|
||||
}
|
||||
|
||||
private handleToolCallUpdate(update: {
|
||||
toolCallId: string;
|
||||
status?: 'pending' | 'in_progress' | 'completed' | 'failed' | null;
|
||||
title?: string | null;
|
||||
content?: Array<{ type: string; [key: string]: unknown }> | null;
|
||||
kind?: string | null;
|
||||
}): void {
|
||||
const toolCall = this.toolCallMap.get(update.toolCallId);
|
||||
if (toolCall) {
|
||||
// Update the tool call in place
|
||||
if (update.status) toolCall.status = update.status;
|
||||
if (update.content) toolCall.content = update.content;
|
||||
if (update.title)
|
||||
toolCall.title = typeof update.title === 'string' ? update.title : '';
|
||||
}
|
||||
}
|
||||
|
||||
private handlePlanUpdate(update: {
|
||||
entries: Array<{
|
||||
content: string;
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
priority?: string;
|
||||
}>;
|
||||
}): void {
|
||||
// Create a tool_call message for plan updates (TodoWriteTool)
|
||||
// This ensures todos appear at the correct position in the chat
|
||||
const uuid = this.getMessageUuid();
|
||||
const timestamp = this.getMessageTimestamp();
|
||||
|
||||
// Format entries as markdown checklist text for UpdatedPlanToolCall.parsePlanEntries
|
||||
const todoText = update.entries
|
||||
.map((entry) => {
|
||||
const checkbox =
|
||||
entry.status === 'completed'
|
||||
? '[x]'
|
||||
: entry.status === 'in_progress'
|
||||
? '[-]'
|
||||
: '[ ]';
|
||||
return `- ${checkbox} ${entry.content}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const todoContent = [
|
||||
{
|
||||
type: 'content' as const,
|
||||
content: {
|
||||
type: 'text',
|
||||
text: todoText,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
this.messages.push({
|
||||
uuid,
|
||||
sessionId: this.sessionId,
|
||||
timestamp,
|
||||
type: 'tool_call',
|
||||
toolCall: {
|
||||
toolCallId: uuid, // Use the same uuid as toolCallId for plan updates
|
||||
kind: 'todowrite',
|
||||
title: 'TodoWrite',
|
||||
status: 'completed',
|
||||
content: todoContent,
|
||||
timestamp: Date.parse(timestamp),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private flushCurrentMessage(): void {
|
||||
if (!this.currentMessage) return;
|
||||
|
||||
const uuid = this.getMessageUuid();
|
||||
this.messages.push({
|
||||
uuid,
|
||||
sessionId: this.sessionId,
|
||||
timestamp: this.getMessageTimestamp(),
|
||||
type: this.currentMessage.type,
|
||||
message: {
|
||||
role: this.currentMessage.role,
|
||||
parts: this.currentMessage.parts,
|
||||
},
|
||||
});
|
||||
|
||||
this.currentMessage = null;
|
||||
}
|
||||
|
||||
flushMessages(): void {
|
||||
this.flushCurrentMessage();
|
||||
}
|
||||
|
||||
getMessages(): ExportMessage[] {
|
||||
return this.messages;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects session data from ChatRecord[] using HistoryReplayer.
|
||||
* Returns the raw ExportSessionData (SSOT) without normalization.
|
||||
*/
|
||||
export async function collectSessionData(
|
||||
conversation: {
|
||||
sessionId: string;
|
||||
startTime: string;
|
||||
messages: ChatRecord[];
|
||||
},
|
||||
config: Config,
|
||||
): Promise<ExportSessionData> {
|
||||
// Create export session context
|
||||
const exportContext = new ExportSessionContext(
|
||||
conversation.sessionId,
|
||||
config,
|
||||
);
|
||||
|
||||
// Create history replayer with export context
|
||||
const replayer = new HistoryReplayer(exportContext);
|
||||
|
||||
// Replay chat records to build export messages
|
||||
await replayer.replay(conversation.messages);
|
||||
|
||||
// Flush any buffered messages
|
||||
exportContext.flushMessages();
|
||||
|
||||
// Get the export messages
|
||||
const messages = exportContext.getMessages();
|
||||
|
||||
return {
|
||||
sessionId: conversation.sessionId,
|
||||
startTime: conversation.startTime,
|
||||
messages,
|
||||
};
|
||||
}
|
||||
83
packages/cli/src/ui/utils/export/formatters/html.ts
Normal file
83
packages/cli/src/ui/utils/export/formatters/html.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ExportSessionData } from '../types.js';
|
||||
import { HTML_TEMPLATE } from './htmlTemplate.js';
|
||||
|
||||
/**
|
||||
* Escapes JSON for safe embedding in HTML.
|
||||
*/
|
||||
function escapeJsonForHtml(json: string): string {
|
||||
return json
|
||||
.replace(/<\/script/gi, '<\\/script')
|
||||
.replace(/&/g, '\\u0026')
|
||||
.replace(/</g, '\\u003c')
|
||||
.replace(/>/g, '\\u003e')
|
||||
.replace(/\u2028/g, '\\u2028')
|
||||
.replace(/\u2029/g, '\\u2029');
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the HTML template built from assets.
|
||||
*/
|
||||
export function loadHtmlTemplate(): string {
|
||||
return HTML_TEMPLATE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects JSON data into the HTML template.
|
||||
*/
|
||||
export function injectDataIntoHtmlTemplate(
|
||||
template: string,
|
||||
data: {
|
||||
sessionId: string;
|
||||
startTime: string;
|
||||
messages: unknown[];
|
||||
},
|
||||
): string {
|
||||
const jsonData = JSON.stringify(data, null, 2);
|
||||
const escapedJsonData = escapeJsonForHtml(jsonData);
|
||||
const idAttribute = 'id="chat-data"';
|
||||
const idIndex = template.indexOf(idAttribute);
|
||||
if (idIndex === -1) {
|
||||
return template;
|
||||
}
|
||||
|
||||
const openTagStart = template.lastIndexOf('<script', idIndex);
|
||||
if (openTagStart === -1) {
|
||||
return template;
|
||||
}
|
||||
|
||||
const openTagEnd = template.indexOf('>', idIndex);
|
||||
if (openTagEnd === -1) {
|
||||
return template;
|
||||
}
|
||||
|
||||
const closeTagStart = template.indexOf('</script>', openTagEnd);
|
||||
if (closeTagStart === -1) {
|
||||
return template;
|
||||
}
|
||||
|
||||
const lineStart = template.lastIndexOf('\n', openTagStart);
|
||||
const lineIndent =
|
||||
lineStart === -1 ? '' : template.slice(lineStart + 1, openTagStart);
|
||||
const indentedJson = escapedJsonData
|
||||
.split('\n')
|
||||
.map((line) => `${lineIndent}${line}`)
|
||||
.join('\n');
|
||||
|
||||
const before = template.slice(0, openTagEnd + 1);
|
||||
const after = template.slice(closeTagStart);
|
||||
return `${before}\n${indentedJson}\n${after}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts ExportSessionData to HTML format.
|
||||
*/
|
||||
export function toHtml(sessionData: ExportSessionData): string {
|
||||
const template = loadHtmlTemplate();
|
||||
return injectDataIntoHtmlTemplate(template, sessionData);
|
||||
}
|
||||
10
packages/cli/src/ui/utils/export/formatters/htmlTemplate.ts
Normal file
10
packages/cli/src/ui/utils/export/formatters/htmlTemplate.ts
Normal file
File diff suppressed because one or more lines are too long
15
packages/cli/src/ui/utils/export/formatters/json.ts
Normal file
15
packages/cli/src/ui/utils/export/formatters/json.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ExportSessionData } from '../types.js';
|
||||
|
||||
/**
|
||||
* Converts ExportSessionData to JSON format.
|
||||
* Outputs a single JSON object containing the entire session.
|
||||
*/
|
||||
export function toJson(sessionData: ExportSessionData): string {
|
||||
return JSON.stringify(sessionData, null, 2);
|
||||
}
|
||||
31
packages/cli/src/ui/utils/export/formatters/jsonl.ts
Normal file
31
packages/cli/src/ui/utils/export/formatters/jsonl.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ExportSessionData } from '../types.js';
|
||||
|
||||
/**
|
||||
* Converts ExportSessionData to JSONL (JSON Lines) format.
|
||||
* Each message is output as a separate JSON object on its own line.
|
||||
*/
|
||||
export function toJsonl(sessionData: ExportSessionData): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
// Add session metadata as the first line
|
||||
lines.push(
|
||||
JSON.stringify({
|
||||
type: 'session_metadata',
|
||||
sessionId: sessionData.sessionId,
|
||||
startTime: sessionData.startTime,
|
||||
}),
|
||||
);
|
||||
|
||||
// Add each message as a separate line
|
||||
for (const message of sessionData.messages) {
|
||||
lines.push(JSON.stringify(message));
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
225
packages/cli/src/ui/utils/export/formatters/markdown.ts
Normal file
225
packages/cli/src/ui/utils/export/formatters/markdown.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ExportSessionData, ExportMessage } from '../types.js';
|
||||
|
||||
/**
|
||||
* Converts ExportSessionData to markdown format.
|
||||
*/
|
||||
export function toMarkdown(sessionData: ExportSessionData): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
// Add header with metadata
|
||||
lines.push('# Chat Session Export\n');
|
||||
lines.push(`- **Session ID**: \`${sanitizeText(sessionData.sessionId)}\``);
|
||||
lines.push(`- **Start Time**: ${sanitizeText(sessionData.startTime)}`);
|
||||
lines.push(`- **Exported**: ${new Date().toISOString()}`);
|
||||
lines.push('\n---\n');
|
||||
|
||||
// Process each message
|
||||
for (const message of sessionData.messages) {
|
||||
if (message.type === 'user') {
|
||||
lines.push('## User\n');
|
||||
lines.push(formatMessageContent(message));
|
||||
} else if (message.type === 'assistant') {
|
||||
lines.push('## Assistant\n');
|
||||
lines.push(formatMessageContent(message));
|
||||
} else if (message.type === 'tool_call') {
|
||||
lines.push(formatToolCall(message));
|
||||
} else if (message.type === 'system') {
|
||||
lines.push('### System\n');
|
||||
// Format as blockquote
|
||||
const text = formatMessageContent(message);
|
||||
lines.push(`> ${text.replace(/\n/g, '\n> ')}`);
|
||||
}
|
||||
|
||||
lines.push('\n');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatMessageContent(message: ExportMessage): string {
|
||||
const text = extractTextFromMessage(message);
|
||||
|
||||
// Special handling for "Content from referenced files"
|
||||
// We look for the pattern: --- Content from referenced files --- ... --- End of content ---
|
||||
// and wrap the inner content in code blocks if possible.
|
||||
|
||||
// Note: This simple regex replacement might be fragile if nested, but usually this marker is top-level.
|
||||
// We'll use a replacer function to handle the wrapping.
|
||||
|
||||
const processedText = text.replace(
|
||||
/--- Content from referenced files ---\n([\s\S]*?)\n--- End of content ---/g,
|
||||
(match, content) =>
|
||||
`\n> **Referenced Files:**\n\n${createCodeBlock(content)}\n`,
|
||||
);
|
||||
|
||||
return processedText;
|
||||
}
|
||||
|
||||
function formatToolCall(message: ExportMessage): string {
|
||||
if (!message.toolCall) return '';
|
||||
|
||||
const lines: string[] = [];
|
||||
const { title, status, rawInput, content, locations } = message.toolCall;
|
||||
|
||||
const titleStr = typeof title === 'string' ? title : JSON.stringify(title);
|
||||
|
||||
lines.push(`### Tool: ${sanitizeText(titleStr)}`);
|
||||
lines.push(`**Status**: ${sanitizeText(status)}\n`);
|
||||
|
||||
// Input
|
||||
if (rawInput) {
|
||||
lines.push('**Input:**');
|
||||
const inputStr =
|
||||
typeof rawInput === 'string'
|
||||
? rawInput
|
||||
: JSON.stringify(rawInput, null, 2);
|
||||
lines.push(createCodeBlock(inputStr, 'json'));
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Locations
|
||||
if (locations && locations.length > 0) {
|
||||
lines.push('**Affected Files:**');
|
||||
for (const loc of locations) {
|
||||
const lineSuffix = loc.line ? `:${loc.line}` : '';
|
||||
lines.push(`- \`${sanitizeText(loc.path)}${lineSuffix}\``);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Output Content
|
||||
if (content && content.length > 0) {
|
||||
lines.push('**Output:**');
|
||||
|
||||
for (const item of content) {
|
||||
if (item.type === 'content' && item['content']) {
|
||||
const contentData = item['content'] as { type: string; text?: string };
|
||||
if (contentData.type === 'text' && contentData.text) {
|
||||
// Try to infer language from locations if available and if there is only one location
|
||||
// or if the tool title suggests a file operation.
|
||||
let language = '';
|
||||
if (locations && locations.length === 1 && locations[0].path) {
|
||||
language = getLanguageFromPath(locations[0].path);
|
||||
}
|
||||
|
||||
lines.push(createCodeBlock(contentData.text, language));
|
||||
}
|
||||
} else if (item.type === 'diff') {
|
||||
const path = item['path'] as string;
|
||||
const diffText = item['newText'] as string;
|
||||
lines.push(`\n*Diff for \`${sanitizeText(path)}\`:*`);
|
||||
lines.push(createCodeBlock(diffText, 'diff'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts text content from an export message.
|
||||
*/
|
||||
function extractTextFromMessage(message: ExportMessage): string {
|
||||
if (!message.message?.parts) return '';
|
||||
|
||||
const textParts: string[] = [];
|
||||
for (const part of message.message.parts) {
|
||||
if ('text' in part) {
|
||||
textParts.push(part.text);
|
||||
}
|
||||
}
|
||||
|
||||
return textParts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a markdown code block with dynamic fence length to avoid escaping issues.
|
||||
* Does NOT escape HTML content inside the block, as that would break code readability.
|
||||
* Security is handled by the fence.
|
||||
*/
|
||||
function createCodeBlock(content: string, language: string = ''): string {
|
||||
const fence = buildFence(content);
|
||||
return `${fence}${language}\n${content}\n${fence}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes text to prevent HTML injection while preserving Markdown.
|
||||
* Only escapes < and & to avoid breaking Markdown structures like code blocks (if used inline) or quotes.
|
||||
*/
|
||||
function sanitizeText(value: string): string {
|
||||
return (value ?? '').replace(/&/g, '&').replace(/</g, '<');
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the necessary fence length for a code block.
|
||||
* Ensures the fence is longer than any sequence of backticks in the content.
|
||||
*/
|
||||
function buildFence(value: string): string {
|
||||
const matches = (value ?? '').match(/`+/g);
|
||||
const maxRun = matches
|
||||
? Math.max(...matches.map((match) => match.length))
|
||||
: 0;
|
||||
const fenceLength = Math.max(3, maxRun + 1);
|
||||
return '`'.repeat(fenceLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple helper to guess language from file extension.
|
||||
*/
|
||||
function getLanguageFromPath(path: string): string {
|
||||
const ext = path.split('.').pop()?.toLowerCase();
|
||||
switch (ext) {
|
||||
case 'ts':
|
||||
case 'tsx':
|
||||
return 'typescript';
|
||||
case 'js':
|
||||
case 'jsx':
|
||||
case 'mjs':
|
||||
case 'cjs':
|
||||
return 'javascript';
|
||||
case 'py':
|
||||
return 'python';
|
||||
case 'rb':
|
||||
return 'ruby';
|
||||
case 'go':
|
||||
return 'go';
|
||||
case 'rs':
|
||||
return 'rust';
|
||||
case 'java':
|
||||
return 'java';
|
||||
case 'c':
|
||||
case 'cpp':
|
||||
case 'h':
|
||||
case 'hpp':
|
||||
return 'cpp';
|
||||
case 'cs':
|
||||
return 'csharp';
|
||||
case 'html':
|
||||
return 'html';
|
||||
case 'css':
|
||||
return 'css';
|
||||
case 'json':
|
||||
return 'json';
|
||||
case 'md':
|
||||
return 'markdown';
|
||||
case 'sh':
|
||||
case 'bash':
|
||||
case 'zsh':
|
||||
return 'bash';
|
||||
case 'yaml':
|
||||
case 'yml':
|
||||
return 'yaml';
|
||||
case 'xml':
|
||||
return 'xml';
|
||||
case 'sql':
|
||||
return 'sql';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
18
packages/cli/src/ui/utils/export/index.ts
Normal file
18
packages/cli/src/ui/utils/export/index.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export type { ExportMessage, ExportSessionData } from './types.js';
|
||||
export { collectSessionData } from './collect.js';
|
||||
export { normalizeSessionData } from './normalize.js';
|
||||
export { toMarkdown } from './formatters/markdown.js';
|
||||
export {
|
||||
toHtml,
|
||||
loadHtmlTemplate,
|
||||
injectDataIntoHtmlTemplate,
|
||||
} from './formatters/html.js';
|
||||
export { toJson } from './formatters/json.js';
|
||||
export { toJsonl } from './formatters/jsonl.js';
|
||||
export { generateExportFilename } from './utils.js';
|
||||
324
packages/cli/src/ui/utils/export/normalize.ts
Normal file
324
packages/cli/src/ui/utils/export/normalize.ts
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Part } from '@google/genai';
|
||||
import { ExitPlanModeTool, ToolNames } from '@qwen-code/qwen-code-core';
|
||||
import type { ChatRecord, Config, Kind } from '@qwen-code/qwen-code-core';
|
||||
import type { ExportMessage, ExportSessionData } from './types.js';
|
||||
|
||||
/**
|
||||
* Normalizes export session data by merging tool call information from tool_result records.
|
||||
* This ensures the SSOT contains complete tool call metadata.
|
||||
*/
|
||||
export function normalizeSessionData(
|
||||
sessionData: ExportSessionData,
|
||||
originalRecords: ChatRecord[],
|
||||
config: Config,
|
||||
): ExportSessionData {
|
||||
const normalized = [...sessionData.messages];
|
||||
const toolCallIndexById = new Map<string, number>();
|
||||
|
||||
// Build index of tool call messages
|
||||
normalized.forEach((message, index) => {
|
||||
if (message.type === 'tool_call' && message.toolCall?.toolCallId) {
|
||||
toolCallIndexById.set(message.toolCall.toolCallId, index);
|
||||
}
|
||||
});
|
||||
|
||||
// Merge tool result information into tool call messages
|
||||
for (const record of originalRecords) {
|
||||
if (record.type !== 'tool_result') continue;
|
||||
|
||||
const toolCallMessage = buildToolCallMessageFromResult(record, config);
|
||||
if (!toolCallMessage?.toolCall) continue;
|
||||
|
||||
const existingIndex = toolCallIndexById.get(
|
||||
toolCallMessage.toolCall.toolCallId,
|
||||
);
|
||||
|
||||
if (existingIndex === undefined) {
|
||||
// No existing tool call, add this one
|
||||
toolCallIndexById.set(
|
||||
toolCallMessage.toolCall.toolCallId,
|
||||
normalized.length,
|
||||
);
|
||||
normalized.push(toolCallMessage);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Merge into existing tool call
|
||||
const existingMessage = normalized[existingIndex];
|
||||
if (existingMessage.type !== 'tool_call' || !existingMessage.toolCall) {
|
||||
continue;
|
||||
}
|
||||
|
||||
mergeToolCallData(existingMessage.toolCall, toolCallMessage.toolCall);
|
||||
}
|
||||
|
||||
return {
|
||||
...sessionData,
|
||||
messages: normalized,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges incoming tool call data into existing tool call.
|
||||
*/
|
||||
function mergeToolCallData(
|
||||
existing: NonNullable<ExportMessage['toolCall']>,
|
||||
incoming: NonNullable<ExportMessage['toolCall']>,
|
||||
): void {
|
||||
if (!existing.content || existing.content.length === 0) {
|
||||
existing.content = incoming.content;
|
||||
}
|
||||
if (existing.status === 'pending' || existing.status === 'in_progress') {
|
||||
existing.status = incoming.status;
|
||||
}
|
||||
if (!existing.rawInput && incoming.rawInput) {
|
||||
existing.rawInput = incoming.rawInput;
|
||||
}
|
||||
if (!existing.kind || existing.kind === 'other') {
|
||||
existing.kind = incoming.kind;
|
||||
}
|
||||
if ((!existing.title || existing.title === '') && incoming.title) {
|
||||
existing.title = incoming.title;
|
||||
}
|
||||
if (
|
||||
(!existing.locations || existing.locations.length === 0) &&
|
||||
incoming.locations &&
|
||||
incoming.locations.length > 0
|
||||
) {
|
||||
existing.locations = incoming.locations;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a tool call message from a tool_result ChatRecord.
|
||||
*/
|
||||
function buildToolCallMessageFromResult(
|
||||
record: ChatRecord,
|
||||
config: Config,
|
||||
): ExportMessage | null {
|
||||
const toolCallResult = record.toolCallResult;
|
||||
const toolName = extractToolNameFromRecord(record);
|
||||
|
||||
// Skip todo_write tool - it's already handled by plan update in collect.ts
|
||||
// This prevents duplicate todo messages in the export
|
||||
if (toolName === ToolNames.TODO_WRITE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toolCallId = toolCallResult?.callId ?? record.uuid;
|
||||
const functionCallArgs = extractFunctionCallArgs(record);
|
||||
const { kind, title, locations } = resolveToolMetadata(
|
||||
config,
|
||||
toolName,
|
||||
functionCallArgs ??
|
||||
(toolCallResult as { args?: Record<string, unknown> } | undefined)?.args,
|
||||
);
|
||||
const rawInput = normalizeRawInput(
|
||||
functionCallArgs ??
|
||||
(toolCallResult as { args?: unknown } | undefined)?.args,
|
||||
);
|
||||
|
||||
const content =
|
||||
extractDiffContent(toolCallResult?.resultDisplay) ??
|
||||
transformPartsToToolCallContent(record.message?.parts ?? []);
|
||||
|
||||
return {
|
||||
uuid: record.uuid,
|
||||
parentUuid: record.parentUuid,
|
||||
sessionId: record.sessionId,
|
||||
timestamp: record.timestamp,
|
||||
type: 'tool_call',
|
||||
toolCall: {
|
||||
toolCallId,
|
||||
kind,
|
||||
title,
|
||||
status: toolCallResult?.error ? 'failed' : 'completed',
|
||||
rawInput,
|
||||
content,
|
||||
locations,
|
||||
timestamp: Date.parse(record.timestamp),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts tool name from a ChatRecord.
|
||||
*/
|
||||
function extractToolNameFromRecord(record: ChatRecord): string {
|
||||
if (!record.message?.parts) {
|
||||
return '';
|
||||
}
|
||||
|
||||
for (const part of record.message.parts) {
|
||||
if ('functionResponse' in part && part.functionResponse?.name) {
|
||||
return part.functionResponse.name;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts function call args from a ChatRecord.
|
||||
*/
|
||||
function extractFunctionCallArgs(
|
||||
record: ChatRecord,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!record.message?.parts) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const part of record.message.parts) {
|
||||
if ('functionCall' in part && part.functionCall?.args) {
|
||||
return part.functionCall.args as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves tool metadata (kind, title, locations) from tool registry.
|
||||
*/
|
||||
function resolveToolMetadata(
|
||||
config: Config,
|
||||
toolName: string,
|
||||
args?: Record<string, unknown>,
|
||||
): {
|
||||
kind: string;
|
||||
title: string | object;
|
||||
locations?: Array<{ path: string; line?: number | null }>;
|
||||
} {
|
||||
const toolRegistry = config.getToolRegistry?.();
|
||||
const tool = toolName ? toolRegistry?.getTool?.(toolName) : undefined;
|
||||
|
||||
let title: string | object = tool?.displayName ?? toolName ?? 'tool_call';
|
||||
let locations: Array<{ path: string; line?: number | null }> | undefined;
|
||||
const kind = mapToolKind(tool?.kind as Kind | undefined, toolName);
|
||||
|
||||
if (tool && args) {
|
||||
try {
|
||||
const invocation = tool.build(args);
|
||||
title = `${title}: ${invocation.getDescription()}`;
|
||||
locations = invocation.toolLocations().map((loc) => ({
|
||||
path: loc.path,
|
||||
line: loc.line ?? null,
|
||||
}));
|
||||
} catch {
|
||||
// Keep defaults on build failure
|
||||
}
|
||||
}
|
||||
|
||||
return { kind, title, locations };
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps tool kind to allowed export kinds.
|
||||
*/
|
||||
function mapToolKind(kind: Kind | undefined, toolName?: string): string {
|
||||
if (toolName && toolName === ExitPlanModeTool.Name) {
|
||||
return 'switch_mode';
|
||||
}
|
||||
|
||||
if (toolName && toolName === ToolNames.TODO_WRITE) {
|
||||
return 'todowrite';
|
||||
}
|
||||
|
||||
const allowedKinds = new Set<string>([
|
||||
'read',
|
||||
'edit',
|
||||
'delete',
|
||||
'move',
|
||||
'search',
|
||||
'execute',
|
||||
'think',
|
||||
'fetch',
|
||||
'other',
|
||||
]);
|
||||
|
||||
if (kind && allowedKinds.has(kind)) {
|
||||
return kind;
|
||||
}
|
||||
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts diff content from tool result display.
|
||||
*/
|
||||
function extractDiffContent(
|
||||
resultDisplay: unknown,
|
||||
): Array<{ type: string; [key: string]: unknown }> | null {
|
||||
if (!resultDisplay || typeof resultDisplay !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const display = resultDisplay as Record<string, unknown>;
|
||||
if ('fileName' in display && 'newContent' in display) {
|
||||
return [
|
||||
{
|
||||
type: 'diff',
|
||||
path: display['fileName'] as string,
|
||||
oldText: (display['originalContent'] as string) ?? '',
|
||||
newText: display['newContent'] as string,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes raw input to string or object.
|
||||
*/
|
||||
function normalizeRawInput(value: unknown): string | object | undefined {
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'object' && value !== null) return value;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms Parts to tool call content array.
|
||||
*/
|
||||
function transformPartsToToolCallContent(
|
||||
parts: Part[],
|
||||
): Array<{ type: string; [key: string]: unknown }> {
|
||||
const content: Array<{ type: string; [key: string]: unknown }> = [];
|
||||
|
||||
for (const part of parts) {
|
||||
if ('text' in part && part.text) {
|
||||
content.push({
|
||||
type: 'content',
|
||||
content: { type: 'text', text: part.text },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if ('functionResponse' in part && part.functionResponse) {
|
||||
const response = part.functionResponse.response as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const outputField = response?.['output'];
|
||||
const errorField = response?.['error'];
|
||||
const responseText =
|
||||
typeof outputField === 'string'
|
||||
? outputField
|
||||
: typeof errorField === 'string'
|
||||
? errorField
|
||||
: JSON.stringify(response);
|
||||
content.push({
|
||||
type: 'content',
|
||||
content: { type: 'text', text: responseText },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
54
packages/cli/src/ui/utils/export/types.ts
Normal file
54
packages/cli/src/ui/utils/export/types.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Universal export message format - SSOT for all export formats.
|
||||
* This is format-agnostic and contains all information needed for any export type.
|
||||
*/
|
||||
export interface ExportMessage {
|
||||
uuid: string;
|
||||
parentUuid?: string | null;
|
||||
sessionId?: string;
|
||||
timestamp: string;
|
||||
type: 'user' | 'assistant' | 'system' | 'tool_call';
|
||||
|
||||
/** For user/assistant messages */
|
||||
message?: {
|
||||
role?: string;
|
||||
parts?: Array<{ text: string }>;
|
||||
content?: string;
|
||||
};
|
||||
|
||||
/** Model used for assistant messages */
|
||||
model?: string;
|
||||
|
||||
/** For tool_call messages */
|
||||
toolCall?: {
|
||||
toolCallId: string;
|
||||
kind: string;
|
||||
title: string | object;
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
rawInput?: string | object;
|
||||
content?: Array<{
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
locations?: Array<{
|
||||
path: string;
|
||||
line?: number | null;
|
||||
}>;
|
||||
timestamp?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete export session data - the single source of truth.
|
||||
*/
|
||||
export interface ExportSessionData {
|
||||
sessionId: string;
|
||||
startTime: string;
|
||||
messages: ExportMessage[];
|
||||
}
|
||||
13
packages/cli/src/ui/utils/export/utils.ts
Normal file
13
packages/cli/src/ui/utils/export/utils.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generates a filename with timestamp for export files.
|
||||
*/
|
||||
export function generateExportFilename(extension: string): string {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
return `qwen-code-export-${timestamp}.${extension}`;
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as path from 'node:path';
|
||||
import type { Part, FunctionCall } from '@google/genai';
|
||||
import type {
|
||||
ResumedSessionData,
|
||||
|
|
@ -12,8 +13,13 @@ import type {
|
|||
AnyDeclarativeTool,
|
||||
ToolResultDisplay,
|
||||
SlashCommandRecordPayload,
|
||||
AtCommandRecordPayload,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { HistoryItem, HistoryItemWithoutId } from '../types.js';
|
||||
import type {
|
||||
HistoryItem,
|
||||
HistoryItemWithoutId,
|
||||
IndividualToolCallDisplay,
|
||||
} from '../types.js';
|
||||
import { ToolCallStatus } from '../types.js';
|
||||
|
||||
/**
|
||||
|
|
@ -137,6 +143,8 @@ function convertToHistoryItems(
|
|||
config: Config,
|
||||
): HistoryItemWithoutId[] {
|
||||
const items: HistoryItemWithoutId[] = [];
|
||||
const pendingAtCommands: AtCommandRecordPayload[] = [];
|
||||
let atCommandCounter = 0;
|
||||
|
||||
// Track pending tool calls for grouping with results
|
||||
const pendingToolCalls = new Map<
|
||||
|
|
@ -152,6 +160,59 @@ function convertToHistoryItems(
|
|||
confirmationDetails: undefined;
|
||||
}> = [];
|
||||
|
||||
const buildAtCommandDisplays = (
|
||||
payload: AtCommandRecordPayload,
|
||||
): IndividualToolCallDisplay[] => {
|
||||
// Error case: single "Read File(s)" with error message
|
||||
if (payload.status === 'error') {
|
||||
atCommandCounter += 1;
|
||||
const filesLabel = payload.filesRead?.length
|
||||
? payload.filesRead.join(', ')
|
||||
: 'files';
|
||||
return [
|
||||
{
|
||||
callId: `at-command-${atCommandCounter}`,
|
||||
name: 'Read File(s)',
|
||||
description: 'Error attempting to read files',
|
||||
status: ToolCallStatus.Error,
|
||||
resultDisplay:
|
||||
payload.message || `Error reading files (${filesLabel})`,
|
||||
confirmationDetails: undefined,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Success case: individual tool calls for each file
|
||||
if (!payload.filesRead?.length) {
|
||||
atCommandCounter += 1;
|
||||
return [
|
||||
{
|
||||
callId: `at-command-${atCommandCounter}`,
|
||||
name: 'Read File',
|
||||
description: 'Read File(s)',
|
||||
status: ToolCallStatus.Success,
|
||||
resultDisplay: undefined,
|
||||
confirmationDetails: undefined,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return payload.filesRead.map((filePath) => {
|
||||
atCommandCounter += 1;
|
||||
const isDir = filePath.endsWith('/');
|
||||
return {
|
||||
callId: `at-command-${atCommandCounter}`,
|
||||
name: isDir ? 'Read Directory' : 'Read File',
|
||||
description: isDir
|
||||
? `Read directory ${path.basename(filePath)}`
|
||||
: `Read file ${path.basename(filePath)}`,
|
||||
status: ToolCallStatus.Success,
|
||||
resultDisplay: undefined,
|
||||
confirmationDetails: undefined,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
for (const record of conversation.messages) {
|
||||
if (record.type === 'system') {
|
||||
if (record.subtype === 'slash_command') {
|
||||
|
|
@ -180,10 +241,44 @@ function convertToHistoryItems(
|
|||
}
|
||||
}
|
||||
}
|
||||
if (record.subtype === 'at_command') {
|
||||
const payload = record.systemPayload as
|
||||
| AtCommandRecordPayload
|
||||
| undefined;
|
||||
if (!payload) continue;
|
||||
pendingAtCommands.push(payload);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
switch (record.type) {
|
||||
case 'user': {
|
||||
if (pendingAtCommands.length > 0) {
|
||||
// Flush any pending tool group before user message
|
||||
if (currentToolGroup.length > 0) {
|
||||
items.push({
|
||||
type: 'tool_group',
|
||||
tools: [...currentToolGroup],
|
||||
});
|
||||
currentToolGroup = [];
|
||||
}
|
||||
|
||||
const payload = pendingAtCommands.shift()!;
|
||||
const text =
|
||||
payload.userText ||
|
||||
extractTextFromParts(record.message?.parts as Part[]);
|
||||
if (text) {
|
||||
items.push({ type: 'user', text });
|
||||
}
|
||||
|
||||
const toolDisplays = buildAtCommandDisplays(payload);
|
||||
if (toolDisplays.length > 0) {
|
||||
items.push({
|
||||
type: 'tool_group',
|
||||
tools: toolDisplays,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
// Flush any pending tool group before user message
|
||||
if (currentToolGroup.length > 0) {
|
||||
items.push({
|
||||
|
|
@ -290,6 +385,31 @@ function convertToHistoryItems(
|
|||
}
|
||||
}
|
||||
|
||||
if (pendingAtCommands.length > 0) {
|
||||
for (const payload of pendingAtCommands) {
|
||||
// Flush any pending tool group before standalone @-command
|
||||
if (currentToolGroup.length > 0) {
|
||||
items.push({
|
||||
type: 'tool_group',
|
||||
tools: [...currentToolGroup],
|
||||
});
|
||||
currentToolGroup = [];
|
||||
}
|
||||
|
||||
const text = payload.userText;
|
||||
if (text) {
|
||||
items.push({ type: 'user', text });
|
||||
}
|
||||
const toolDisplays = buildAtCommandDisplays(payload);
|
||||
if (toolDisplays.length > 0) {
|
||||
items.push({
|
||||
type: 'tool_group',
|
||||
tools: toolDisplays,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush any remaining tool group
|
||||
if (currentToolGroup.length > 0) {
|
||||
items.push({
|
||||
|
|
|
|||
|
|
@ -31,6 +31,9 @@ import { promisify } from 'node:util';
|
|||
import { isKittyProtocolEnabled } from './kittyProtocolDetector.js';
|
||||
import { VSCODE_SHIFT_ENTER_SEQUENCE } from './platformConstants.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { createDebugLogger } from '@qwen-code/qwen-code-core';
|
||||
|
||||
const debugLogger = createDebugLogger('TERMINAL_SETUP');
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
|
|
@ -96,7 +99,7 @@ async function detectTerminal(): Promise<SupportedTerminal | null> {
|
|||
return 'trae';
|
||||
} catch (error) {
|
||||
// Continue detection even if process check fails
|
||||
console.debug('Parent process detection failed:', error);
|
||||
debugLogger.debug('Parent process detection failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -111,7 +114,7 @@ async function backupFile(filePath: string): Promise<void> {
|
|||
await fs.copyFile(filePath, backupPath);
|
||||
} catch (error) {
|
||||
// Log backup errors but continue with operation
|
||||
console.warn(`Failed to create backup of ${filePath}:`, error);
|
||||
debugLogger.warn(`Failed to create backup of ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ import type { UpdateInfo } from 'update-notifier';
|
|||
import updateNotifier from 'update-notifier';
|
||||
import semver from 'semver';
|
||||
import { getPackageJson } from '../../utils/package.js';
|
||||
import { createDebugLogger } from '@qwen-code/qwen-code-core';
|
||||
|
||||
const debugLogger = createDebugLogger('UPDATE_CHECK');
|
||||
|
||||
export const FETCH_TIMEOUT_MS = 2000;
|
||||
|
||||
|
|
@ -95,7 +98,7 @@ export async function checkForUpdates(): Promise<UpdateObject | null> {
|
|||
|
||||
return null;
|
||||
} catch (e) {
|
||||
console.warn('Failed to check for updates: ' + e);
|
||||
debugLogger.warn('Failed to check for updates: ' + e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue