Skip to content

Commit 5e1b8b0

Browse files
authored
feat(vscode-companion): support /export session command (#2592)
* feat(vscode-companion): support /export session command * fix(vscode-ide-companion/webview): prefer ACP session id for export * feat(vscode-ide-companion): support /export slash command Add nested /export completion and ACP command availability for the VS Code companion. Reuse the shared export flow, write to the default path, and show clickable export results in chat. * fix(export): align slash command messaging Restore the CLI export description to the existing wording. Keep the VS Code companion error message consistent with the required /export subcommands. * fix(webui): support explicit markdown file links Handle local markdown file links in assistant messages even when automatic file-link detection is disabled. Normalize encoded paths and line fragments so exported files can be opened from the VS Code webview. * test(vscode-ide-companion): make export path assertion cross-platform * fix(vscode-ide-companion): use public session export entrypoint * fix(cli): replay standalone ESC after early capture * fix(vscode-ide-companion): resolve rebase artifacts and vitest export alias Remove duplicate AvailableCommand import caused by merge, and add vitest resolve alias for @qwen-code/qwen-code/export so the session export service tests can resolve the CLI export module from source. * fix(cli): fix getAvailableCommands test mock to use getCommandsForMode The test mock was only setting up getCommands but getAvailableCommands calls getCommandsForMode. Add getCommandsForMode to the mock and set up test data on it instead. * fix(vscode-ide-companion): fix export file link click and add save dialog - Fix file:/// URI handling in MarkdownRenderer: normalizeExplicitFileLink now strips the file:// scheme before checking isAbsolutePath, so exported file links are properly recognized and clickable - Replace direct cwd file write with vscode.window.showSaveDialog() so users can choose the export destination and filename - Handle cancelled save dialog gracefully (return null, skip success message) * fix(webui): scope file link handler to file:// URIs only, fix # in filenames - normalizeExplicitFileLink now returns early for file:// URIs without splitting on #, since vscode.Uri.file() encodes # as %23 in the path. This prevents filenames containing # from being truncated after decode. - Explicit-link click handler now only fires for file:// URI hrefs, not arbitrary relative paths. This prevents model-generated markdown links from bypassing enableFileLinks=false and opening arbitrary files. - Remove unused KNOWN_FILE_EXTENSIONS constant. * fix(vscode-ide-companion): update export tests for save dialog, fix stale JSDoc - Add showSaveDialog mock to sessionExportService.test.ts - Update existing test to verify save dialog is called with correct args - Add test for cancelled save dialog returning null - Fix JSDoc that incorrectly claimed fallback-to-cwd behavior
1 parent 93cbad2 commit 5e1b8b0

19 files changed

Lines changed: 1151 additions & 49 deletions

packages/cli/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616
".": {
1717
"types": "./dist/index.d.ts",
1818
"import": "./dist/index.js"
19+
},
20+
"./export": {
21+
"types": "./dist/src/export/index.d.ts",
22+
"import": "./dist/src/export/index.js"
1923
}
2024
},
2125
"scripts": {

packages/cli/src/export/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Qwen Team
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
export {
8+
collectSessionData,
9+
generateExportFilename,
10+
normalizeSessionData,
11+
toHtml,
12+
toJson,
13+
toJsonl,
14+
toMarkdown,
15+
type ExportMessage,
16+
type ExportSessionData,
17+
} from '../ui/utils/export/index.js';

packages/cli/src/nonInteractiveCliCommands.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
*/
66

77
import { describe, it, expect, vi, beforeEach } from 'vitest';
8-
import { handleSlashCommand } from './nonInteractiveCliCommands.js';
8+
import {
9+
getAvailableCommands,
10+
handleSlashCommand,
11+
} from './nonInteractiveCliCommands.js';
912
import type { Config } from '@qwen-code/qwen-code-core';
1013
import type { LoadedSettings } from './config/settings.js';
1114
import { CommandKind, type ExecutionMode } from './ui/commands/types.js';
@@ -340,3 +343,43 @@ describe('handleSlashCommand', () => {
340343
});
341344
});
342345
});
346+
347+
describe('getAvailableCommands', () => {
348+
let mockConfig: Config;
349+
350+
beforeEach(() => {
351+
mockCommandServiceCreate.mockResolvedValue({
352+
getCommands: mockGetCommands,
353+
getCommandsForMode: mockGetCommandsForMode,
354+
});
355+
356+
mockConfig = {
357+
getExperimentalZedIntegration: vi.fn().mockReturnValue(false),
358+
isInteractive: vi.fn().mockReturnValue(false),
359+
getSessionId: vi.fn().mockReturnValue('test-session'),
360+
getFolderTrustFeature: vi.fn().mockReturnValue(false),
361+
getFolderTrust: vi.fn().mockReturnValue(false),
362+
getProjectRoot: vi.fn().mockReturnValue('/test/project'),
363+
getDisabledSlashCommands: vi.fn().mockReturnValue([]),
364+
storage: {},
365+
} as unknown as Config;
366+
});
367+
368+
it('includes /export in the default non-interactive command list', async () => {
369+
mockGetCommandsForMode.mockReturnValue([
370+
{
371+
name: 'export',
372+
description: 'Export current session',
373+
kind: CommandKind.BUILT_IN,
374+
action: vi.fn(),
375+
},
376+
]);
377+
378+
const commands = await getAvailableCommands(
379+
mockConfig,
380+
new AbortController().signal,
381+
);
382+
383+
expect(commands.map((command) => command.name)).toContain('export');
384+
});
385+
});

