Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
f518717
feat(vscode-companion): support /export session command
yiliang114 Mar 22, 2026
612fb34
fix(vscode-ide-companion/webview): prefer ACP session id for export
yiliang114 Apr 11, 2026
5c44d2b
feat(vscode-ide-companion): support /export slash command
yiliang114 Apr 18, 2026
77488b0
fix(export): align slash command messaging
yiliang114 Apr 18, 2026
eb09075
fix(webui): support explicit markdown file links
yiliang114 Apr 18, 2026
47d3489
test(vscode-ide-companion): make export path assertion cross-platform
yiliang114 Apr 18, 2026
9e08b22
fix(vscode-ide-companion): use public session export entrypoint
yiliang114 Apr 19, 2026
b705931
fix(cli): replay standalone ESC after early capture
yiliang114 Apr 19, 2026
92bf609
fix(vscode-ide-companion): resolve rebase artifacts and vitest export…
yiliang114 Apr 20, 2026
a1f90ae
fix(cli): fix getAvailableCommands test mock to use getCommandsForMode
yiliang114 Apr 20, 2026
4fd1624
fix(vscode-ide-companion): fix export file link click and add save di…
yiliang114 Apr 21, 2026
ffbb99c
fix(webui): scope file link handler to file:// URIs only, fix # in fi…
yiliang114 Apr 21, 2026
7a5cb2b
fix(vscode-ide-companion): update export tests for save dialog, fix s…
yiliang114 Apr 21, 2026
02449fd
fix(webui): restore explicit file-path link handling alongside file:/…
yiliang114 Apr 22, 2026
950ad4a
Merge remote-tracking branch 'origin/main' into feat/1991-vscode-expo…
yiliang114 Apr 24, 2026
3562f07
fix(cli): add missing getDisabledSlashCommands mock in getAvailableCo…
yiliang114 Apr 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
17 changes: 17 additions & 0 deletions packages/cli/src/export/index.ts
Original file line number Diff line number Diff line change
@@ -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';
45 changes: 44 additions & 1 deletion packages/cli/src/nonInteractiveCliCommands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
});
});
4 changes: 2 additions & 2 deletions packages/cli/src/utils/earlyInputCapture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});

Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/utils/earlyInputCapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}

Expand Down
10 changes: 10 additions & 0 deletions packages/vscode-ide-companion/esbuild.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 */
Expand Down
Original file line number Diff line number Diff line change
@@ -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('<html>export</html>');
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,
'<html>export</html>',
'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.');
});
});
});
Loading
Loading