diff --git a/packages/cli/package.json b/packages/cli/package.json index fa293df7b..378df7d2a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -16,6 +16,10 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./export": { + "types": "./dist/src/export/index.d.ts", + "import": "./dist/src/export/index.js" } }, "scripts": { diff --git a/packages/cli/src/export/index.ts b/packages/cli/src/export/index.ts new file mode 100644 index 000000000..04d1a7d4a --- /dev/null +++ b/packages/cli/src/export/index.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export { + collectSessionData, + generateExportFilename, + normalizeSessionData, + toHtml, + toJson, + toJsonl, + toMarkdown, + type ExportMessage, + type ExportSessionData, +} from '../ui/utils/export/index.js'; diff --git a/packages/cli/src/nonInteractiveCliCommands.test.ts b/packages/cli/src/nonInteractiveCliCommands.test.ts index 8e40b633d..5d7759db7 100644 --- a/packages/cli/src/nonInteractiveCliCommands.test.ts +++ b/packages/cli/src/nonInteractiveCliCommands.test.ts @@ -5,7 +5,10 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { handleSlashCommand } from './nonInteractiveCliCommands.js'; +import { + getAvailableCommands, + handleSlashCommand, +} from './nonInteractiveCliCommands.js'; import type { Config } from '@qwen-code/qwen-code-core'; import type { LoadedSettings } from './config/settings.js'; import { CommandKind, type ExecutionMode } from './ui/commands/types.js'; @@ -340,3 +343,43 @@ describe('handleSlashCommand', () => { }); }); }); + +describe('getAvailableCommands', () => { + let mockConfig: Config; + + beforeEach(() => { + mockCommandServiceCreate.mockResolvedValue({ + getCommands: mockGetCommands, + getCommandsForMode: mockGetCommandsForMode, + }); + + mockConfig = { + getExperimentalZedIntegration: vi.fn().mockReturnValue(false), + isInteractive: vi.fn().mockReturnValue(false), + getSessionId: vi.fn().mockReturnValue('test-session'), + getFolderTrustFeature: vi.fn().mockReturnValue(false), + getFolderTrust: vi.fn().mockReturnValue(false), + getProjectRoot: vi.fn().mockReturnValue('/test/project'), + getDisabledSlashCommands: vi.fn().mockReturnValue([]), + storage: {}, + } as unknown as Config; + }); + + it('includes /export in the default non-interactive command list', async () => { + mockGetCommandsForMode.mockReturnValue([ + { + name: 'export', + description: 'Export current session', + kind: CommandKind.BUILT_IN, + action: vi.fn(), + }, + ]); + + const commands = await getAvailableCommands( + mockConfig, + new AbortController().signal, + ); + + expect(commands.map((command) => command.name)).toContain('export'); + }); +}); diff --git a/packages/cli/src/utils/earlyInputCapture.test.ts b/packages/cli/src/utils/earlyInputCapture.test.ts index 21431edc1..89f373f7f 100644 --- a/packages/cli/src/utils/earlyInputCapture.test.ts +++ b/packages/cli/src/utils/earlyInputCapture.test.ts @@ -264,13 +264,13 @@ describe('earlyInputCapture', () => { expect(input.toString()).toBe(''); }); - it('should drop standalone ESC on capture end', () => { + it('should replay standalone ESC on capture end', () => { startEarlyInputCapture(); mockStdin.write(Buffer.from('\x1b')); stopEarlyInputCapture(); const input = getAndClearCapturedInput(); - expect(input.toString()).toBe(''); + expect(input.toString()).toBe('\x1b'); }); }); diff --git a/packages/cli/src/utils/earlyInputCapture.ts b/packages/cli/src/utils/earlyInputCapture.ts index b9abb3ba6..51b94119a 100644 --- a/packages/cli/src/utils/earlyInputCapture.ts +++ b/packages/cli/src/utils/earlyInputCapture.ts @@ -223,6 +223,9 @@ function shouldReplayPendingAtStop(pending: Buffer): boolean { if (pending.length === 0) { return false; } + if (pending.length === 1 && pending[0] === 0x1b) { + return true; + } return classifyEscapeSequence(pending, 0) === 'user'; } diff --git a/packages/vscode-ide-companion/esbuild.js b/packages/vscode-ide-companion/esbuild.js index 6abe3e6a5..5e5944b96 100644 --- a/packages/vscode-ide-companion/esbuild.js +++ b/packages/vscode-ide-companion/esbuild.js @@ -81,6 +81,15 @@ const reactDedupPlugin = { }, }; +const publicCliExportPlugin = { + name: 'public-cli-export', + setup(build) { + build.onResolve({ filter: /^@qwen-code\/qwen-code\/export$/ }, () => ({ + path: resolve(repoRoot, 'packages/cli/src/export/index.ts'), + })); + }, +}; + /** * Resolve `*.wasm?binary` imports to embedded Uint8Array content. * This keeps the companion bundle compatible with core's inline-WASM loader. @@ -187,6 +196,7 @@ async function main() { 'import.meta.url': 'import_meta.url', }, plugins: [ + publicCliExportPlugin, wasmBinaryPlugin, wasmLoader({ mode: 'embedded' }), /* add to the end of plugins array */ diff --git a/packages/vscode-ide-companion/src/services/sessionExportService.test.ts b/packages/vscode-ide-companion/src/services/sessionExportService.test.ts new file mode 100644 index 000000000..e67f54071 --- /dev/null +++ b/packages/vscode-ide-companion/src/services/sessionExportService.test.ts @@ -0,0 +1,191 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as path from 'node:path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockLoadSession, + mockCollectSessionData, + mockNormalizeSessionData, + mockToHtml, + mockToMarkdown, + mockToJson, + mockToJsonl, + mockGenerateExportFilename, + mockWriteFile, + mockShowSaveDialog, +} = vi.hoisted(() => ({ + mockLoadSession: vi.fn(), + mockCollectSessionData: vi.fn(), + mockNormalizeSessionData: vi.fn(), + mockToHtml: vi.fn(), + mockToMarkdown: vi.fn(), + mockToJson: vi.fn(), + mockToJsonl: vi.fn(), + mockGenerateExportFilename: vi.fn(), + mockWriteFile: vi.fn(), + mockShowSaveDialog: vi.fn(), +})); + +vi.mock('@qwen-code/qwen-code-core', () => { + class SessionService { + constructor(_cwd: string) {} + + async loadSession(_sessionId: string) { + return mockLoadSession(); + } + } + + return { + SessionService, + }; +}); + +vi.mock('@qwen-code/qwen-code/export', () => ({ + collectSessionData: mockCollectSessionData, + normalizeSessionData: mockNormalizeSessionData, + toHtml: mockToHtml, + toMarkdown: mockToMarkdown, + toJson: mockToJson, + toJsonl: mockToJsonl, + generateExportFilename: mockGenerateExportFilename, +})); + +vi.mock('node:fs/promises', () => ({ + writeFile: mockWriteFile, +})); + +vi.mock('vscode', () => ({ + Uri: { + file: (fsPath: string) => ({ fsPath }), + }, + window: { + showSaveDialog: mockShowSaveDialog, + }, +})); + +import { + exportSessionToFile, + parseExportSlashCommand, +} from './sessionExportService.js'; + +describe('sessionExportService', () => { + beforeEach(() => { + vi.clearAllMocks(); + + mockLoadSession.mockResolvedValue({ + conversation: { + sessionId: 'session-1', + startTime: '2025-01-01T00:00:00Z', + messages: [], + }, + }); + mockCollectSessionData.mockResolvedValue({ + sessionId: 'session-1', + startTime: '2025-01-01T00:00:00Z', + messages: [], + }); + mockNormalizeSessionData.mockImplementation((data) => data); + mockToHtml.mockReturnValue('export'); + mockToMarkdown.mockReturnValue('# export'); + mockToJson.mockReturnValue('{"ok":true}'); + mockToJsonl.mockReturnValue('{"ok":true}'); + mockGenerateExportFilename.mockImplementation( + (format: string) => `qwen-export.${format}`, + ); + }); + + describe('parseExportSlashCommand', () => { + it('returns null for non-export input', () => { + expect(parseExportSlashCommand('hello')).toBeNull(); + expect(parseExportSlashCommand('/model')).toBeNull(); + }); + + it('requires an explicit subcommand for bare /export', () => { + expect(() => parseExportSlashCommand('/export')).toThrow( + "Command '/export' requires a subcommand.", + ); + expect(() => parseExportSlashCommand('/export ')).toThrow( + "Command '/export' requires a subcommand.", + ); + }); + + it('returns the requested export format', () => { + expect(parseExportSlashCommand('/export html')).toBe('html'); + expect(parseExportSlashCommand('/export md')).toBe('md'); + expect(parseExportSlashCommand('/export JSON')).toBe('json'); + }); + + it('rejects unsupported export arguments', () => { + expect(() => parseExportSlashCommand('/export csv')).toThrow( + 'Unsupported /export format. Use /export html, /export md, /export json, or /export jsonl.', + ); + expect(() => parseExportSlashCommand('/export md extra')).toThrow( + 'Unsupported /export format. Use /export html, /export md, /export json, or /export jsonl.', + ); + }); + }); + + describe('exportSessionToFile', () => { + it('writes the exported session to the user-chosen path', async () => { + const chosenPath = path.join('/workspace', 'qwen-export.html'); + mockShowSaveDialog.mockResolvedValue({ fsPath: chosenPath }); + + const result = await exportSessionToFile({ + sessionId: 'session-1', + cwd: '/workspace', + format: 'html', + }); + + expect(mockCollectSessionData).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'session-1' }), + expect.anything(), + ); + expect(mockNormalizeSessionData).toHaveBeenCalled(); + expect(mockToHtml).toHaveBeenCalled(); + expect(mockShowSaveDialog).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Export Session as HTML', + }), + ); + expect(mockWriteFile).toHaveBeenCalledWith( + chosenPath, + 'export', + 'utf-8', + ); + expect(result).toEqual({ + filename: 'qwen-export.html', + uri: { fsPath: chosenPath }, + }); + }); + + it('returns null when the user cancels the save dialog', async () => { + mockShowSaveDialog.mockResolvedValue(undefined); + + const result = await exportSessionToFile({ + sessionId: 'session-1', + cwd: '/workspace', + format: 'html', + }); + + expect(result).toBeNull(); + expect(mockWriteFile).not.toHaveBeenCalled(); + }); + + it('throws when the target session cannot be loaded', async () => { + mockLoadSession.mockResolvedValue(undefined); + + await expect( + exportSessionToFile({ + sessionId: 'missing-session', + cwd: '/workspace', + format: 'json', + }), + ).rejects.toThrow('No active session found to export.'); + }); + }); +}); diff --git a/packages/vscode-ide-companion/src/services/sessionExportService.ts b/packages/vscode-ide-companion/src/services/sessionExportService.ts new file mode 100644 index 000000000..3f4ab50ea --- /dev/null +++ b/packages/vscode-ide-companion/src/services/sessionExportService.ts @@ -0,0 +1,147 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as vscode from 'vscode'; +import { SessionService, type Config } from '@qwen-code/qwen-code-core'; +import { + collectSessionData, + generateExportFilename, + normalizeSessionData, + toHtml, + toJson, + toJsonl, + toMarkdown, +} from '@qwen-code/qwen-code/export'; +import { + EXPORT_SESSION_FORMATS, + getExportSubcommandRequiredMessage, + isSessionExportFormat, + type SessionExportFormat, +} from '../utils/exportSlashCommand.js'; + +export { EXPORT_SESSION_FORMATS as SESSION_EXPORT_FORMATS }; +export type { SessionExportFormat } from '../utils/exportSlashCommand.js'; + +export interface SessionExportResult { + filename: string; + uri: vscode.Uri; +} + +const EXPORT_CONFIG = { + getChannel: () => 'vscode-companion', + getToolRegistry: () => undefined, +} as unknown as Config; + +export function parseExportSlashCommand( + text: string, +): SessionExportFormat | null { + const trimmed = text.replace(/\u200B/g, '').trim(); + if (!trimmed.startsWith('/')) { + return null; + } + + const parts = trimmed.split(/\s+/).filter(Boolean); + const [command, format, ...rest] = parts; + if (command !== '/export') { + return null; + } + + if (!format) { + throw new Error(getExportSubcommandRequiredMessage()); + } + + const normalizedFormat = format.toLowerCase(); + if (rest.length === 0 && isSessionExportFormat(normalizedFormat)) { + return normalizedFormat; + } + + throw new Error( + 'Unsupported /export format. Use /export html, /export md, /export json, or /export jsonl.', + ); +} + +function renderExportContent( + format: SessionExportFormat, + normalizedData: Awaited>, +): string { + switch (format) { + case 'html': + return toHtml(normalizedData); + case 'md': + return toMarkdown(normalizedData); + case 'json': + return toJson(normalizedData); + case 'jsonl': + return toJsonl(normalizedData); + default: { + const unreachableFormat: never = format; + throw new Error(`Unsupported export format: ${unreachableFormat}`); + } + } +} + +/** + * Export session to file via a native Save dialog. + * Returns null if the user cancels the dialog. + * + * @param options.sessionId - The session to export + * @param options.cwd - Working directory used as default save location + * @param options.format - Target format (html, md, json, jsonl) + * @returns Export result with filename and URI, or null if cancelled + */ +export async function exportSessionToFile(options: { + sessionId: string; + cwd: string; + format: SessionExportFormat; +}): Promise { + const { cwd, format, sessionId } = options; + const sessionService = new SessionService(cwd); + const sessionData = await sessionService.loadSession(sessionId); + + if (!sessionData) { + throw new Error('No active session found to export.'); + } + + const exportData = await collectSessionData( + sessionData.conversation, + EXPORT_CONFIG, + ); + const normalizedData = normalizeSessionData( + exportData, + sessionData.conversation.messages, + EXPORT_CONFIG, + ); + const content = renderExportContent(format, normalizedData); + const defaultFilename = generateExportFilename(format); + + // Show native Save dialog so users can choose destination and filename + const filterMap: Record> = { + html: { HTML: ['html'] }, + md: { Markdown: ['md'] }, + json: { JSON: ['json'] }, + jsonl: { 'JSON Lines': ['jsonl'] }, + }; + + const saveUri = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file(path.join(cwd, defaultFilename)), + filters: filterMap[format], + title: `Export Session as ${format.toUpperCase()}`, + }); + + if (!saveUri) { + // User cancelled the save dialog + return null; + } + + await fs.writeFile(saveUri.fsPath, content, 'utf-8'); + + return { + filename: path.basename(saveUri.fsPath), + uri: saveUri, + }; +} diff --git a/packages/vscode-ide-companion/src/utils/exportSlashCommand.ts b/packages/vscode-ide-companion/src/utils/exportSlashCommand.ts new file mode 100644 index 000000000..6575eb8a6 --- /dev/null +++ b/packages/vscode-ide-companion/src/utils/exportSlashCommand.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export const EXPORT_PARENT_COMMAND_NAME = 'export' as const; + +export const EXPORT_PARENT_COMMAND_DESCRIPTION = + 'Export current session to a file. Available formats: html, md, json, jsonl.'; + +export const EXPORT_SESSION_FORMATS = ['html', 'md', 'json', 'jsonl'] as const; + +export type SessionExportFormat = (typeof EXPORT_SESSION_FORMATS)[number]; + +export const EXPORT_SUBCOMMAND_SPECS: ReadonlyArray<{ + name: SessionExportFormat; + description: string; +}> = [ + { name: 'html', description: 'Export session to HTML format' }, + { name: 'md', description: 'Export session to markdown format' }, + { name: 'json', description: 'Export session to JSON format' }, + { + name: 'jsonl', + description: 'Export session to JSONL format (one message per line)', + }, +]; + +export function isSessionExportFormat( + value: string, +): value is SessionExportFormat { + return EXPORT_SESSION_FORMATS.includes(value as SessionExportFormat); +} + +export function getExportSubcommandRequiredMessage(): string { + const availableLines = EXPORT_SUBCOMMAND_SPECS.map( + (subcommand) => ` - ${subcommand.name}: ${subcommand.description}`, + ); + return `Command '/${EXPORT_PARENT_COMMAND_NAME}' requires a subcommand. Available:\n${availableLines.join('\n')}`; +} diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index 8e6963c07..1d90cc8fe 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -59,6 +59,10 @@ import type { ModelInfo, AvailableCommand } from '@agentclientprotocol/sdk'; import type { Question } from '../types/acpTypes.js'; import { useImagePaste, type WebViewImageMessage } from './hooks/useImage.js'; import { computeContextUsage } from './utils/contextUsage.js'; +import { + buildSlashCommandItems, + isExpandableSlashCommand, +} from './utils/slashCommandUtils.js'; /** * Memoized message list that only re-renders when messages or callbacks change, @@ -296,16 +300,9 @@ export const App: React.FC = () => { }, ]; - // Slash Commands group - commands from server (available_commands_update) - const slashCommandItems: CompletionItem[] = availableCommands.map( - (cmd) => ({ - id: cmd.name, - label: `/${cmd.name}`, - description: cmd.description, - type: 'command' as const, - group: 'Slash Commands', - value: cmd.name, - }), + const slashCommandItems = buildSlashCommandItems( + query, + availableCommands, ); // Combine all commands @@ -316,12 +313,11 @@ export const App: React.FC = () => { ]; // Filter by query - const lowerQuery = query.toLowerCase(); return allCommands.filter( (cmd) => - cmd.label.toLowerCase().includes(lowerQuery) || + cmd.label.toLowerCase().includes(query.toLowerCase()) || (cmd.description && - cmd.description.toLowerCase().includes(lowerQuery)), + cmd.description.toLowerCase().includes(query.toLowerCase())), ); } }, @@ -722,7 +718,11 @@ export const App: React.FC = () => { // Skip when fillOnly (Tab) — let the generic insertion path fill the // command text so the user can keep typing arguments. const serverCmd = availableCommands.find((c) => c.name === itemId); - if (serverCmd && !fillOnly) { + if ( + serverCmd && + !fillOnly && + !isExpandableSlashCommand(serverCmd.name) + ) { // Clear the trigger text since we're sending the command clearTriggerText(); // Send the slash command as a user message @@ -814,6 +814,17 @@ export const App: React.FC = () => { newRange.collapse(false); sel?.removeAllRanges(); sel?.addRange(newRange); + + if ( + completion.triggerChar === '/' && + isExpandableSlashCommand(insertValue.trim()) + ) { + completion.closeCompletion(); + requestAnimationFrame(() => { + inputElement.dispatchEvent(new Event('input', { bubbles: true })); + }); + return; + } } // Close the completion menu diff --git a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts index 6dc7bea52..1c954f472 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts @@ -6,12 +6,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const { mockProcessImageAttachments, mockShowErrorMessage } = vi.hoisted( - () => ({ - mockProcessImageAttachments: vi.fn(), - mockShowErrorMessage: vi.fn(), - }), -); +const { + mockProcessImageAttachments, + mockShowErrorMessage, + mockExportSessionToFile, +} = vi.hoisted(() => ({ + mockProcessImageAttachments: vi.fn(), + mockShowErrorMessage: vi.fn(), + mockExportSessionToFile: vi.fn(), +})); const { mockExecuteCommand } = vi.hoisted(() => ({ mockExecuteCommand: vi.fn(), })); @@ -27,6 +30,13 @@ vi.mock('vscode', () => ({ workspace: { workspaceFolders: [{ uri: { fsPath: '/workspace' } }], }, + Uri: { + file: (fsPath: string) => ({ + fsPath, + toString: () => + `file://${encodeURI(fsPath.replace(/\\/g, '/')).replace(/#/g, '%23')}`, + }), + }, })); vi.mock('../utils/imageHandler.js', async (importOriginal) => { @@ -38,6 +48,23 @@ vi.mock('../utils/imageHandler.js', async (importOriginal) => { }; }); +vi.mock('../../services/sessionExportService.js', () => ({ + parseExportSlashCommand: (text: string) => { + const trimmed = text.trim(); + if (trimmed === '/export html') { + return 'html'; + } + if (trimmed === '/export md') { + return 'md'; + } + if (trimmed === '/export') { + throw new Error("Command '/export' requires a subcommand."); + } + return null; + }, + exportSessionToFile: mockExportSessionToFile, +})); + import { SessionMessageHandler } from './SessionMessageHandler.js'; describe('SessionMessageHandler', () => { @@ -49,6 +76,10 @@ describe('SessionMessageHandler', () => { savedImageCount: 0, promptImages: [], }); + mockExportSessionToFile.mockResolvedValue({ + filename: 'export.html', + uri: { fsPath: '/workspace/export.html' }, + }); }); it('forwards the active model when opening a new chat tab', async () => { @@ -265,4 +296,209 @@ describe('SessionMessageHandler', () => { data: {}, }); }); + + it('intercepts /export html and uses the VSCode export flow instead of sending a prompt', async () => { + const agentManager = { + isConnected: true, + currentSessionId: 'session-1', + getSessionList: vi + .fn() + .mockResolvedValue([{ sessionId: 'session-1', cwd: '/workspace' }]), + sendMessage: vi.fn(), + }; + const conversationStore = { + createConversation: vi.fn(), + getConversation: vi.fn(), + addMessage: vi.fn(), + }; + const sendToWebView = vi.fn(); + + const handler = new SessionMessageHandler( + agentManager as never, + conversationStore as never, + 'session-1', + sendToWebView, + ); + + await handler.handle({ + type: 'sendMessage', + data: { + text: '/export html', + }, + }); + + expect(mockExportSessionToFile).toHaveBeenCalledWith({ + sessionId: 'session-1', + cwd: '/workspace', + format: 'html', + }); + expect(conversationStore.addMessage).not.toHaveBeenCalled(); + expect(agentManager.sendMessage).not.toHaveBeenCalled(); + expect(sendToWebView).toHaveBeenCalledWith({ + type: 'message', + data: expect.objectContaining({ + role: 'assistant', + content: + 'Session exported to HTML: [export.html](file:///workspace/export.html)', + }), + }); + }); + + it('prefers the active ACP session id over the local conversation id when exporting', async () => { + const agentManager = { + isConnected: true, + currentSessionId: 'session-1', + getSessionList: vi + .fn() + .mockResolvedValue([{ sessionId: 'session-1', cwd: '/workspace' }]), + sendMessage: vi.fn(), + }; + const conversationStore = { + createConversation: vi.fn(), + getConversation: vi.fn(), + addMessage: vi.fn(), + }; + const sendToWebView = vi.fn(); + + const handler = new SessionMessageHandler( + agentManager as never, + conversationStore as never, + 'conv_local_123', + sendToWebView, + ); + + await handler.handle({ + type: 'sendMessage', + data: { + text: '/export html', + }, + }); + + expect(mockExportSessionToFile).toHaveBeenCalledWith({ + sessionId: 'session-1', + cwd: '/workspace', + format: 'html', + }); + expect(agentManager.sendMessage).not.toHaveBeenCalled(); + }); + + it('reports bare /export as a missing subcommand instead of exporting', async () => { + const agentManager = { + isConnected: true, + currentSessionId: 'session-1', + getSessionList: vi.fn(), + sendMessage: vi.fn(), + }; + const conversationStore = { + createConversation: vi.fn(), + getConversation: vi.fn(), + addMessage: vi.fn(), + }; + const sendToWebView = vi.fn(); + + const handler = new SessionMessageHandler( + agentManager as never, + conversationStore as never, + 'session-1', + sendToWebView, + ); + + await handler.handle({ + type: 'sendMessage', + data: { + text: '/export', + }, + }); + + expect(mockExportSessionToFile).not.toHaveBeenCalled(); + expect(sendToWebView).toHaveBeenCalledWith({ + type: 'error', + data: { message: "Command '/export' requires a subcommand." }, + }); + expect(agentManager.sendMessage).not.toHaveBeenCalled(); + }); + + it('reports export failures back to the user', async () => { + mockExportSessionToFile.mockRejectedValue(new Error('disk full')); + + const agentManager = { + isConnected: true, + currentSessionId: 'session-1', + getSessionList: vi + .fn() + .mockResolvedValue([{ sessionId: 'session-1', cwd: '/workspace' }]), + sendMessage: vi.fn(), + }; + const conversationStore = { + createConversation: vi.fn(), + getConversation: vi.fn(), + addMessage: vi.fn(), + }; + const sendToWebView = vi.fn(); + + const handler = new SessionMessageHandler( + agentManager as never, + conversationStore as never, + 'session-1', + sendToWebView, + ); + + await handler.handle({ + type: 'sendMessage', + data: { + text: '/export md', + }, + }); + + expect(sendToWebView).toHaveBeenCalledWith({ + type: 'error', + data: { message: 'Failed to export session: disk full' }, + }); + expect(agentManager.sendMessage).not.toHaveBeenCalled(); + }); + + it('encodes exported file links before rendering markdown', async () => { + mockExportSessionToFile.mockResolvedValue({ + filename: 'export (#1).html', + uri: { fsPath: '/workspace/export (#1).html' }, + }); + + const agentManager = { + isConnected: true, + currentSessionId: 'session-1', + getSessionList: vi + .fn() + .mockResolvedValue([{ sessionId: 'session-1', cwd: '/workspace' }]), + sendMessage: vi.fn(), + }; + const conversationStore = { + createConversation: vi.fn(), + getConversation: vi.fn(), + addMessage: vi.fn(), + }; + const sendToWebView = vi.fn(); + + const handler = new SessionMessageHandler( + agentManager as never, + conversationStore as never, + 'session-1', + sendToWebView, + ); + + await handler.handle({ + type: 'sendMessage', + data: { + text: '/export html', + }, + }); + + expect(sendToWebView).toHaveBeenCalledWith({ + type: 'message', + data: expect.objectContaining({ + role: 'assistant', + content: + 'Session exported to HTML: [export (#1).html](file:///workspace/export%20(%231).html)', + }), + }); + }); }); diff --git a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts index 7f5e72ae2..ef73630ae 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts @@ -15,6 +15,20 @@ import { } from '../utils/imageHandler.js'; import { isAuthenticationRequiredError } from '../../utils/authErrors.js'; import { getErrorMessage } from '../../utils/errorMessage.js'; +import { + exportSessionToFile, + parseExportSlashCommand, + type SessionExportFormat, +} from '../../services/sessionExportService.js'; + +function formatExportSuccessMessage( + formatLabel: string, + filename: string, + filePath: string, +): string { + const markdownLinkPath = vscode.Uri.file(filePath).toString(); + return `Session exported to ${formatLabel}: [${filename}](${markdownLinkPath})`; +} /** * Session message handler @@ -287,6 +301,73 @@ export class SessionMessageHandler extends BaseMessageHandler { return isAuthenticationRequiredError(error); } + private async resolveSessionWorkingDir(sessionId: string): Promise { + try { + const sessions = await this.agentManager.getSessionList(); + const match = sessions.find( + (session) => + session.sessionId === sessionId || session.id === sessionId, + ); + if (typeof match?.cwd === 'string' && match.cwd.length > 0) { + return match.cwd; + } + } catch (error) { + console.warn( + '[SessionMessageHandler] Failed to resolve export session cwd:', + error, + ); + } + + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + return workspaceFolder?.uri.fsPath || process.cwd(); + } + + private async handleExportCommand( + format: SessionExportFormat, + ): Promise { + // Prefer the active ACP session id. The local conversation id may still be + // a webview-only `conv_*` placeholder after starting a fresh session. + const sessionId = + this.agentManager.currentSessionId ?? this.currentConversationId; + if (!sessionId) { + const errorMsg = 'No active session found to export.'; + this.sendToWebView({ + type: 'error', + data: { message: errorMsg }, + }); + return; + } + + try { + const cwd = await this.resolveSessionWorkingDir(sessionId); + const result = await exportSessionToFile({ sessionId, cwd, format }); + if (!result) { + // User cancelled the save dialog + return; + } + const formatLabel = format.toUpperCase(); + this.sendToWebView({ + type: 'message', + data: { + role: 'assistant', + content: formatExportSuccessMessage( + formatLabel, + result.filename, + result.uri.fsPath, + ), + timestamp: Date.now(), + }, + }); + } catch (error) { + const errorMsg = this.getErrorMessage(error); + console.error('[SessionMessageHandler] Failed to export session:', error); + this.sendToWebView({ + type: 'error', + data: { message: `Failed to export session: ${errorMsg}` }, + }); + } + } + /** * Handle send message request */ @@ -318,6 +399,21 @@ export class SessionMessageHandler extends BaseMessageHandler { return; } + try { + const exportFormat = parseExportSlashCommand(trimmedText); + if (exportFormat) { + await this.handleExportCommand(exportFormat); + return; + } + } catch (error) { + const errorMsg = this.getErrorMessage(error); + this.sendToWebView({ + type: 'error', + data: { message: errorMsg }, + }); + return; + } + let displayText = trimmedText ? text : ''; let promptText = text; if (context && context.length > 0) { diff --git a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts index bfa85625b..27ca238d1 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts @@ -7,6 +7,7 @@ import type { RefObject } from 'react'; import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import type { CompletionItem } from '../../types/completionItemTypes.js'; +import { shouldAllowCompletionQuery } from '../utils/slashCommandUtils.js'; interface CompletionTriggerState { isOpen: boolean; @@ -325,8 +326,7 @@ export function useCompletionTrigger( if (isValidTrigger) { const query = text.substring(triggerPos + 1, effectiveCursorPosition); - // Only show if query doesn't contain spaces (still typing the reference) - if (!query.includes(' ') && !query.includes('\n')) { + if (shouldAllowCompletionQuery(triggerChar, query)) { // Get precise cursor position for menu const cursorPos = getCursorPosition(); if (cursorPos) { diff --git a/packages/vscode-ide-companion/src/webview/utils/slashCommandUtils.test.ts b/packages/vscode-ide-companion/src/webview/utils/slashCommandUtils.test.ts new file mode 100644 index 000000000..75edecc3f --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/utils/slashCommandUtils.test.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import type { AvailableCommand } from '@agentclientprotocol/sdk'; +import { + buildSlashCommandItems, + isExpandableSlashCommand, + shouldAllowCompletionQuery, +} from './slashCommandUtils.js'; + +const availableCommands: AvailableCommand[] = [ + { + name: 'export', + description: + 'Export current session to a file. Available formats: html, md, json, jsonl.', + input: null, + }, + { + name: 'help', + description: 'Show help', + input: null, + }, +]; + +describe('slashCommandUtils', () => { + describe('shouldAllowCompletionQuery', () => { + it('keeps slash completion open when the query contains spaces', () => { + expect(shouldAllowCompletionQuery('/', 'export ')).toBe(true); + expect(shouldAllowCompletionQuery('/', 'export md')).toBe(true); + }); + + it('still blocks @ completion when the query contains spaces', () => { + expect(shouldAllowCompletionQuery('@', 'foo bar')).toBe(false); + }); + }); + + describe('buildSlashCommandItems', () => { + it('returns top-level slash commands for prefix queries', () => { + const items = buildSlashCommandItems('exp', availableCommands); + + expect(items.map((item) => item.id)).toContain('export'); + }); + + it('returns export subcommands for an exact /export parent query', () => { + const items = buildSlashCommandItems('export ', availableCommands); + + expect(items.map((item) => item.value)).toEqual([ + 'export html', + 'export md', + 'export json', + 'export jsonl', + ]); + }); + + it('filters export subcommands by the typed child query', () => { + const items = buildSlashCommandItems('export j', availableCommands); + + expect(items.map((item) => item.value)).toEqual([ + 'export json', + 'export jsonl', + ]); + }); + }); + + describe('isExpandableSlashCommand', () => { + it('marks /export as an expandable command', () => { + expect(isExpandableSlashCommand('export')).toBe(true); + expect(isExpandableSlashCommand('help')).toBe(false); + }); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/utils/slashCommandUtils.ts b/packages/vscode-ide-companion/src/webview/utils/slashCommandUtils.ts new file mode 100644 index 000000000..83d6245db --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/utils/slashCommandUtils.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { AvailableCommand } from '@agentclientprotocol/sdk'; +import type { CompletionItem } from '../../types/completionItemTypes.js'; +import { + EXPORT_PARENT_COMMAND_NAME, + EXPORT_SUBCOMMAND_SPECS, +} from '../../utils/exportSlashCommand.js'; + +export function shouldAllowCompletionQuery( + trigger: '@' | '/', + query: string, +): boolean { + if (query.includes('\n')) { + return false; + } + + if (trigger === '/') { + return true; + } + + return !query.includes(' '); +} + +export function isExpandableSlashCommand(commandName: string): boolean { + return commandName === EXPORT_PARENT_COMMAND_NAME; +} + +function matchesQuery( + query: string, + label: string, + description?: string, +): boolean { + const normalizedQuery = query.toLowerCase(); + return ( + label.toLowerCase().includes(normalizedQuery) || + (description?.toLowerCase().includes(normalizedQuery) ?? false) + ); +} + +function buildExportSubcommandItems(childQuery: string): CompletionItem[] { + return EXPORT_SUBCOMMAND_SPECS.filter((subcommand) => + matchesQuery( + childQuery, + `/export ${subcommand.name}`, + subcommand.description, + ), + ).map((subcommand) => ({ + id: `export:${subcommand.name}`, + label: `/${EXPORT_PARENT_COMMAND_NAME} ${subcommand.name}`, + description: subcommand.description, + type: 'command' as const, + group: 'Slash Commands', + value: `${EXPORT_PARENT_COMMAND_NAME} ${subcommand.name}`, + })); +} + +export function buildSlashCommandItems( + query: string, + availableCommands: readonly AvailableCommand[], +): CompletionItem[] { + const normalizedQuery = query.trimStart().toLowerCase(); + + if ( + normalizedQuery === EXPORT_PARENT_COMMAND_NAME || + normalizedQuery.startsWith(`${EXPORT_PARENT_COMMAND_NAME} `) + ) { + const childQuery = + normalizedQuery === EXPORT_PARENT_COMMAND_NAME + ? '' + : normalizedQuery + .slice(EXPORT_PARENT_COMMAND_NAME.length + 1) + .trimStart(); + return buildExportSubcommandItems(childQuery); + } + + return availableCommands + .map((cmd) => ({ + id: cmd.name, + label: `/${cmd.name}`, + description: cmd.description, + type: 'command' as const, + group: 'Slash Commands', + value: cmd.name, + })) + .filter((item) => matchesQuery(query, item.label, item.description)); +} diff --git a/packages/vscode-ide-companion/tsconfig.json b/packages/vscode-ide-companion/tsconfig.json index c9c55c8cb..26b3fd815 100644 --- a/packages/vscode-ide-companion/tsconfig.json +++ b/packages/vscode-ide-companion/tsconfig.json @@ -10,7 +10,8 @@ "strict": true, "skipLibCheck": true, "paths": { - "@lydell/node-pty": ["../../node_modules/@lydell/node-pty/node-pty.d.ts"] + "@lydell/node-pty": ["../../node_modules/@lydell/node-pty/node-pty.d.ts"], + "@qwen-code/qwen-code/export": ["../cli/src/export/index.ts"] } /* Additional Checks */ // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ diff --git a/packages/vscode-ide-companion/vitest.config.ts b/packages/vscode-ide-companion/vitest.config.ts index 50c8ea3cb..0640064fd 100644 --- a/packages/vscode-ide-companion/vitest.config.ts +++ b/packages/vscode-ide-companion/vitest.config.ts @@ -1,6 +1,15 @@ +import path from 'node:path'; import { defineConfig } from 'vitest/config'; export default defineConfig({ + resolve: { + alias: { + '@qwen-code/qwen-code/export': path.resolve( + __dirname, + '../cli/src/export/index.ts', + ), + }, + }, test: { globals: true, environment: 'node', diff --git a/packages/webui/src/components/messages/MarkdownRenderer/MarkdownRenderer.test.tsx b/packages/webui/src/components/messages/MarkdownRenderer/MarkdownRenderer.test.tsx new file mode 100644 index 000000000..bee3bd9ad --- /dev/null +++ b/packages/webui/src/components/messages/MarkdownRenderer/MarkdownRenderer.test.tsx @@ -0,0 +1,75 @@ +// @vitest-environment jsdom +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { flushSync } from 'react-dom'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { MarkdownRenderer } from './MarkdownRenderer.js'; + +let root: Root | null = null; +let container: HTMLDivElement | null = null; + +function renderMarkdown( + content: string, + onFileClick = vi.fn(), + enableFileLinks = false, +) { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + flushSync(() => { + root?.render( + , + ); + }); + + return { onFileClick }; +} + +afterEach(() => { + flushSync(() => { + root?.unmount(); + }); + container?.remove(); + root = null; + container = null; +}); + +describe('MarkdownRenderer explicit file links', () => { + it('opens markdown file links with decoded absolute paths', () => { + const { onFileClick } = renderMarkdown( + 'Saved: [export.html](/tmp/my%20dir/export.html)', + ); + + const anchor = container?.querySelector('a'); + expect(anchor).toBeTruthy(); + + anchor?.dispatchEvent( + new MouseEvent('click', { bubbles: true, cancelable: true }), + ); + + expect(onFileClick).toHaveBeenCalledWith('/tmp/my dir/export.html'); + }); + + it('converts markdown file links with line fragments into vscode paths', () => { + const { onFileClick } = renderMarkdown('[app.ts](/tmp/src/app.ts#L12)'); + + const anchor = container?.querySelector('a'); + expect(anchor).toBeTruthy(); + + anchor?.dispatchEvent( + new MouseEvent('click', { bubbles: true, cancelable: true }), + ); + + expect(onFileClick).toHaveBeenCalledWith('/tmp/src/app.ts:12'); + }); +}); diff --git a/packages/webui/src/components/messages/MarkdownRenderer/MarkdownRenderer.tsx b/packages/webui/src/components/messages/MarkdownRenderer/MarkdownRenderer.tsx index 3d52a342b..344932626 100644 --- a/packages/webui/src/components/messages/MarkdownRenderer/MarkdownRenderer.tsx +++ b/packages/webui/src/components/messages/MarkdownRenderer/MarkdownRenderer.tsx @@ -29,10 +29,50 @@ const FILE_PATH_REGEX = const FILE_PATH_WITH_LINES_REGEX = /(?:[a-zA-Z]:)?[/\\](?:[\w\-. ]+[/\\])+[\w\-. ]+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)#(\d+)(?:-(\d+))?/gi; -// Known file extensions for validation +// Known file extensions for validation of explicit markdown links const KNOWN_FILE_EXTENSIONS = /\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|ya?ml|toml|xml|html|vue|svelte)$/i; +const safeDecodePath = (value: string): string => { + try { + return decodeURIComponent(value); + } catch { + return value; + } +}; + +const normalizeExplicitFileLink = (raw: string): string => { + const decoded = safeDecodePath(raw).replace(/\\/g, '/'); + + // file:// URIs (e.g. from vscode.Uri.file().toString()) encode special + // characters like # as %23 in the path component. After decoding the + // full URI we can strip the scheme and return the filesystem path + // directly — no fragment splitting needed, because any # in the + // decoded result is a literal filename character, not an anchor. + if (/^file:\/\//i.test(decoded)) { + let filePath = decoded.replace(/^file:\/\/\//i, ''); + // On Unix the path should start with / + if (!/^[a-zA-Z]:/.test(filePath) && !filePath.startsWith('/')) { + filePath = '/' + filePath; + } + return filePath; + } + + const hashIndex = decoded.indexOf('#'); + if (hashIndex < 0) { + return decoded; + } + + const base = decoded.slice(0, hashIndex); + const fragment = decoded.slice(hashIndex + 1); + const lineMatch = fragment.match(/^L?(\d+)(?:-\d+)?$/i); + if (lineMatch) { + return `${base}:${parseInt(lineMatch[1], 10)}`; + } + + return base; +}; + /** * Escape HTML characters for security */ @@ -98,7 +138,6 @@ export const MarkdownRenderer: FC = ({ 'gi', ); - // Convert a "path#fragment" into VS Code friendly "path:line" const normalizePathAndLine = ( raw: string, ): { displayText: string; dataPath: string } => { @@ -293,14 +332,12 @@ export const MarkdownRenderer: FC = ({ // Event delegation: intercept clicks on generated file-path links const handleContainerClick = useCallback( (e: React.MouseEvent) => { - if (!enableFileLinks) { - return; - } const target = e.target as HTMLElement | null; if (!target) { return; } + // Check for file-path-link (created by processFilePaths when enableFileLinks=true) const anchor = (target.closest && target.closest('a.file-path-link')) as HTMLAnchorElement | null; if (anchor) { @@ -314,6 +351,11 @@ export const MarkdownRenderer: FC = ({ return; } + // Handle explicit markdown links (file:// URIs and normal file-path hrefs). + // file:// URIs come from trusted system-generated content (e.g. /export). + // Normal file-path links (absolute or with known extensions) are also + // supported so that intentional markdown links remain clickable even + // when enableFileLinks is false. const anyAnchor = (target.closest && target.closest('a')) as HTMLAnchorElement | null; if (!anyAnchor) { @@ -321,28 +363,39 @@ export const MarkdownRenderer: FC = ({ } const href = anyAnchor.getAttribute('href') || ''; - if (!/^https?:\/\//i.test(href)) { + + // Handle file:// URI links (e.g. from /export success messages). + if (/^file:\/\//i.test(href) && onFileClick) { + const candidate = normalizeExplicitFileLink(href); + e.preventDefault(); + e.stopPropagation(); + onFileClick(candidate); return; } - try { - const url = new URL(href); - const host = url.hostname || ''; - const path = url.pathname || ''; - const noPath = path === '' || path === '/'; - // Only treat as file if host has a known file extension - if (noPath && KNOWN_FILE_EXTENSIONS.test(host)) { - const text = (anyAnchor.textContent || '').trim(); - const candidate = KNOWN_FILE_EXTENSIONS.test(text) ? text : host; - e.preventDefault(); - e.stopPropagation(); - onFileClick?.(candidate); - } - } catch { - // ignore + // Skip external links — let browser handle them normally + if (/^(https?|mailto|ftp|data):/i.test(href)) { + return; + } + + // Handle explicit markdown file-path links (e.g. [filename](/path/to/file)) + // even when enableFileLinks=false, so intentional links like Export Session + // output remain clickable. + const text = (anyAnchor.textContent || '').trim(); + const candidate = normalizeExplicitFileLink(href || text); + + const isAbsolutePath = /^(?:[a-zA-Z]:[/\\]|[/\\])/i.test(candidate); + const isRelativeFile = + !isAbsolutePath && + KNOWN_FILE_EXTENSIONS.test(candidate.replace(/:\d+(?::\d+)?$/, '')); + + if ((isAbsolutePath || isRelativeFile) && onFileClick) { + e.preventDefault(); + e.stopPropagation(); + onFileClick(candidate); } }, - [enableFileLinks, onFileClick], + [onFileClick], ); return (