packages/cli/src/utils/earlyInputCapture.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -264,13 +264,13 @@ describe('earlyInputCapture', () => {
264264
expect(input.toString()).toBe('');
265265
});
266266

267-
it('should drop standalone ESC on capture end', () => {
267+
it('should replay standalone ESC on capture end', () => {
268268
startEarlyInputCapture();
269269
mockStdin.write(Buffer.from('\x1b'));
270270
stopEarlyInputCapture();
271271

272272
const input = getAndClearCapturedInput();
273-
expect(input.toString()).toBe('');
273+
expect(input.toString()).toBe('\x1b');
274274
});
275275
});
276276

packages/cli/src/utils/earlyInputCapture.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,9 @@ function shouldReplayPendingAtStop(pending: Buffer): boolean {
223223
if (pending.length === 0) {
224224
return false;
225225
}
226+
if (pending.length === 1 && pending[0] === 0x1b) {
227+
return true;
228+
}
226229
return classifyEscapeSequence(pending, 0) === 'user';
227230
}
228231

packages/vscode-ide-companion/esbuild.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,15 @@ const reactDedupPlugin = {
8181
},
8282
};
8383

84+
const publicCliExportPlugin = {
85+
name: 'public-cli-export',
86+
setup(build) {
87+
build.onResolve({ filter: /^@qwen-code\/qwen-code\/export$/ }, () => ({
88+
path: resolve(repoRoot, 'packages/cli/src/export/index.ts'),
89+
}));
90+
},
91+
};
92+
8493
/**
8594
* Resolve `*.wasm?binary` imports to embedded Uint8Array content.
8695
* This keeps the companion bundle compatible with core's inline-WASM loader.
@@ -187,6 +196,7 @@ async function main() {
187196
'import.meta.url': 'import_meta.url',
188197
},
189198
plugins: [
199+
publicCliExportPlugin,
190200
wasmBinaryPlugin,
191201
wasmLoader({ mode: 'embedded' }),
192202
/* add to the end of plugins array */
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Qwen Team
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import * as path from 'node:path';
8+
import { beforeEach, describe, expect, it, vi } from 'vitest';
9+
10+
const {
11+
mockLoadSession,
12+
mockCollectSessionData,
13+
mockNormalizeSessionData,
14+
mockToHtml,
15+
mockToMarkdown,
16+
mockToJson,
17+
mockToJsonl,
18+
mockGenerateExportFilename,
19+
mockWriteFile,
20+
mockShowSaveDialog,
21+
} = vi.hoisted(() => ({
22+
mockLoadSession: vi.fn(),
23+
mockCollectSessionData: vi.fn(),
24+
mockNormalizeSessionData: vi.fn(),
25+
mockToHtml: vi.fn(),
26+
mockToMarkdown: vi.fn(),
27+
mockToJson: vi.fn(),
28+
mockToJsonl: vi.fn(),
29+
mockGenerateExportFilename: vi.fn(),
30+
mockWriteFile: vi.fn(),
31+
mockShowSaveDialog: vi.fn(),
32+
}));
33+
34+
vi.mock('@qwen-code/qwen-code-core', () => {
35+
class SessionService {
36+
constructor(_cwd: string) {}
37+
38+
async loadSession(_sessionId: string) {
39+
return mockLoadSession();
40+
}
41+
}
42+
43+
return {
44+
SessionService,
45+
};
46+
});
47+
48+
vi.mock('@qwen-code/qwen-code/export', () => ({
49+
collectSessionData: mockCollectSessionData,
50+
normalizeSessionData: mockNormalizeSessionData,
51+
toHtml: mockToHtml,
52+
toMarkdown: mockToMarkdown,
53+
toJson: mockToJson,
54+
toJsonl: mockToJsonl,
55+
generateExportFilename: mockGenerateExportFilename,
56+
}));
57+
58+
vi.mock('node:fs/promises', () => ({
59+
writeFile: mockWriteFile,
60+
}));
61+
62+
vi.mock('vscode', () => ({
63+
Uri: {
64+
file: (fsPath: string) => ({ fsPath }),
65+
},
66+
window: {
67+
showSaveDialog: mockShowSaveDialog,
68+
},
69+
}));
70+
71+
import {
72+
exportSessionToFile,
73+
parseExportSlashCommand,
74+
} from './sessionExportService.js';
75+
76+
describe('sessionExportService', () => {
77+
beforeEach(() => {
78+
vi.clearAllMocks();
79+
80+
mockLoadSession.mockResolvedValue({
81+
conversation: {
82+
sessionId: 'session-1',
83+
startTime: '2025-01-01T00:00:00Z',
84+
messages: [],
85+
},
86+
});
87+
mockCollectSessionData.mockResolvedValue({
88+
sessionId: 'session-1',
89+
startTime: '2025-01-01T00:00:00Z',
90+
messages: [],
91+
});
92+
mockNormalizeSessionData.mockImplementation((data) => data);
93+
mockToHtml.mockReturnValue('<html>export</html>');
94+
mockToMarkdown.mockReturnValue('# export');
95+
mockToJson.mockReturnValue('{"ok":true}');
96+
mockToJsonl.mockReturnValue('{"ok":true}');
97+
mockGenerateExportFilename.mockImplementation(
98+
(format: string) => `qwen-export.${format}`,
99+
);
100+
});
101+
102+
describe('parseExportSlashCommand', () => {
103+
it('returns null for non-export input', () => {
104+
expect(parseExportSlashCommand('hello')).toBeNull();
105+
expect(parseExportSlashCommand('/model')).toBeNull();
106+
});
107+
108+
it('requires an explicit subcommand for bare /export', () => {
109+
expect(() => parseExportSlashCommand('/export')).toThrow(
110+
"Command '/export' requires a subcommand.",
111+
);
112+
expect(() => parseExportSlashCommand('/export ')).toThrow(
113+
"Command '/export' requires a subcommand.",
114+
);
115+
});
116+
117+
it('returns the requested export format', () => {
118+
expect(parseExportSlashCommand('/export html')).toBe('html');
119+
expect(parseExportSlashCommand('/export md')).toBe('md');
120+
expect(parseExportSlashCommand('/export JSON')).toBe('json');
121+
});
122+
123+
it('rejects unsupported export arguments', () => {
124+
expect(() => parseExportSlashCommand('/export csv')).toThrow(
125+
'Unsupported /export format. Use /export html, /export md, /export json, or /export jsonl.',
126+
);
127+
expect(() => parseExportSlashCommand('/export md extra')).toThrow(
128+
'Unsupported /export format. Use /export html, /export md, /export json, or /export jsonl.',
129+
);
130+
});
131+
});
132+
133+
describe('exportSessionToFile', () => {
134+
it('writes the exported session to the user-chosen path', async () => {
135+
const chosenPath = path.join('/workspace', 'qwen-export.html');
136+
mockShowSaveDialog.mockResolvedValue({ fsPath: chosenPath });
137+
138+
const result = await exportSessionToFile({
139+
sessionId: 'session-1',
140+
cwd: '/workspace',
141+
format: 'html',
142+
});
143+
144+
expect(mockCollectSessionData).toHaveBeenCalledWith(
145+
expect.objectContaining({ sessionId: 'session-1' }),
146+
expect.anything(),
147+
);
148+
expect(mockNormalizeSessionData).toHaveBeenCalled();
149+
expect(mockToHtml).toHaveBeenCalled();
150+
expect(mockShowSaveDialog).toHaveBeenCalledWith(
151+
expect.objectContaining({
152+
title: 'Export Session as HTML',
153+
}),
154+
);
155+
expect(mockWriteFile).toHaveBeenCalledWith(
156+
chosenPath,
157+
'<html>export</html>',
158+
'utf-8',
159+
);
160+
expect(result).toEqual({
161+
filename: 'qwen-export.html',
162+
uri: { fsPath: chosenPath },
163+
});
164+
});
165+
166+
it('returns null when the user cancels the save dialog', async () => {
167+
mockShowSaveDialog.mockResolvedValue(undefined);
168+
169+
const result = await exportSessionToFile({
170+
sessionId: 'session-1',
171+
cwd: '/workspace',
172+
format: 'html',
173+
});
174+
175+
expect(result).toBeNull();
176+
expect(mockWriteFile).not.toHaveBeenCalled();
177+
});
178+
179+
it('throws when the target session cannot be loaded', async () => {
180+
mockLoadSession.mockResolvedValue(undefined);
181+
182+
await expect(
183+
exportSessionToFile({
184+
sessionId: 'missing-session',
185+
cwd: '/workspace',
186+
format: 'json',
187+
}),
188+
).rejects.toThrow('No active session found to export.');
189+
});
190+
});
191+
});

0 commit comments

Comments
 (0)