diff --git a/QWEN.md b/AGENTS.md similarity index 99% rename from QWEN.md rename to AGENTS.md index 3e15e6d6f..bc835dfb6 100644 --- a/QWEN.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -# Qwen Code - Project Context +# AGENTS.md - Qwen Code Project Context ## Project Overview diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 246d80019..dbdab8de4 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -64,6 +64,7 @@ import type { CliArgs } from '../config/config.js'; import { loadCliConfig } from '../config/config.js'; import { Session } from './session/Session.js'; import { formatAcpModelId } from '../utils/acpModelUtils.js'; +import { runWithAcpRuntimeOutputDir } from './runtimeOutputDirContext.js'; const debugLogger = createDebugLogger('ACP_AGENT'); @@ -87,7 +88,33 @@ export async function runAcpAgent( stream, ); + // Handle SIGTERM/SIGINT for graceful shutdown. + // Without this, signal handlers registered elsewhere in the CLI + // (e.g., stdin raw mode restoration) override the default exit behavior, + // causing the ACP process to ignore termination signals. + let shuttingDown = false; + const shutdownHandler = () => { + if (shuttingDown) return; + shuttingDown = true; + debugLogger.debug('[ACP] Shutdown signal received, closing streams'); + try { + process.stdin.destroy(); + } catch { + // stdin may already be closed + } + try { + process.stdout.destroy(); + } catch { + // stdout may already be closed + } + }; + process.on('SIGTERM', shutdownHandler); + process.on('SIGINT', shutdownHandler); + await connection.closed; + + process.off('SIGTERM', shutdownHandler); + process.off('SIGINT', shutdownHandler); } function toStdioServer(server: McpServer): McpServerStdio | undefined { @@ -188,8 +215,14 @@ class QwenAgent implements Agent { } async loadSession(params: LoadSessionRequest): Promise { - const sessionService = new SessionService(params.cwd); - const exists = await sessionService.sessionExists(params.sessionId); + const exists = await runWithAcpRuntimeOutputDir( + this.settings, + params.cwd, + async () => { + const sessionService = new SessionService(params.cwd); + return sessionService.sessionExists(params.sessionId); + }, + ); if (!exists) { throw RequestError.invalidParams( undefined, @@ -230,10 +263,12 @@ class QwenAgent implements Agent { params: ListSessionsRequest, ): Promise { const cwd = params.cwd || process.cwd(); - const sessionService = new SessionService(cwd); const numericCursor = params.cursor ? Number(params.cursor) : undefined; - const result = await sessionService.listSessions({ - cursor: Number.isNaN(numericCursor) ? undefined : numericCursor, + const result = await runWithAcpRuntimeOutputDir(this.settings, cwd, () => { + const sessionService = new SessionService(cwd); + return sessionService.listSessions({ + cursor: Number.isNaN(numericCursor) ? undefined : numericCursor, + }); }); const sessions: SessionInfo[] = result.items.map((item) => ({ diff --git a/packages/cli/src/acp-integration/runtimeOutputDirContext.test.ts b/packages/cli/src/acp-integration/runtimeOutputDirContext.test.ts new file mode 100644 index 000000000..e1068a74c --- /dev/null +++ b/packages/cli/src/acp-integration/runtimeOutputDirContext.test.ts @@ -0,0 +1,34 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import path from 'node:path'; +import { Storage } from '@qwen-code/qwen-code-core'; +import type { LoadedSettings } from '../config/settings.js'; +import { runWithAcpRuntimeOutputDir } from './runtimeOutputDirContext.js'; + +describe('runWithAcpRuntimeOutputDir', () => { + beforeEach(() => { + Storage.setRuntimeBaseDir(null); + delete process.env['QWEN_RUNTIME_DIR']; + }); + + afterEach(() => { + Storage.setRuntimeBaseDir(null); + delete process.env['QWEN_RUNTIME_DIR']; + }); + + it('uses the merged runtimeOutputDir relative to cwd within the async context', async () => { + const cwd = path.resolve('workspace', 'project-a'); + const settings = { + merged: { + advanced: { + runtimeOutputDir: '.qwen-runtime', + }, + }, + } as LoadedSettings; + + await runWithAcpRuntimeOutputDir(settings, cwd, async () => { + expect(Storage.getRuntimeBaseDir()).toBe(path.join(cwd, '.qwen-runtime')); + }); + + expect(Storage.getRuntimeBaseDir()).toBe(Storage.getGlobalQwenDir()); + }); +}); diff --git a/packages/cli/src/acp-integration/runtimeOutputDirContext.ts b/packages/cli/src/acp-integration/runtimeOutputDirContext.ts new file mode 100644 index 000000000..7387e5994 --- /dev/null +++ b/packages/cli/src/acp-integration/runtimeOutputDirContext.ts @@ -0,0 +1,14 @@ +import { Storage } from '@qwen-code/qwen-code-core'; +import type { LoadedSettings } from '../config/settings.js'; + +export function runWithAcpRuntimeOutputDir( + settings: LoadedSettings, + cwd: string, + fn: () => T, +): T { + return Storage.runWithRuntimeBaseDir( + settings.merged.advanced?.runtimeOutputDir, + cwd, + fn, + ); +} diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 346537409..9715d765c 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -58,6 +58,7 @@ describe('Session', () => { switchModel: switchModelSpy, getModel: vi.fn().mockImplementation(() => currentModel), getSessionId: vi.fn().mockReturnValue('test-session-id'), + getWorkingDir: vi.fn().mockReturnValue(process.cwd()), getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false), getUsageStatisticsEnabled: vi.fn().mockReturnValue(false), getContentGeneratorConfig: vi.fn().mockReturnValue(undefined), @@ -241,5 +242,38 @@ describe('Session', () => { await fs.rm(tempDir, { recursive: true, force: true }); } }); + + it('runs prompt inside runtime output dir context', async () => { + const runtimeDir = path.resolve('runtime', 'from-settings'); + core.Storage.setRuntimeBaseDir(runtimeDir); + session = new Session( + 'test-session-id', + mockChat, + mockConfig, + mockClient, + mockSettings, + ); + const runWithRuntimeBaseDirSpy = vi.spyOn( + core.Storage, + 'runWithRuntimeBaseDir', + ); + + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValue((async function* () {})()); + + const promptRequest: PromptRequest = { + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }; + + await session.prompt(promptRequest); + + expect(runWithRuntimeBaseDirSpy).toHaveBeenCalledWith( + runtimeDir, + process.cwd(), + expect.any(Function), + ); + }); }); }); diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 43c1c2d14..45b837569 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -34,6 +34,7 @@ import { TodoWriteTool, ExitPlanModeTool, readManyFiles, + Storage, ToolNames, } from '@qwen-code/qwen-code-core'; @@ -100,6 +101,7 @@ export class Session implements SessionContext { */ private pendingPromptCompletion: Promise | null = null; private turn: number = 0; + private readonly runtimeBaseDir: string; // Modular components private readonly historyReplayer: HistoryReplayer; @@ -118,6 +120,7 @@ export class Session implements SessionContext { private readonly settings: LoadedSettings, ) { this.sessionId = id; + this.runtimeBaseDir = Storage.getRuntimeBaseDir(); // Initialize modular components with this session as context this.toolCallEmitter = new ToolCallEmitter(this); @@ -189,150 +192,170 @@ export class Session implements SessionContext { params: PromptRequest, pendingSend: AbortController, ): Promise { - // Increment turn counter for each user prompt - this.turn += 1; + return Storage.runWithRuntimeBaseDir( + this.runtimeBaseDir, + this.config.getWorkingDir(), + async () => { + // Increment turn counter for each user prompt + this.turn += 1; - const chat = this.chat; - const promptId = this.config.getSessionId() + '########' + this.turn; + const chat = this.chat; + const promptId = this.config.getSessionId() + '########' + this.turn; - // Extract text from all text blocks to construct the full prompt text for logging - const promptText = params.prompt - .filter((block) => block.type === 'text') - .map((block) => (block.type === 'text' ? block.text : '')) - .join(' '); + // Extract text from all text blocks to construct the full prompt text for logging + const promptText = params.prompt + .filter((block) => block.type === 'text') + .map((block) => (block.type === 'text' ? block.text : '')) + .join(' '); - // Log user prompt - logUserPrompt( - this.config, - new UserPromptEvent( - promptText.length, - promptId, - this.config.getContentGeneratorConfig()?.authType, - promptText, - ), - ); - - // record user message for session management - this.config.getChatRecordingService()?.recordUserMessage(promptText); - - // Check if the input contains a slash command - // Extract text from the first text block if present - const firstTextBlock = params.prompt.find((block) => block.type === 'text'); - const inputText = firstTextBlock?.text || ''; - - let parts: Part[] | null; - - if (isSlashCommand(inputText)) { - // Handle slash command - uses default allowed commands (init, summary, compress) - const slashCommandResult = await handleSlashCommand( - inputText, - pendingSend, - this.config, - this.settings, - ); - - parts = await this.#processSlashCommandResult( - slashCommandResult, - params.prompt, - ); - - // If parts is null, the command was fully handled (e.g., /summary completed) - // Return early without sending to the model - if (parts === null) { - return { stopReason: 'end_turn' }; - } - } else { - // Normal processing for non-slash commands - parts = await this.#resolvePrompt(params.prompt, pendingSend.signal); - } - - let nextMessage: Content | null = { role: 'user', parts }; - - while (nextMessage !== null) { - if (pendingSend.signal.aborted) { - chat.addHistory(nextMessage); - return { stopReason: 'cancelled' }; - } - - const functionCalls: FunctionCall[] = []; - let usageMetadata: GenerateContentResponseUsageMetadata | null = null; - const streamStartTime = Date.now(); - - try { - const responseStream = await chat.sendMessageStream( - this.config.getModel(), - { - message: nextMessage?.parts ?? [], - config: { - abortSignal: pendingSend.signal, - }, - }, - promptId, + // Log user prompt + logUserPrompt( + this.config, + new UserPromptEvent( + promptText.length, + promptId, + this.config.getContentGeneratorConfig()?.authType, + promptText, + ), ); - nextMessage = null; - for await (const resp of responseStream) { + // record user message for session management + this.config.getChatRecordingService()?.recordUserMessage(promptText); + + // Check if the input contains a slash command + // Extract text from the first text block if present + const firstTextBlock = params.prompt.find( + (block) => block.type === 'text', + ); + const inputText = firstTextBlock?.text || ''; + + let parts: Part[] | null; + + if (isSlashCommand(inputText)) { + // Handle slash command - uses default allowed commands (init, summary, compress) + const slashCommandResult = await handleSlashCommand( + inputText, + pendingSend, + this.config, + this.settings, + ); + + parts = await this.#processSlashCommandResult( + slashCommandResult, + params.prompt, + ); + + // If parts is null, the command was fully handled (e.g., /summary completed) + // Return early without sending to the model + if (parts === null) { + return { stopReason: 'end_turn' }; + } + } else { + // Normal processing for non-slash commands + parts = await this.#resolvePrompt(params.prompt, pendingSend.signal); + } + + let nextMessage: Content | null = { role: 'user', parts }; + + while (nextMessage !== null) { if (pendingSend.signal.aborted) { + chat.addHistory(nextMessage); return { stopReason: 'cancelled' }; } - if ( - resp.type === StreamEventType.CHUNK && - resp.value.candidates && - resp.value.candidates.length > 0 - ) { - const candidate = resp.value.candidates[0]; - for (const part of candidate.content?.parts ?? []) { - if (!part.text) { - continue; + const functionCalls: FunctionCall[] = []; + let usageMetadata: GenerateContentResponseUsageMetadata | null = null; + const streamStartTime = Date.now(); + + try { + const responseStream = await chat.sendMessageStream( + this.config.getModel(), + { + message: nextMessage?.parts ?? [], + config: { + abortSignal: pendingSend.signal, + }, + }, + promptId, + ); + nextMessage = null; + + for await (const resp of responseStream) { + if (pendingSend.signal.aborted) { + return { stopReason: 'cancelled' }; } - this.messageEmitter.emitMessage( - part.text, - 'assistant', - part.thought, + if ( + resp.type === StreamEventType.CHUNK && + resp.value.candidates && + resp.value.candidates.length > 0 + ) { + const candidate = resp.value.candidates[0]; + for (const part of candidate.content?.parts ?? []) { + if (!part.text) { + continue; + } + + this.messageEmitter.emitMessage( + part.text, + 'assistant', + part.thought, + ); + } + } + + if ( + resp.type === StreamEventType.CHUNK && + resp.value.usageMetadata + ) { + usageMetadata = resp.value.usageMetadata; + } + + if ( + resp.type === StreamEventType.CHUNK && + resp.value.functionCalls + ) { + functionCalls.push(...resp.value.functionCalls); + } + } + } catch (error) { + if (getErrorStatus(error) === 429) { + throw new RequestError( + 429, + 'Rate limit exceeded. Try again later.', ); } + + throw error; } - if (resp.type === StreamEventType.CHUNK && resp.value.usageMetadata) { - usageMetadata = resp.value.usageMetadata; + if (usageMetadata) { + const durationMs = Date.now() - streamStartTime; + await this.messageEmitter.emitUsageMetadata( + usageMetadata, + '', + durationMs, + ); } - if (resp.type === StreamEventType.CHUNK && resp.value.functionCalls) { - functionCalls.push(...resp.value.functionCalls); + if (functionCalls.length > 0) { + const toolResponseParts: Part[] = []; + + for (const fc of functionCalls) { + const response = await this.runTool( + pendingSend.signal, + promptId, + fc, + ); + toolResponseParts.push(...response); + } + + nextMessage = { role: 'user', parts: toolResponseParts }; } } - } catch (error) { - if (getErrorStatus(error) === 429) { - throw new RequestError(429, 'Rate limit exceeded. Try again later.'); - } - - throw error; - } - - if (usageMetadata) { - const durationMs = Date.now() - streamStartTime; - await this.messageEmitter.emitUsageMetadata( - usageMetadata, - '', - durationMs, - ); - } - - if (functionCalls.length > 0) { - const toolResponseParts: Part[] = []; - - for (const fc of functionCalls) { - const response = await this.runTool(pendingSend.signal, promptId, fc); - toolResponseParts.push(...response); - } - - nextMessage = { role: 'user', parts: toolResponseParts }; - } - } - - return { stopReason: 'end_turn' }; + return { stopReason: 'end_turn' }; + }, + ); } async sendUpdate(update: SessionUpdate): Promise { @@ -867,13 +890,12 @@ export class Session implements SessionContext { } case 'no_command': - // No command was found or executed, use original prompt - return originalPrompt.map((block) => { - if (block.type === 'text') { - return { text: block.text }; - } - throw new Error(`Unsupported block type: ${block.type}`); - }); + // No command was found or executed, resolve the original prompt + // through the standard path that handles all block types + return this.#resolvePrompt( + originalPrompt, + new AbortController().signal, + ); default: { // Exhaustiveness check diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 207ecb8dd..d1b2bd3f8 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -14,6 +14,7 @@ import { DEFAULT_QWEN_MODEL, OutputFormat, NativeLspService, + Storage, } from '@qwen-code/qwen-code-core'; import { loadCliConfig, parseArguments, type CliArgs } from './config.js'; import type { Settings } from './settings.js'; @@ -2439,3 +2440,79 @@ describe('Telemetry configuration via environment variables', () => { expect(config.getTelemetryLogPromptsEnabled()).toBe(false); }); }); + +describe('loadCliConfig runtimeOutputDir', () => { + const originalArgv = process.argv; + const originalRuntimeEnv = process.env['QWEN_RUNTIME_DIR']; + + beforeEach(() => { + process.argv = ['node', 'script.js']; + Storage.setRuntimeBaseDir(null); + delete process.env['QWEN_RUNTIME_DIR']; + }); + + afterEach(() => { + process.argv = originalArgv; + Storage.setRuntimeBaseDir(null); + if (originalRuntimeEnv !== undefined) { + process.env['QWEN_RUNTIME_DIR'] = originalRuntimeEnv; + } else { + delete process.env['QWEN_RUNTIME_DIR']; + } + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('should set runtime base dir from settings with absolute path', async () => { + const runtimeDir = path.resolve('custom', 'runtime'); + const argv = await parseArguments(); + const settings: Settings = { + advanced: { runtimeOutputDir: runtimeDir }, + }; + await loadCliConfig(settings, argv); + expect(Storage.getRuntimeBaseDir()).toBe(runtimeDir); + }); + + it('should resolve relative runtimeOutputDir against cwd', async () => { + const argv = await parseArguments(); + const settings: Settings = { + advanced: { runtimeOutputDir: '.qwen' }, + }; + const cwd = path.resolve('workspace', 'my-project'); + await loadCliConfig(settings, argv, cwd); + expect(Storage.getRuntimeBaseDir()).toBe(path.join(cwd, '.qwen')); + }); + + it('should not set runtime base dir when runtimeOutputDir is absent', async () => { + const argv = await parseArguments(); + const settings: Settings = {}; + await loadCliConfig(settings, argv); + expect(Storage.getRuntimeBaseDir()).toBe(Storage.getGlobalQwenDir()); + }); + + it('should let QWEN_RUNTIME_DIR env var take priority over settings', async () => { + const envDir = path.resolve('from-env'); + const settingsDir = path.resolve('from-settings'); + process.env['QWEN_RUNTIME_DIR'] = envDir; + const argv = await parseArguments(); + const settings: Settings = { + advanced: { runtimeOutputDir: settingsDir }, + }; + await loadCliConfig(settings, argv); + // getRuntimeBaseDir checks env var first at call time + expect(Storage.getRuntimeBaseDir()).toBe(envDir); + }); + + it('should reset runtime base dir on subsequent load when runtimeOutputDir is absent', async () => { + const argv = await parseArguments(); + const firstRuntimeDir = path.resolve('first', 'runtime'); + await loadCliConfig( + { advanced: { runtimeOutputDir: firstRuntimeDir } }, + argv, + ); + expect(Storage.getRuntimeBaseDir()).toBe(firstRuntimeDir); + + await loadCliConfig({}, argv); + expect(Storage.getRuntimeBaseDir()).toBe(Storage.getGlobalQwenDir()); + }); +}); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index d1b8fbf86..78ef3dde0 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -708,6 +708,11 @@ export async function loadCliConfig( ): Promise { const debugMode = isDebugMode(argv); + // Set runtime output directory from settings (env var QWEN_RUNTIME_DIR + // is auto-detected inside getRuntimeBaseDir() at each call site). + // Pass cwd so that relative paths like ".qwen" resolve per-project. + Storage.setRuntimeBaseDir(settings.advanced?.runtimeOutputDir, cwd); + const ideMode = settings.ide?.enabled ?? false; const folderTrust = settings.security?.folderTrust?.enabled ?? false; diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index c97b41f86..379ea2168 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1263,6 +1263,17 @@ const SETTINGS_SCHEMA = { description: 'Configuration for the bug report command.', showInDialog: false, }, + runtimeOutputDir: { + type: 'string', + label: 'Runtime Output Directory', + category: 'Advanced', + requiresRestart: true, + default: undefined as string | undefined, + description: + 'Custom directory for runtime output (temp files, debug logs, session data, todos, etc.). ' + + 'Config files remain at ~/.qwen. Env var QWEN_RUNTIME_DIR takes priority.', + showInDialog: false, + }, tavilyApiKey: { type: 'string', label: 'Tavily API Key (Deprecated)', diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 477aecd7e..b28ed2591 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -326,8 +326,15 @@ export async function main() { } } - // Handle --resume without a session ID by showing the session picker + // Handle --resume without a session ID by showing the session picker. + // Set the runtime output dir early so the picker can find sessions stored + // under a custom runtimeOutputDir (setRuntimeBaseDir is idempotent and will + // be called again inside loadCliConfig). if (argv.resume === '') { + Storage.setRuntimeBaseDir( + settings.merged.advanced?.runtimeOutputDir, + process.cwd(), + ); const selectedSessionId = await showResumeSessionPicker(); if (!selectedSessionId) { // User cancelled or no sessions available diff --git a/packages/cli/src/services/insight/generators/StaticInsightGenerator.test.ts b/packages/cli/src/services/insight/generators/StaticInsightGenerator.test.ts new file mode 100644 index 000000000..7c8e8dedb --- /dev/null +++ b/packages/cli/src/services/insight/generators/StaticInsightGenerator.test.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'path'; +import fs from 'fs/promises'; +import { Storage, type Config } from '@qwen-code/qwen-code-core'; +import { StaticInsightGenerator } from './StaticInsightGenerator.js'; + +vi.mock('fs/promises', () => ({ + default: { + mkdir: vi.fn(), + access: vi.fn(), + writeFile: vi.fn(), + unlink: vi.fn(), + symlink: vi.fn(), + copyFile: vi.fn(), + }, +})); + +describe('StaticInsightGenerator', () => { + const mockedFs = vi.mocked(fs); + const mockConfig = {} as Config; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-03-05T12:34:56.000Z')); + Storage.setRuntimeBaseDir(path.resolve('runtime-output')); + vi.clearAllMocks(); + + mockedFs.mkdir.mockResolvedValue(undefined); + mockedFs.access.mockRejectedValue(new Error('not found')); + mockedFs.writeFile.mockResolvedValue(undefined); + mockedFs.unlink.mockRejectedValue(new Error('not found')); + mockedFs.symlink.mockResolvedValue(undefined); + mockedFs.copyFile.mockResolvedValue(undefined); + }); + + afterEach(() => { + Storage.setRuntimeBaseDir(null); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('writes insights under runtime output directory', async () => { + const generator = new StaticInsightGenerator(mockConfig); + const generateInsights = vi.fn().mockResolvedValue({}); + const renderInsightHTML = vi.fn().mockResolvedValue('ok'); + + ( + generator as unknown as { + dataProcessor: { generateInsights: typeof generateInsights }; + } + ).dataProcessor = { generateInsights }; + ( + generator as unknown as { + templateRenderer: { renderInsightHTML: typeof renderInsightHTML }; + } + ).templateRenderer = { renderInsightHTML }; + + const projectsDir = path.resolve( + 'workspace', + 'project-a', + '.qwen', + 'projects', + ); + const outputDir = path.join(Storage.getRuntimeBaseDir(), 'insights'); + const facetsDir = path.join(outputDir, 'facets'); + const expectedOutputPath = path.join(outputDir, 'insight-2026-03-05.html'); + + const outputPath = await generator.generateStaticInsight(projectsDir); + + expect(mockedFs.mkdir).toHaveBeenCalledWith(outputDir, { recursive: true }); + expect(mockedFs.mkdir).toHaveBeenCalledWith(facetsDir, { recursive: true }); + expect(generateInsights).toHaveBeenCalledWith( + projectsDir, + facetsDir, + undefined, + ); + expect(renderInsightHTML).toHaveBeenCalledWith({}); + expect(mockedFs.writeFile).toHaveBeenCalledWith( + expectedOutputPath, + 'ok', + 'utf-8', + ); + expect(outputPath).toBe(expectedOutputPath); + }); +}); diff --git a/packages/cli/src/services/insight/generators/StaticInsightGenerator.ts b/packages/cli/src/services/insight/generators/StaticInsightGenerator.ts index 6d0c661cc..51fca7e99 100644 --- a/packages/cli/src/services/insight/generators/StaticInsightGenerator.ts +++ b/packages/cli/src/services/insight/generators/StaticInsightGenerator.ts @@ -6,7 +6,6 @@ import fs from 'fs/promises'; import path from 'path'; -import os from 'os'; import { DataProcessor } from './DataProcessor.js'; import { TemplateRenderer } from './TemplateRenderer.js'; import type { @@ -14,7 +13,7 @@ import type { InsightProgressCallback, } from '../types/StaticInsightTypes.js'; -import { updateSymlink, type Config } from '@qwen-code/qwen-code-core'; +import { updateSymlink, Storage, type Config } from '@qwen-code/qwen-code-core'; export class StaticInsightGenerator { private dataProcessor: DataProcessor; @@ -27,7 +26,7 @@ export class StaticInsightGenerator { // Ensure the output directory exists private async ensureOutputDirectory(): Promise { - const outputDir = path.join(os.homedir(), '.qwen', 'insights'); + const outputDir = path.join(Storage.getRuntimeBaseDir(), 'insights'); await fs.mkdir(outputDir, { recursive: true }); return outputDir; } diff --git a/packages/cli/src/ui/commands/insightCommand.test.ts b/packages/cli/src/ui/commands/insightCommand.test.ts new file mode 100644 index 000000000..159dd1747 --- /dev/null +++ b/packages/cli/src/ui/commands/insightCommand.test.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import path from 'path'; +import open from 'open'; +import { Storage } from '@qwen-code/qwen-code-core'; +import { insightCommand } from './insightCommand.js'; +import type { CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; + +const mockGenerateStaticInsight = vi.fn(); + +vi.mock('../../services/insight/generators/StaticInsightGenerator.js', () => ({ + StaticInsightGenerator: vi.fn(() => ({ + generateStaticInsight: mockGenerateStaticInsight, + })), +})); + +vi.mock('open', () => ({ + default: vi.fn(), +})); + +describe('insightCommand', () => { + let mockContext: CommandContext; + + beforeEach(() => { + Storage.setRuntimeBaseDir(path.resolve('runtime-output')); + mockGenerateStaticInsight.mockResolvedValue( + path.resolve('runtime-output', 'insights', 'insight-2026-03-05.html'), + ); + vi.mocked(open).mockResolvedValue(undefined as never); + + mockContext = createMockCommandContext({ + services: { + config: {} as CommandContext['services']['config'], + }, + ui: { + addItem: vi.fn(), + setPendingItem: vi.fn(), + setDebugMessage: vi.fn(), + }, + } as unknown as CommandContext); + }); + + afterEach(() => { + Storage.setRuntimeBaseDir(null); + vi.restoreAllMocks(); + }); + + it('uses runtime base dir to locate projects directory', async () => { + if (!insightCommand.action) { + throw new Error('insight command must have action'); + } + + await insightCommand.action(mockContext, ''); + + expect(mockGenerateStaticInsight).toHaveBeenCalledWith( + path.join(Storage.getRuntimeBaseDir(), 'projects'), + expect.any(Function), + ); + }); +}); diff --git a/packages/cli/src/ui/commands/insightCommand.ts b/packages/cli/src/ui/commands/insightCommand.ts index 1693254bb..de28381b3 100644 --- a/packages/cli/src/ui/commands/insightCommand.ts +++ b/packages/cli/src/ui/commands/insightCommand.ts @@ -10,9 +10,8 @@ import { MessageType } from '../types.js'; import type { HistoryItemInsightProgress } from '../types.js'; import { t } from '../../i18n/index.js'; import { join } from 'path'; -import os from 'os'; import { StaticInsightGenerator } from '../../services/insight/generators/StaticInsightGenerator.js'; -import { createDebugLogger } from '@qwen-code/qwen-code-core'; +import { createDebugLogger, Storage } from '@qwen-code/qwen-code-core'; import open from 'open'; const logger = createDebugLogger('DataProcessor'); @@ -29,7 +28,7 @@ export const insightCommand: SlashCommand = { try { context.ui.setDebugMessage(t('Generating insights...')); - const projectsDir = join(os.homedir(), '.qwen', 'projects'); + const projectsDir = join(Storage.getRuntimeBaseDir(), 'projects'); if (!context.services.config) { throw new Error('Config service is not available'); } diff --git a/packages/core/src/config/storage.test.ts b/packages/core/src/config/storage.test.ts index 36cc1b258..a32b9fbde 100644 --- a/packages/core/src/config/storage.test.ts +++ b/packages/core/src/config/storage.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as os from 'node:os'; import * as path from 'node:path'; import { Storage } from './storage.js'; @@ -40,3 +40,321 @@ describe('Storage – additional helpers', () => { expect(Storage.getMcpOAuthTokensPath()).toBe(expected); }); }); + +describe('Storage – getRuntimeBaseDir / setRuntimeBaseDir', () => { + const originalEnv = process.env['QWEN_RUNTIME_DIR']; + + beforeEach(() => { + // Reset state before each test + Storage.setRuntimeBaseDir(null); + delete process.env['QWEN_RUNTIME_DIR']; + }); + + afterEach(() => { + // Restore original env + Storage.setRuntimeBaseDir(null); + if (originalEnv !== undefined) { + process.env['QWEN_RUNTIME_DIR'] = originalEnv; + } else { + delete process.env['QWEN_RUNTIME_DIR']; + } + }); + + it('defaults to getGlobalQwenDir() when nothing is configured', () => { + expect(Storage.getRuntimeBaseDir()).toBe(Storage.getGlobalQwenDir()); + }); + + it('uses setRuntimeBaseDir value when set with absolute path', () => { + const runtimeDir = path.resolve('custom', 'runtime'); + Storage.setRuntimeBaseDir(runtimeDir); + expect(Storage.getRuntimeBaseDir()).toBe(runtimeDir); + }); + + it('env var QWEN_RUNTIME_DIR takes priority over setRuntimeBaseDir', () => { + const settingsDir = path.resolve('from-settings'); + const envDir = path.resolve('from-env'); + Storage.setRuntimeBaseDir(settingsDir); + process.env['QWEN_RUNTIME_DIR'] = envDir; + expect(Storage.getRuntimeBaseDir()).toBe(envDir); + }); + + it('expands tilde (~) in setRuntimeBaseDir', () => { + Storage.setRuntimeBaseDir('~/custom-runtime'); + const expected = path.join(os.homedir(), 'custom-runtime'); + expect(Storage.getRuntimeBaseDir()).toBe(expected); + }); + + it('expands Windows-style tilde paths in setRuntimeBaseDir', () => { + Storage.setRuntimeBaseDir('~\\custom-runtime'); + const expected = path.join(os.homedir(), 'custom-runtime'); + expect(Storage.getRuntimeBaseDir()).toBe(expected); + }); + + it('expands tilde (~) in QWEN_RUNTIME_DIR env var', () => { + process.env['QWEN_RUNTIME_DIR'] = '~/env-runtime'; + const expected = path.join(os.homedir(), 'env-runtime'); + expect(Storage.getRuntimeBaseDir()).toBe(expected); + }); + + it('resolves relative paths in setRuntimeBaseDir using process.cwd by default', () => { + Storage.setRuntimeBaseDir('relative/path'); + const expected = path.resolve('relative/path'); + expect(Storage.getRuntimeBaseDir()).toBe(expected); + }); + + it('resolves relative paths in setRuntimeBaseDir using explicit cwd', () => { + const cwd = path.resolve('workspace', 'projectA'); + Storage.setRuntimeBaseDir('.qwen', cwd); + expect(Storage.getRuntimeBaseDir()).toBe(path.join(cwd, '.qwen')); + }); + + it('ignores cwd when path is absolute', () => { + const absolutePath = path.resolve('absolute', 'path'); + const cwd = path.resolve('workspace', 'projectA'); + Storage.setRuntimeBaseDir(absolutePath, cwd); + expect(Storage.getRuntimeBaseDir()).toBe(absolutePath); + }); + + it('ignores cwd when path starts with tilde', () => { + Storage.setRuntimeBaseDir( + '~/runtime', + path.resolve('workspace', 'projectA'), + ); + const expected = path.join(os.homedir(), 'runtime'); + expect(Storage.getRuntimeBaseDir()).toBe(expected); + }); + + it('resolves relative paths in QWEN_RUNTIME_DIR env var', () => { + process.env['QWEN_RUNTIME_DIR'] = 'relative/env-path'; + const expected = path.resolve('relative/env-path'); + expect(Storage.getRuntimeBaseDir()).toBe(expected); + }); + + it('resets to default when setRuntimeBaseDir is called with null', () => { + const customDir = path.resolve('custom'); + Storage.setRuntimeBaseDir(customDir); + expect(Storage.getRuntimeBaseDir()).toBe(customDir); + + Storage.setRuntimeBaseDir(null); + expect(Storage.getRuntimeBaseDir()).toBe(Storage.getGlobalQwenDir()); + }); + + it('resets to default when setRuntimeBaseDir is called with undefined', () => { + Storage.setRuntimeBaseDir(path.resolve('custom')); + Storage.setRuntimeBaseDir(undefined); + expect(Storage.getRuntimeBaseDir()).toBe(Storage.getGlobalQwenDir()); + }); + + it('resets to default when setRuntimeBaseDir is called with empty string', () => { + Storage.setRuntimeBaseDir(path.resolve('custom')); + Storage.setRuntimeBaseDir(''); + expect(Storage.getRuntimeBaseDir()).toBe(Storage.getGlobalQwenDir()); + }); + + it('handles bare tilde (~) as home directory', () => { + Storage.setRuntimeBaseDir('~'); + expect(Storage.getRuntimeBaseDir()).toBe(os.homedir()); + }); +}); + +describe('Storage – runtime path methods use getRuntimeBaseDir', () => { + const originalEnv = process.env['QWEN_RUNTIME_DIR']; + + beforeEach(() => { + Storage.setRuntimeBaseDir(null); + delete process.env['QWEN_RUNTIME_DIR']; + }); + + afterEach(() => { + Storage.setRuntimeBaseDir(null); + if (originalEnv !== undefined) { + process.env['QWEN_RUNTIME_DIR'] = originalEnv; + } else { + delete process.env['QWEN_RUNTIME_DIR']; + } + }); + + it('getGlobalTempDir uses custom runtime base dir', () => { + const customDir = path.resolve('custom'); + Storage.setRuntimeBaseDir(customDir); + expect(Storage.getGlobalTempDir()).toBe(path.join(customDir, 'tmp')); + }); + + it('getGlobalDebugDir uses custom runtime base dir', () => { + const customDir = path.resolve('custom'); + Storage.setRuntimeBaseDir(customDir); + expect(Storage.getGlobalDebugDir()).toBe(path.join(customDir, 'debug')); + }); + + it('getDebugLogPath uses custom runtime base dir', () => { + const customDir = path.resolve('custom'); + Storage.setRuntimeBaseDir(customDir); + expect(Storage.getDebugLogPath('session-123')).toBe( + path.join(customDir, 'debug', 'session-123.txt'), + ); + }); + + it('getGlobalIdeDir uses custom runtime base dir', () => { + const customDir = path.resolve('custom'); + Storage.setRuntimeBaseDir(customDir); + expect(Storage.getGlobalIdeDir()).toBe(path.join(customDir, 'ide')); + }); + + it('getProjectDir uses custom runtime base dir', () => { + const customDir = path.resolve('custom'); + Storage.setRuntimeBaseDir(customDir); + const storage = new Storage('/tmp/project'); + expect(storage.getProjectDir()).toContain(path.join(customDir, 'projects')); + }); + + it('getHistoryDir uses custom runtime base dir', () => { + const customDir = path.resolve('custom'); + Storage.setRuntimeBaseDir(customDir); + const storage = new Storage('/tmp/project'); + expect(storage.getHistoryDir()).toContain(path.join(customDir, 'history')); + }); + + it('getProjectTempDir uses custom runtime base dir', () => { + const customDir = path.resolve('custom'); + Storage.setRuntimeBaseDir(customDir); + const storage = new Storage('/tmp/project'); + expect(storage.getProjectTempDir()).toContain(path.join(customDir, 'tmp')); + }); + + it('getProjectTempCheckpointsDir uses custom runtime base dir', () => { + const customDir = path.resolve('custom'); + Storage.setRuntimeBaseDir(customDir); + const storage = new Storage('/tmp/project'); + expect(storage.getProjectTempCheckpointsDir()).toContain( + path.join(customDir, 'tmp'), + ); + expect(storage.getProjectTempCheckpointsDir()).toMatch(/checkpoints$/); + }); + + it('getHistoryFilePath uses custom runtime base dir', () => { + const customDir = path.resolve('custom'); + Storage.setRuntimeBaseDir(customDir); + const storage = new Storage('/tmp/project'); + expect(storage.getHistoryFilePath()).toContain(path.join(customDir, 'tmp')); + expect(storage.getHistoryFilePath()).toMatch(/shell_history$/); + }); +}); + +describe('Storage – config paths remain at ~/.qwen regardless of runtime dir', () => { + const originalEnv = process.env['QWEN_RUNTIME_DIR']; + const globalQwenDir = Storage.getGlobalQwenDir(); + + beforeEach(() => { + Storage.setRuntimeBaseDir(path.resolve('custom-runtime')); + process.env['QWEN_RUNTIME_DIR'] = path.resolve('env-runtime'); + }); + + afterEach(() => { + Storage.setRuntimeBaseDir(null); + if (originalEnv !== undefined) { + process.env['QWEN_RUNTIME_DIR'] = originalEnv; + } else { + delete process.env['QWEN_RUNTIME_DIR']; + } + }); + + it('getGlobalSettingsPath still uses ~/.qwen', () => { + expect(Storage.getGlobalSettingsPath()).toBe( + path.join(globalQwenDir, 'settings.json'), + ); + }); + + it('getInstallationIdPath still uses ~/.qwen', () => { + expect(Storage.getInstallationIdPath()).toBe( + path.join(globalQwenDir, 'installation_id'), + ); + }); + + it('getGoogleAccountsPath still uses ~/.qwen', () => { + expect(Storage.getGoogleAccountsPath()).toBe( + path.join(globalQwenDir, 'google_accounts.json'), + ); + }); + + it('getMcpOAuthTokensPath still uses ~/.qwen', () => { + expect(Storage.getMcpOAuthTokensPath()).toBe( + path.join(globalQwenDir, 'mcp-oauth-tokens.json'), + ); + }); + + it('getOAuthCredsPath still uses ~/.qwen', () => { + expect(Storage.getOAuthCredsPath()).toBe( + path.join(globalQwenDir, 'oauth_creds.json'), + ); + }); + + it('getUserCommandsDir still uses ~/.qwen', () => { + expect(Storage.getUserCommandsDir()).toBe( + path.join(globalQwenDir, 'commands'), + ); + }); + + it('getGlobalMemoryFilePath still uses ~/.qwen', () => { + expect(Storage.getGlobalMemoryFilePath()).toBe( + path.join(globalQwenDir, 'memory.md'), + ); + }); + + it('getGlobalBinDir still uses ~/.qwen', () => { + expect(Storage.getGlobalBinDir()).toBe(path.join(globalQwenDir, 'bin')); + }); + + it('getUserSkillsDirs still includes ~/.qwen/skills', () => { + const storage = new Storage('/tmp/project'); + const skillsDirs = storage.getUserSkillsDirs(); + expect( + skillsDirs.some((dir) => dir === path.join(globalQwenDir, 'skills')), + ).toBe(true); + }); +}); + +describe('Storage – runtime base dir async context isolation', () => { + const originalEnv = process.env['QWEN_RUNTIME_DIR']; + + beforeEach(() => { + Storage.setRuntimeBaseDir(null); + delete process.env['QWEN_RUNTIME_DIR']; + }); + + afterEach(() => { + Storage.setRuntimeBaseDir(null); + if (originalEnv !== undefined) { + process.env['QWEN_RUNTIME_DIR'] = originalEnv; + } else { + delete process.env['QWEN_RUNTIME_DIR']; + } + }); + + it('uses contextual runtime dir inside runWithRuntimeBaseDir', async () => { + Storage.setRuntimeBaseDir(path.resolve('global-runtime')); + const cwd = path.resolve('workspace', 'project-a'); + + await Storage.runWithRuntimeBaseDir('.qwen', cwd, async () => { + expect(Storage.getRuntimeBaseDir()).toBe(path.join(cwd, '.qwen')); + }); + }); + + it('keeps concurrent contexts isolated', async () => { + const cwdA = path.resolve('workspace', 'a'); + const cwdB = path.resolve('workspace', 'b'); + + const runA = Storage.runWithRuntimeBaseDir('.qwen-a', cwdA, async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + return Storage.getRuntimeBaseDir(); + }); + + const runB = Storage.runWithRuntimeBaseDir('.qwen-b', cwdB, async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + return Storage.getRuntimeBaseDir(); + }); + + const [a, b] = await Promise.all([runA, runB]); + expect(a).toBe(path.join(cwdA, '.qwen-a')); + expect(b).toBe(path.join(cwdB, '.qwen-b')); + }); +}); diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index 211c499a9..e29cefa62 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -7,6 +7,7 @@ import * as path from 'node:path'; import * as os from 'node:os'; import * as fs from 'node:fs'; +import { AsyncLocalStorage } from 'node:async_hooks'; import { getProjectHash, sanitizeCwd } from '../utils/paths.js'; export const QWEN_DIR = '.qwen'; @@ -23,10 +24,99 @@ const ARENA_DIR_NAME = 'arena'; export class Storage { private readonly targetDir: string; + /** + * Custom runtime output base directory set via settings. + * When null, falls back to getGlobalQwenDir(). + */ + private static runtimeBaseDir: string | null = null; + private static readonly runtimeBaseDirContext = new AsyncLocalStorage< + string | null + >(); + constructor(targetDir: string) { this.targetDir = targetDir; } + private static resolveRuntimeBaseDir( + dir: string | null | undefined, + cwd?: string, + ): string | null { + if (!dir) { + return null; + } + + let resolved = dir; + if ( + resolved === '~' || + resolved.startsWith('~/') || + resolved.startsWith('~\\') + ) { + const relativeSegments = + resolved === '~' + ? [] + : resolved + .slice(2) + .split(/[/\\]+/) + .filter(Boolean); + resolved = path.join(os.homedir(), ...relativeSegments); + } + if (!path.isAbsolute(resolved)) { + resolved = cwd ? path.resolve(cwd, resolved) : path.resolve(resolved); + } + return resolved; + } + + /** + * Sets the custom runtime output base directory. + * Handles tilde (~) expansion and resolves relative paths to absolute. + * Pass null/undefined/empty string to reset to default (getGlobalQwenDir()). + * @param dir - The directory path, or null/undefined to reset + * @param cwd - Base directory for resolving relative paths (defaults to process.cwd()). + * Pass the project root so that relative values like ".qwen" resolve + * per-project, enabling a single global config to work across all projects. + */ + static setRuntimeBaseDir(dir: string | null | undefined, cwd?: string): void { + Storage.runtimeBaseDir = Storage.resolveRuntimeBaseDir(dir, cwd); + } + + /** + * Runs function execution in an async context with a specific runtime output dir. + * This is used to isolate runtime output paths between concurrent sessions. + */ + static runWithRuntimeBaseDir( + dir: string | null | undefined, + cwd: string | undefined, + fn: () => T, + ): T { + const resolved = Storage.resolveRuntimeBaseDir(dir, cwd); + return Storage.runtimeBaseDirContext.run(resolved, fn); + } + + /** + * Returns the base directory for all runtime output (temp files, debug logs, + * session data, todos, insights, etc.). + * + * Priority: QWEN_RUNTIME_DIR env var > setRuntimeBaseDir() value > getGlobalQwenDir() + * @returns Absolute path to the runtime output base directory + */ + static getRuntimeBaseDir(): string { + const envDir = process.env['QWEN_RUNTIME_DIR']; + if (envDir) { + return ( + Storage.resolveRuntimeBaseDir(envDir) ?? Storage.getGlobalQwenDir() + ); + } + + const contextualDir = Storage.runtimeBaseDirContext.getStore(); + if (contextualDir !== undefined) { + return contextualDir ?? Storage.getGlobalQwenDir(); + } + if (Storage.runtimeBaseDir) { + return Storage.runtimeBaseDir; + } + return Storage.getGlobalQwenDir(); + } + static getGlobalQwenDir(): string { const homeDir = os.homedir(); if (!homeDir) { @@ -60,11 +150,11 @@ export class Storage { } static getGlobalTempDir(): string { - return path.join(Storage.getGlobalQwenDir(), TMP_DIR_NAME); + return path.join(Storage.getRuntimeBaseDir(), TMP_DIR_NAME); } static getGlobalDebugDir(): string { - return path.join(Storage.getGlobalQwenDir(), DEBUG_DIR_NAME); + return path.join(Storage.getRuntimeBaseDir(), DEBUG_DIR_NAME); } static getDebugLogPath(sessionId: string): string { @@ -72,7 +162,7 @@ export class Storage { } static getGlobalIdeDir(): string { - return path.join(Storage.getGlobalQwenDir(), IDE_DIR_NAME); + return path.join(Storage.getRuntimeBaseDir(), IDE_DIR_NAME); } static getGlobalBinDir(): string { @@ -89,7 +179,10 @@ export class Storage { getProjectDir(): string { const projectId = sanitizeCwd(this.getProjectRoot()); - const projectsDir = path.join(Storage.getGlobalQwenDir(), PROJECT_DIR_NAME); + const projectsDir = path.join( + Storage.getRuntimeBaseDir(), + PROJECT_DIR_NAME, + ); return path.join(projectsDir, projectId); } @@ -114,7 +207,7 @@ export class Storage { getHistoryDir(): string { const hash = getProjectHash(this.getProjectRoot()); - const historyDir = path.join(Storage.getGlobalQwenDir(), 'history'); + const historyDir = path.join(Storage.getRuntimeBaseDir(), 'history'); const targetDir = path.join(historyDir, hash); return targetDir; } diff --git a/packages/core/src/tools/todoWrite.test.ts b/packages/core/src/tools/todoWrite.test.ts index cd21a55b7..cb096a60c 100644 --- a/packages/core/src/tools/todoWrite.test.ts +++ b/packages/core/src/tools/todoWrite.test.ts @@ -6,10 +6,12 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import type { TodoWriteParams, TodoItem } from './todoWrite.js'; -import { TodoWriteTool } from './todoWrite.js'; +import { TodoWriteTool, listTodoSessions } from './todoWrite.js'; import * as fs from 'fs/promises'; import * as fsSync from 'fs'; +import * as path from 'node:path'; import type { Config } from '../config/config.js'; +import { Storage } from '../config/storage.js'; // Mock fs modules vi.mock('fs/promises'); @@ -302,3 +304,120 @@ describe('TodoWriteTool', () => { }); }); }); + +describe('TodoWriteTool – runtime output directory', () => { + let tool: TodoWriteTool; + let mockAbortSignal: AbortSignal; + let mockConfig: Config; + const originalRuntimeEnv = process.env['QWEN_RUNTIME_DIR']; + + beforeEach(() => { + mockConfig = { + getSessionId: () => 'runtime-session', + } as Config; + tool = new TodoWriteTool(mockConfig); + mockAbortSignal = new AbortController().signal; + Storage.setRuntimeBaseDir(null); + delete process.env['QWEN_RUNTIME_DIR']; + vi.clearAllMocks(); + }); + + afterEach(() => { + Storage.setRuntimeBaseDir(null); + if (originalRuntimeEnv !== undefined) { + process.env['QWEN_RUNTIME_DIR'] = originalRuntimeEnv; + } else { + delete process.env['QWEN_RUNTIME_DIR']; + } + vi.restoreAllMocks(); + }); + + it('should write todos to custom runtime dir when setRuntimeBaseDir is set', async () => { + const customRuntimeDir = path.resolve('custom', 'runtime'); + Storage.setRuntimeBaseDir(customRuntimeDir); + + const params: TodoWriteParams = { + todos: [{ id: '1', content: 'Task 1', status: 'pending' }], + }; + + mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); + mockFs.mkdir.mockResolvedValue(undefined); + mockFs.writeFile.mockResolvedValue(undefined); + + const invocation = tool.build(params); + await invocation.execute(mockAbortSignal); + + // Verify the file path starts with the custom runtime dir + const writePath = mockFs.writeFile.mock.calls[0]?.[0] as string; + expect(writePath).toContain(path.join(customRuntimeDir, 'todos')); + expect(writePath).toContain('runtime-session.json'); + }); + + it('should write todos to env var dir when QWEN_RUNTIME_DIR is set', async () => { + const envRuntimeDir = path.resolve('env', 'runtime'); + process.env['QWEN_RUNTIME_DIR'] = envRuntimeDir; + + const params: TodoWriteParams = { + todos: [{ id: '1', content: 'Task 1', status: 'pending' }], + }; + + mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); + mockFs.mkdir.mockResolvedValue(undefined); + mockFs.writeFile.mockResolvedValue(undefined); + + const invocation = tool.build(params); + await invocation.execute(mockAbortSignal); + + const writePath = mockFs.writeFile.mock.calls[0]?.[0] as string; + expect(writePath).toContain(path.join(envRuntimeDir, 'todos')); + }); + + it('should use default ~/.qwen path when no custom dir is configured', async () => { + const params: TodoWriteParams = { + todos: [{ id: '1', content: 'Task 1', status: 'pending' }], + }; + + mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); + mockFs.mkdir.mockResolvedValue(undefined); + mockFs.writeFile.mockResolvedValue(undefined); + + const invocation = tool.build(params); + await invocation.execute(mockAbortSignal); + + const writePath = mockFs.writeFile.mock.calls[0]?.[0] as string; + expect(writePath).toContain(path.join('.qwen', 'todos')); + }); + + it('should check file existence in custom runtime dir for getDescription', () => { + const customRuntimeDir = path.resolve('custom', 'runtime'); + Storage.setRuntimeBaseDir(customRuntimeDir); + mockFsSync.existsSync.mockReturnValue(false); + + const params: TodoWriteParams = { + todos: [{ id: '1', content: 'Task', status: 'pending' }], + }; + const invocation = tool.build(params); + + // Verify existsSync was called with a path under the custom dir + const checkedPath = mockFsSync.existsSync.mock.calls[0]?.[0] as string; + expect(checkedPath).toContain(path.join(customRuntimeDir, 'todos')); + expect(invocation.getDescription()).toBe('Create todos'); + }); + + it('should list todo sessions from custom runtime dir', async () => { + const customRuntimeDir = path.resolve('custom', 'runtime'); + Storage.setRuntimeBaseDir(customRuntimeDir); + mockFs.readdir.mockResolvedValue([ + 'a.json', + 'b.json', + 'README.md', + ] as never); + + const sessions = await listTodoSessions(); + + expect(mockFs.readdir).toHaveBeenCalledWith( + path.join(customRuntimeDir, 'todos'), + ); + expect(sessions).toEqual(['a', 'b']); + }); +}); diff --git a/packages/core/src/tools/todoWrite.ts b/packages/core/src/tools/todoWrite.ts index 2cdbafb51..bd689316b 100644 --- a/packages/core/src/tools/todoWrite.ts +++ b/packages/core/src/tools/todoWrite.ts @@ -10,10 +10,9 @@ import type { FunctionDeclaration } from '@google/genai'; import * as fs from 'fs/promises'; import * as fsSync from 'fs'; import * as path from 'path'; -import * as process from 'process'; -import { QWEN_DIR } from '../utils/paths.js'; import type { Config } from '../config/config.js'; +import { Storage } from '../config/storage.js'; import { ToolDisplayNames, ToolNames } from './tool-names.js'; import { createDebugLogger } from '../utils/debugLogger.js'; @@ -247,9 +246,7 @@ When in doubt, use this tool. Being proactive with task management demonstrates const TODO_SUBDIR = 'todos'; function getTodoFilePath(sessionId?: string): string { - const homeDir = - process.env['HOME'] || process.env['USERPROFILE'] || process.cwd(); - const todoDir = path.join(homeDir, QWEN_DIR, TODO_SUBDIR); + const todoDir = path.join(Storage.getRuntimeBaseDir(), TODO_SUBDIR); // Use sessionId if provided, otherwise fall back to 'default' const filename = `${sessionId || 'default'}.json`; @@ -399,9 +396,7 @@ export async function readTodosForSession( */ export async function listTodoSessions(): Promise { try { - const homeDir = - process.env['HOME'] || process.env['USERPROFILE'] || process.cwd(); - const todoDir = path.join(homeDir, QWEN_DIR, TODO_SUBDIR); + const todoDir = path.join(Storage.getRuntimeBaseDir(), TODO_SUBDIR); const files = await fs.readdir(todoDir); return files .filter((file: string) => file.endsWith('.json')) diff --git a/packages/core/src/utils/debugLogger.test.ts b/packages/core/src/utils/debugLogger.test.ts index 8549359c0..e585dfdfa 100644 --- a/packages/core/src/utils/debugLogger.test.ts +++ b/packages/core/src/utils/debugLogger.test.ts @@ -40,6 +40,7 @@ describe('debugLogger', () => { beforeEach(() => { process.env['QWEN_DEBUG_LOG_FILE'] = '1'; + Storage.setRuntimeBaseDir(null); vi.clearAllMocks(); vi.useFakeTimers(); vi.setSystemTime(new Date('2026-01-24T10:30:00.000Z')); @@ -50,6 +51,7 @@ describe('debugLogger', () => { afterEach(() => { vi.useRealTimers(); setDebugLogSession(null); + Storage.setRuntimeBaseDir(null); if (previousDebugLogFileEnv === undefined) { delete process.env['QWEN_DEBUG_LOG_FILE']; } else { @@ -115,6 +117,27 @@ describe('debugLogger', () => { expect(calls[3]?.[1]).toContain('[ERROR]'); }); + it('creates a new debug directory after the runtime base dir changes', async () => { + Storage.setRuntimeBaseDir(path.resolve('runtime-a')); + const logger = createDebugLogger(); + logger.debug('first'); + await vi.runAllTimersAsync(); + + Storage.setRuntimeBaseDir(path.resolve('runtime-b')); + logger.debug('second'); + await vi.runAllTimersAsync(); + + const mkdirCalls = vi.mocked(fs.mkdir).mock.calls; + expect(mkdirCalls).toContainEqual([ + path.join(path.resolve('runtime-a'), 'debug'), + { recursive: true }, + ]); + expect(mkdirCalls).toContainEqual([ + path.join(path.resolve('runtime-b'), 'debug'), + { recursive: true }, + ]); + }); + it('formats multiple arguments', async () => { const logger = createDebugLogger(); logger.debug('Count:', 42, 'items'); diff --git a/packages/core/src/utils/debugLogger.ts b/packages/core/src/utils/debugLogger.ts index 356028a2f..bc92a2685 100644 --- a/packages/core/src/utils/debugLogger.ts +++ b/packages/core/src/utils/debugLogger.ts @@ -25,6 +25,7 @@ export interface DebugLogger { } let ensureDebugDirPromise: Promise | null = null; +let ensuredDebugDirPath: string | null = null; let hasWriteFailure = false; let globalSession: DebugLogSession | null = null; const sessionContext = new AsyncLocalStorage(); @@ -41,13 +42,16 @@ function getActiveSession(): DebugLogSession | null { } function ensureDebugDirExists(): Promise { - if (!ensureDebugDirPromise) { + const debugDirPath = Storage.getGlobalDebugDir(); + if (!ensureDebugDirPromise || ensuredDebugDirPath !== debugDirPath) { + ensuredDebugDirPath = debugDirPath; ensureDebugDirPromise = fs - .mkdir(Storage.getGlobalDebugDir(), { recursive: true }) + .mkdir(debugDirPath, { recursive: true }) .then(() => undefined) .catch(() => { hasWriteFailure = true; ensureDebugDirPromise = null; + ensuredDebugDirPath = null; }); } return ensureDebugDirPromise ?? Promise.resolve(); @@ -115,6 +119,7 @@ export function isDebugLoggingDegraded(): boolean { export function resetDebugLoggingState(): void { hasWriteFailure = false; ensureDebugDirPromise = null; + ensuredDebugDirPath = null; } const DEBUG_LATEST_ALIAS = 'latest'; diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 61734faaf..8e5725ae0 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -580,6 +580,10 @@ "type": "object", "additionalProperties": true }, + "runtimeOutputDir": { + "description": "Custom directory for runtime output (temp files, debug logs, session data, todos, etc.). Config files remain at ~/.qwen. Env var QWEN_RUNTIME_DIR takes priority.", + "type": "string" + }, "tavilyApiKey": { "description": "⚠️ DEPRECATED: Please use webSearch.provider configuration instead. Legacy API key for the Tavily API.", "type": "string" diff --git a/packages/vscode-ide-companion/src/services/acpConnection.test.ts b/packages/vscode-ide-companion/src/services/acpConnection.test.ts index 32977171a..376ee1d0a 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.test.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it, vi } from 'vitest'; import { RequestError } from '@agentclientprotocol/sdk'; +import type { ContentBlock } from '@agentclientprotocol/sdk'; // AcpConnection imports AcpFileHandler which imports vscode. // Mock vscode so it can be resolved without the actual VS Code runtime. @@ -14,11 +15,36 @@ vi.mock('vscode', () => ({})); import { AcpConnection } from './acpConnection.js'; import { ACP_ERROR_CODES } from '../constants/acpSchema.js'; +type AcpConnectionInternal = { + child: { killed: boolean; exitCode: number | null; kill?: () => void } | null; + sdkConnection: unknown; + sessionId: string | null; + lastExitCode: number | null; + lastExitSignal: string | null; + mapReadTextFileError: (error: unknown, filePath: string) => unknown; + ensureConnection: () => unknown; +}; + +function createConnection(overrides?: Partial) { + const conn = new AcpConnection() as unknown as AcpConnectionInternal; + if (overrides) { + Object.assign(conn, overrides); + } + return conn; +} + +function createMockChild(overrides?: Record) { + return { + killed: false, + exitCode: null, + kill: vi.fn(), + ...overrides, + } as unknown as AcpConnectionInternal['child']; +} + describe('AcpConnection readTextFile error mapping', () => { it('maps ENOENT to RESOURCE_NOT_FOUND RequestError', () => { - const conn = new AcpConnection() as unknown as { - mapReadTextFileError: (error: unknown, filePath: string) => unknown; - }; + const conn = createConnection(); const enoent = Object.assign(new Error('missing file'), { code: 'ENOENT' }); expect(() => @@ -31,9 +57,7 @@ describe('AcpConnection readTextFile error mapping', () => { }); it('keeps non-ENOENT RequestError unchanged', () => { - const conn = new AcpConnection() as unknown as { - mapReadTextFileError: (error: unknown, filePath: string) => unknown; - }; + const conn = createConnection(); const requestError = new RequestError( ACP_ERROR_CODES.INTERNAL_ERROR, 'Internal error', @@ -43,4 +67,159 @@ describe('AcpConnection readTextFile error mapping', () => { requestError, ); }); + + it('passes structured ACP prompt blocks through without wrapping them as text', async () => { + const prompt = vi.fn().mockResolvedValue({}); + const onEndTurn = vi.fn(); + const conn = new AcpConnection() as unknown as { + sdkConnection: { + prompt: (params: { + sessionId: string; + prompt: ContentBlock[]; + }) => Promise; + }; + sessionId: string | null; + onEndTurn: (reason?: string) => void; + sendPrompt: (prompt: string | ContentBlock[]) => Promise; + }; + const promptBlocks: ContentBlock[] = [ + { type: 'text', text: 'Inspect this image' }, + { + type: 'resource_link', + name: 'pasted image.png', + mimeType: 'image/png', + uri: 'file:///tmp/pasted image.png', + }, + ]; + + conn.sdkConnection = { prompt }; + conn.sessionId = 'session-1'; + conn.onEndTurn = onEndTurn; + + await conn.sendPrompt(promptBlocks); + + expect(prompt).toHaveBeenCalledWith({ + sessionId: 'session-1', + prompt: promptBlocks, + }); + expect(onEndTurn).toHaveBeenCalled(); + }); +}); + +describe('AcpConnection.isConnected', () => { + it('returns true when child is alive', () => { + const conn = createConnection({ + child: { killed: false, exitCode: null }, + }); + expect((conn as unknown as AcpConnection).isConnected).toBe(true); + }); + + it('returns false when child is null', () => { + const conn = createConnection({ child: null }); + expect((conn as unknown as AcpConnection).isConnected).toBe(false); + }); + + it('returns false when child was killed', () => { + const conn = createConnection({ + child: { killed: true, exitCode: null }, + }); + expect((conn as unknown as AcpConnection).isConnected).toBe(false); + }); + + it('returns false when child exited on its own (exitCode set)', () => { + // 143 = 128 + 15 (SIGTERM) + const conn = createConnection({ + child: { killed: false, exitCode: 143 }, + }); + expect((conn as unknown as AcpConnection).isConnected).toBe(false); + }); +}); + +describe('AcpConnection.ensureConnection', () => { + it('throws when sdkConnection is null', () => { + const conn = createConnection({ + sdkConnection: null, + child: { killed: false, exitCode: null }, + }); + expect(() => conn.ensureConnection()).toThrow('Not connected to ACP agent'); + }); + + it('throws when process has exited (exitCode set)', () => { + const conn = createConnection({ + sdkConnection: {}, + child: { killed: false, exitCode: 1 }, + }); + expect(() => conn.ensureConnection()).toThrow('Not connected to ACP agent'); + }); + + it('throws when child is null (process exited and cleaned up)', () => { + const conn = createConnection({ + sdkConnection: {}, + child: null, + }); + expect(() => conn.ensureConnection()).toThrow('Not connected to ACP agent'); + }); + + it('returns sdkConnection when process is alive', () => { + const fakeSdk = { send: vi.fn() }; + const conn = createConnection({ + sdkConnection: fakeSdk, + child: { killed: false, exitCode: null }, + }); + expect(conn.ensureConnection()).toBe(fakeSdk); + }); +}); + +describe('AcpConnection child exit cleanup', () => { + it('disconnect clears child, sdkConnection, and sessionId', () => { + const conn = createConnection({ + child: createMockChild(), + sdkConnection: {}, + sessionId: 'test-session', + }); + + const acpConn = conn as unknown as AcpConnection; + acpConn.disconnect(); + + expect(acpConn.isConnected).toBe(false); + expect(acpConn.hasActiveSession).toBe(false); + expect(acpConn.currentSessionId).toBeNull(); + }); + + it('disconnect calls kill on the child process', () => { + const mockKill = vi.fn(); + const conn = createConnection({ + child: createMockChild({ kill: mockKill }), + sdkConnection: {}, + sessionId: 'test-session', + }); + + (conn as unknown as AcpConnection).disconnect(); + expect(mockKill).toHaveBeenCalledOnce(); + }); +}); + +describe('AcpConnection onDisconnected callback', () => { + it('has a default no-op onDisconnected handler', () => { + const acpConn = new AcpConnection(); + expect(acpConn.onDisconnected).toBeTypeOf('function'); + expect(() => acpConn.onDisconnected(143, 'SIGTERM')).not.toThrow(); + }); + + it('allows setting a custom onDisconnected handler', () => { + const acpConn = new AcpConnection(); + const spy = vi.fn(); + acpConn.onDisconnected = spy; + + acpConn.onDisconnected(1, null); + expect(spy).toHaveBeenCalledWith(1, null); + }); +}); + +describe('AcpConnection lastExitCode/lastExitSignal', () => { + it('initializes exit info as null', () => { + const conn = createConnection(); + expect(conn.lastExitCode).toBeNull(); + expect(conn.lastExitSignal).toBeNull(); + }); }); diff --git a/packages/vscode-ide-companion/src/services/acpConnection.ts b/packages/vscode-ide-companion/src/services/acpConnection.ts index 68a01220e..95f50c373 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.ts @@ -13,6 +13,7 @@ import { import type { Client, Agent, + ContentBlock, SessionNotification, RequestPermissionRequest, RequestPermissionResponse, @@ -52,6 +53,8 @@ export class AcpConnection { private sessionId: string | null = null; private workingDir: string = process.cwd(); private fileHandler = new AcpFileHandler(); + private lastExitCode: number | null = null; + private lastExitSignal: string | null = null; onSessionUpdate: (data: SessionNotification) => void = () => {}; onPermissionRequest: (data: RequestPermissionRequest) => Promise<{ @@ -63,6 +66,9 @@ export class AcpConnection { onAuthenticateUpdate: (data: AuthenticateUpdateNotification) => void = () => {}; onEndTurn: (reason?: string) => void = () => {}; + /** Invoked when the child process exits (expected or unexpected). */ + onDisconnected: (code: number | null, signal: string | null) => void = + () => {}; onAskUserQuestion: (data: AskUserQuestionRequest) => Promise<{ optionId: string; answers?: Record; @@ -78,6 +84,8 @@ export class AcpConnection { this.disconnect(); } + this.lastExitCode = null; + this.lastExitSignal = null; this.workingDir = workingDir; const env = { ...process.env }; @@ -145,6 +153,14 @@ export class AcpConnection { console.error( `[ACP qwen] Process exited with code: ${code}, signal: ${signal}`, ); + this.lastExitCode = code; + this.lastExitSignal = signal; + if (this.child) { + this.sdkConnection = null; + this.sessionId = null; + this.child = null; + this.onDisconnected(code, signal); + } }); await new Promise((resolve) => setTimeout(resolve, 1000)); @@ -154,7 +170,11 @@ export class AcpConnection { } if (!this.child || this.child.killed) { - throw new Error(`Qwen ACP process failed to start`); + const code = this.lastExitCode ?? this.child?.exitCode ?? null; + const signal = this.lastExitSignal; + throw new Error( + `Qwen ACP process failed to start (exit code: ${code}, signal: ${signal})`, + ); } // Convert Node.js child process streams to Web Streams for SDK @@ -332,7 +352,9 @@ export class AcpConnection { } private ensureConnection(): ClientSideConnection { - if (!this.sdkConnection) { + // sdkConnection is cleared asynchronously by the exit handler; + // isConnected (via exitCode) catches the race window before the exit event fires. + if (!this.sdkConnection || !this.isConnected) { throw new Error('Not connected to ACP agent'); } return this.sdkConnection; @@ -408,14 +430,16 @@ export class AcpConnection { return response; } - async sendPrompt(prompt: string): Promise { + async sendPrompt(prompt: string | ContentBlock[]): Promise { const conn = this.ensureConnection(); if (!this.sessionId) { throw new Error('No active ACP session'); } + const promptBlocks = + typeof prompt === 'string' ? [{ type: 'text', text: prompt }] : prompt; const response: PromptResponse = await conn.prompt({ sessionId: this.sessionId, - prompt: [{ type: 'text', text: prompt }], + prompt: promptBlocks, }); // Emit end-of-turn from stopReason if (response.stopReason) { @@ -539,7 +563,9 @@ export class AcpConnection { } get isConnected(): boolean { - return this.child !== null && !this.child.killed; + return ( + this.child !== null && !this.child.killed && this.child.exitCode === null + ); } get hasActiveSession(): boolean { diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts index c5a0920d7..31da317df 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts @@ -7,6 +7,7 @@ import { AcpConnection } from './acpConnection.js'; import type { ModelInfo, AvailableCommand, + ContentBlock, RequestPermissionRequest, SessionNotification, } from '@agentclientprotocol/sdk'; @@ -351,7 +352,7 @@ export class QwenAgentManager { * * @param message - Message content */ - async sendMessage(message: string): Promise { + async sendMessage(message: string | ContentBlock[]): Promise { await this.connection.sendPrompt(message); } diff --git a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.test.ts b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.test.ts new file mode 100644 index 000000000..21f2f4b0f --- /dev/null +++ b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.test.ts @@ -0,0 +1,136 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('vscode', () => ({ + workspace: { + getConfiguration: vi.fn(), + }, +})); + +import { QwenConnectionHandler } from './qwenConnectionHandler.js'; +import type { AcpConnection } from './acpConnection.js'; + +describe('QwenConnectionHandler', () => { + let handler: QwenConnectionHandler; + let mockConnection: AcpConnection; + let mockGetConfiguration: ReturnType; + + beforeEach(async () => { + const vscode = await import('vscode'); + mockGetConfiguration = vscode.workspace.getConfiguration as ReturnType< + typeof vi.fn + >; + mockGetConfiguration.mockReset(); + + handler = new QwenConnectionHandler(); + mockConnection = { + connect: vi.fn().mockResolvedValue(undefined), + newSession: vi.fn().mockResolvedValue({ sessionId: 'test-session' }), + authenticate: vi.fn().mockResolvedValue({}), + } as unknown as AcpConnection; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('proxy configuration', () => { + it('passes --proxy argument when http.proxy is set', async () => { + mockGetConfiguration.mockReturnValue({ + get: (key: string) => { + if (key === 'proxy') { + return 'http://proxy.example.com:8080'; + } + return undefined; + }, + }); + + await handler.connect(mockConnection, '/workspace', '/path/to/cli.js'); + + expect(mockConnection.connect).toHaveBeenCalled(); + const connectArgs = (mockConnection.connect as ReturnType) + .mock.calls[0]; + expect(connectArgs[2]).toContain('--proxy'); + expect(connectArgs[2]).toContain('http://proxy.example.com:8080'); + }); + + it('passes --proxy argument when https.proxy is set (fallback)', async () => { + mockGetConfiguration.mockReturnValue({ + get: (key: string) => { + if (key === 'proxy') { + return undefined; + } + if (key === 'https.proxy') { + return 'http://https-proxy.example.com:8080'; + } + return undefined; + }, + }); + + await handler.connect(mockConnection, '/workspace', '/path/to/cli.js'); + + expect(mockConnection.connect).toHaveBeenCalled(); + const connectArgs = (mockConnection.connect as ReturnType) + .mock.calls[0]; + expect(connectArgs[2]).toContain('--proxy'); + expect(connectArgs[2]).toContain('http://https-proxy.example.com:8080'); + }); + + it('prefers http.proxy over https.proxy', async () => { + mockGetConfiguration.mockReturnValue({ + get: (key: string) => { + if (key === 'proxy') { + return 'http://http-proxy.example.com:8080'; + } + if (key === 'https.proxy') { + return 'http://https-proxy.example.com:8080'; + } + return undefined; + }, + }); + + await handler.connect(mockConnection, '/workspace', '/path/to/cli.js'); + + const connectArgs = (mockConnection.connect as ReturnType) + .mock.calls[0]; + expect(connectArgs[2]).toContain('http://http-proxy.example.com:8080'); + expect(connectArgs[2]).not.toContain( + 'http://https-proxy.example.com:8080', + ); + }); + + it('does not pass --proxy argument when no proxy is configured', async () => { + mockGetConfiguration.mockReturnValue({ + get: () => undefined, + }); + + await handler.connect(mockConnection, '/workspace', '/path/to/cli.js'); + + const connectArgs = (mockConnection.connect as ReturnType) + .mock.calls[0]; + expect(connectArgs[2]).not.toContain('--proxy'); + }); + + it('does not pass --proxy argument when proxy is empty string', async () => { + mockGetConfiguration.mockReturnValue({ + get: (key: string) => { + if (key === 'proxy') { + return ''; + } + return undefined; + }, + }); + + await handler.connect(mockConnection, '/workspace', '/path/to/cli.js'); + + const connectArgs = (mockConnection.connect as ReturnType) + .mock.calls[0]; + expect(connectArgs[2]).not.toContain('--proxy'); + }); + }); +}); diff --git a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts index 60c0b3ac5..6ba990317 100644 --- a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts +++ b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts @@ -10,6 +10,7 @@ * Handles Qwen Agent connection establishment, authentication, and session creation */ +import * as vscode from 'vscode'; import type { AcpConnection } from './acpConnection.js'; import { isAuthenticationRequiredError } from '../utils/authErrors.js'; import { authMethod } from '../types/acpTypes.js'; @@ -73,6 +74,16 @@ export class QwenConnectionHandler { // Build extra CLI arguments (only essential parameters) const extraArgs: string[] = []; + const httpConfig = vscode.workspace.getConfiguration('http'); + const proxyUrl = + httpConfig.get('proxy') || httpConfig.get('https.proxy'); + if (proxyUrl) { + extraArgs.push('--proxy', proxyUrl); + console.log( + '[QwenAgentManager] Using proxy from VSCode settings:', + proxyUrl, + ); + } await connection.connect(cliEntryPath!, workingDir, extraArgs); diff --git a/packages/vscode-ide-companion/src/utils/imageSupport.ts b/packages/vscode-ide-companion/src/utils/imageSupport.ts new file mode 100644 index 000000000..97ca54a42 --- /dev/null +++ b/packages/vscode-ide-companion/src/utils/imageSupport.ts @@ -0,0 +1,161 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { isSupportedImageMimeType } from '@qwen-code/qwen-code-core/src/utils/request-tokenizer/supportedImageFormats.js'; + +// ---------- Types ---------- + +export interface ImageAttachment { + id: string; + name: string; + type: string; + size: number; + data: string; + timestamp: number; +} + +export interface SavedImageAttachment { + path: string; + name: string; + mimeType: string; +} + +// ---------- Constants ---------- + +export const MAX_IMAGE_SIZE = 10 * 1024 * 1024; +export const MAX_TOTAL_IMAGE_SIZE = 20 * 1024 * 1024; + +// ---------- Path escaping ---------- + +export const SHELL_SPECIAL_CHARS = /[ \t()[\]{};|*?$`'"#&<>!~]/; + +export function escapePath(filePath: string): string { + let result = ''; + for (let i = 0; i < filePath.length; i += 1) { + const char = filePath[i]; + + let backslashCount = 0; + for (let j = i - 1; j >= 0 && filePath[j] === '\\'; j -= 1) { + backslashCount += 1; + } + + const isAlreadyEscaped = backslashCount % 2 === 1; + + if (!isAlreadyEscaped && SHELL_SPECIAL_CHARS.test(char)) { + result += `\\${char}`; + } else { + result += char; + } + } + + return result; +} + +export function unescapePath(filePath: string): string { + return filePath.replace( + new RegExp(`\\\\([${SHELL_SPECIAL_CHARS.source.slice(1, -1)}])`, 'g'), + '$1', + ); +} + +// ---------- Image format detection ---------- + +const PASTED_IMAGE_MIME_TO_EXTENSION: Record = { + 'image/bmp': '.bmp', + 'image/heic': '.heic', + 'image/jpeg': '.jpg', + 'image/jpg': '.jpg', + 'image/png': '.png', + 'image/tiff': '.tiff', + 'image/webp': '.webp', +}; + +const DISPLAYABLE_IMAGE_EXTENSION_TO_MIME: Record = { + '.bmp': 'image/bmp', + '.gif': 'image/gif', + '.heic': 'image/heic', + '.heif': 'image/heif', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.png': 'image/png', + '.tiff': 'image/tiff', + '.webp': 'image/webp', +}; + +export function isSupportedPastedImageMimeType(mimeType: string): boolean { + return isSupportedImageMimeType(mimeType); +} + +export function getImageExtensionForMimeType(mimeType: string): string { + return PASTED_IMAGE_MIME_TO_EXTENSION[mimeType] ?? '.png'; +} + +export function getDisplayableImageMimeType( + filePath: string, +): string | undefined { + const lowerPath = filePath.toLowerCase(); + const extensionIndex = lowerPath.lastIndexOf('.'); + if (extensionIndex === -1) { + return undefined; + } + + return DISPLAYABLE_IMAGE_EXTENSION_TO_MIME[lowerPath.slice(extensionIndex)]; +} + +export function isDisplayableImagePath(filePath: string): boolean { + return getDisplayableImageMimeType(filePath) !== undefined; +} + +// ---------- Attachment validation ---------- + +function extractBase64Payload(data: string): string | null { + const dataUrlMatch = data.match(/^data:[^;]+;base64,(.+)$/); + const payload = dataUrlMatch ? dataUrlMatch[1] : data; + const normalized = payload.trim(); + + if (!normalized || /[^A-Za-z0-9+/=]/.test(normalized)) { + return null; + } + + return normalized; +} + +function getDecodedByteSize(base64Payload: string): number { + const padding = base64Payload.endsWith('==') + ? 2 + : base64Payload.endsWith('=') + ? 1 + : 0; + return Math.floor((base64Payload.length * 3) / 4) - padding; +} + +export function normalizeImageAttachment( + attachment: ImageAttachment, + options?: { + maxBytes?: number; + }, +): ImageAttachment | null { + if (!isSupportedPastedImageMimeType(attachment.type)) { + return null; + } + + const payload = extractBase64Payload(attachment.data); + if (!payload) { + return null; + } + + const byteSize = getDecodedByteSize(payload); + const maxBytes = options?.maxBytes ?? MAX_IMAGE_SIZE; + if (byteSize <= 0 || byteSize > maxBytes) { + return null; + } + + return { + ...attachment, + size: byteSize, + data: payload, + }; +} diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index c569c1557..0a76ab5b8 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -18,7 +18,10 @@ import { useFileContext } from './hooks/file/useFileContext.js'; import { useMessageHandling } from './hooks/message/useMessageHandling.js'; import { useToolCalls } from './hooks/useToolCalls.js'; import { useWebViewMessages } from './hooks/useWebViewMessages.js'; -import { useMessageSubmit } from './hooks/useMessageSubmit.js'; +import { + shouldSendMessage, + useMessageSubmit, +} from './hooks/useMessageSubmit.js'; import type { PermissionOption, PermissionToolCall } from '@qwen-code/webui'; import type { TextMessage } from './hooks/message/useMessageHandling.js'; import type { ToolCallData } from './components/messages/toolcalls/ToolCall.js'; @@ -35,6 +38,9 @@ import { InterruptedMessage, FileIcon, PermissionDrawer, + AskUserQuestionDialog, + ImageMessageRenderer, + ImagePreview, // Layout components imported directly from webui EmptyState, ChatHeader, @@ -50,7 +56,7 @@ import { DEFAULT_TOKEN_LIMIT, tokenLimit, } from '@qwen-code/qwen-code-core/src/core/tokenLimits.js'; -import { AskUserQuestionDialog } from '@qwen-code/webui'; +import { useImagePaste, type WebViewImageMessage } from './hooks/useImage.js'; export const App: React.FC = () => { const vscode = useVSCode(); @@ -89,16 +95,10 @@ export const App: React.FC = () => { >([]); const [availableModels, setAvailableModels] = useState([]); const [showModelSelector, setShowModelSelector] = useState(false); - const messagesEndRef = useRef( - null, - ) as React.RefObject; + const messagesEndRef = useRef(null); // Scroll container for message list; used to keep the view anchored to the latest content - const messagesContainerRef = useRef( - null, - ) as React.RefObject; - const inputFieldRef = useRef( - null, - ) as React.RefObject; + const messagesContainerRef = useRef(null); + const inputFieldRef = useRef(null); const [editMode, setEditMode] = useState( ApprovalMode.DEFAULT, @@ -284,10 +284,18 @@ export const App: React.FC = () => { completion.query, ]); - // Message submission + const { attachedImages, handleRemoveImage, clearImages, handlePaste } = + useImagePaste({ + onError: (error) => { + console.error('Paste error:', error); + }, + }); + const { handleSubmit: submitMessage } = useMessageSubmit({ inputText, setInputText, + attachedImages, + clearImages, messageHandling, fileContext, skipAutoActiveContext, @@ -297,6 +305,13 @@ export const App: React.FC = () => { isWaitingForResponse: messageHandling.isWaitingForResponse, }); + const canSubmit = shouldSendMessage({ + inputText, + attachedImages, + isStreaming: messageHandling.isStreaming, + isWaitingForResponse: messageHandling.isWaitingForResponse, + }); + // Handle cancel/stop from the input bar // Emit a cancel to the extension and immediately reflect interruption locally. const handleCancel = useCallback(() => { @@ -813,76 +828,86 @@ export const App: React.FC = () => { console.log('[App] Rendering messages:', allMessages); // Render all messages and tool calls - const renderMessages = useCallback<() => React.ReactNode>( - () => - allMessages.map((item, index) => { - switch (item.type) { - case 'message': { - const msg = item.data as TextMessage; - const handleFileClick = (path: string): void => { - vscode.postMessage({ - type: 'openFile', - data: { path }, - }); - }; + const renderMessages = useCallback<() => React.ReactNode>(() => { + let imageIndex = 0; + return allMessages.map((item, index) => { + switch (item.type) { + case 'message': { + const msg = item.data as TextMessage; + const handleFileClick = (path: string): void => { + vscode.postMessage({ + type: 'openFile', + data: { path }, + }); + }; - if (msg.role === 'thinking') { - return ( - - ); - } - - if (msg.role === 'user') { - return ( - - ); - } - - { - const content = (msg.content || '').trim(); - if (content === 'Interrupted' || content === 'Tool interrupted') { - return ( - - ); - } - return ( - - ); - } - } - - case 'in-progress-tool-call': - case 'completed-tool-call': { + if (msg.kind === 'image' && msg.imagePath) { + imageIndex += 1; return ( - ); } - default: - return null; + if (msg.role === 'thinking') { + return ( + + ); + } + + if (msg.role === 'user') { + return ( + + ); + } + + { + const content = (msg.content || '').trim(); + if (content === 'Interrupted' || content === 'Tool interrupted') { + return ( + + ); + } + return ( + + ); + } } - }), - [allMessages, vscode], - ); + + case 'in-progress-tool-call': + case 'completed-tool-call': { + return ( + + ); + } + + default: + return null; + } + }); + }, [allMessages, vscode]); const hasContent = messageHandling.messages.length > 0 || @@ -1027,11 +1052,21 @@ export const App: React.FC = () => { } }} onAttachContext={handleAttachContextClick} + onPaste={handlePaste} completionIsOpen={completion.isOpen} completionItems={completion.items} onCompletionSelect={handleCompletionSelect} onCompletionFill={(item) => handleCompletionSelect(item, true)} onCompletionClose={completion.closeCompletion} + canSubmit={canSubmit} + extraContent={ + attachedImages.length > 0 ? ( + + ) : null + } showModelSelector={showModelSelector} availableModels={availableModels} currentModelId={modelInfo?.modelId} diff --git a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx index 809f80dbc..e162a7831 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx @@ -7,7 +7,7 @@ * This allows local ApprovalModeValue to work with webui's EditModeInfo */ -import type { FC } from 'react'; +import type { ClipboardEvent, FC, ReactNode } from 'react'; import { InputForm as BaseInputForm, getEditModeIcon } from '@qwen-code/webui'; import type { InputFormProps as BaseInputFormProps, @@ -26,6 +26,10 @@ export interface InputFormProps extends Omit { /** Edit mode value (local type) */ editMode: ApprovalModeValue; + /** Optional paste handler forwarded to the base input */ + onPaste?: (e: ClipboardEvent) => void; + /** Optional content rendered between the input and actions */ + extraContent?: ReactNode; /** Completion fill callback (Tab or equivalent) */ onCompletionFill?: (item: CompletionItem) => void; /** Whether to show model selector */ diff --git a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts new file mode 100644 index 000000000..32484c943 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts @@ -0,0 +1,164 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockProcessImageAttachments, mockShowErrorMessage } = vi.hoisted( + () => ({ + mockProcessImageAttachments: vi.fn(), + mockShowErrorMessage: vi.fn(), + }), +); + +vi.mock('vscode', () => ({ + window: { + showWarningMessage: vi.fn(), + showErrorMessage: mockShowErrorMessage, + }, + commands: { + executeCommand: vi.fn(), + }, + workspace: { + workspaceFolders: [{ uri: { fsPath: '/workspace' } }], + }, +})); + +vi.mock('../utils/imageHandler.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + processImageAttachments: mockProcessImageAttachments, + }; +}); + +import { SessionMessageHandler } from './SessionMessageHandler.js'; + +describe('SessionMessageHandler', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockProcessImageAttachments.mockResolvedValue({ + formattedText: '', + displayText: '', + savedImageCount: 0, + promptImages: [], + }); + }); + + it('does not create conversation state or send an empty prompt when all pasted images fail to materialize', async () => { + const agentManager = { + isConnected: true, + currentSessionId: 'session-1', + sendMessage: vi.fn(), + }; + const conversationStore = { + createConversation: vi.fn().mockResolvedValue({ id: 'conversation-1' }), + getConversation: vi.fn().mockResolvedValue(null), + addMessage: vi.fn(), + }; + const sendToWebView = vi.fn(); + + const handler = new SessionMessageHandler( + agentManager as never, + conversationStore as never, + null, + sendToWebView, + ); + + await handler.handle({ + type: 'sendMessage', + data: { + text: '', + attachments: [ + { + id: 'img-1', + name: 'pasted.png', + type: 'image/png', + size: 3, + data: 'data:image/png;base64,YWJj', + timestamp: Date.now(), + }, + ], + }, + }); + + expect(conversationStore.createConversation).not.toHaveBeenCalled(); + expect(conversationStore.addMessage).not.toHaveBeenCalled(); + expect(agentManager.sendMessage).not.toHaveBeenCalled(); + expect(sendToWebView).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + data: expect.objectContaining({ + message: expect.stringContaining('image'), + }), + }), + ); + }); + + it('sends formatted prompt text so session restore can reconstruct pasted images', async () => { + mockProcessImageAttachments.mockResolvedValue({ + formattedText: '这是什么内容\n\n@/tmp/clipboard/clipboard-123.png', + displayText: '这是什么内容\n\n@/tmp/clipboard/clipboard-123.png', + savedImageCount: 1, + promptImages: [ + { + path: '/tmp/clipboard/clipboard-123.png', + name: 'clipboard-123.png', + mimeType: 'image/png', + }, + ], + }); + + const agentManager = { + isConnected: true, + currentSessionId: 'session-1', + sendMessage: vi.fn().mockResolvedValue(undefined), + }; + const conversationStore = { + createConversation: vi.fn().mockResolvedValue({ id: 'conversation-1' }), + getConversation: vi.fn().mockResolvedValue(null), + addMessage: vi.fn(), + }; + const sendToWebView = vi.fn(); + + const handler = new SessionMessageHandler( + agentManager as never, + conversationStore as never, + null, + sendToWebView, + ); + + await handler.handle({ + type: 'sendMessage', + data: { + text: '这是什么内容', + attachments: [ + { + id: 'img-1', + name: 'clipboard-123.png', + type: 'image/png', + size: 3, + data: 'data:image/png;base64,YWJj', + timestamp: Date.now(), + }, + ], + }, + }); + + expect(agentManager.sendMessage).toHaveBeenCalledWith([ + { + type: 'text', + text: '这是什么内容\n\n@/tmp/clipboard/clipboard-123.png', + }, + { + type: 'resource_link', + name: 'clipboard-123.png', + mimeType: 'image/png', + uri: 'file:///tmp/clipboard/clipboard-123.png', + }, + ]); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts index e03a0e28d..2ee1e6dd8 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts @@ -7,7 +7,12 @@ import * as vscode from 'vscode'; import { BaseMessageHandler } from './BaseMessageHandler.js'; import type { ChatMessage } from '../../services/qwenAgentManager.js'; +import type { ImageAttachment } from '../../utils/imageSupport.js'; import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js'; +import { + processImageAttachments, + buildPromptBlocks, +} from '../utils/imageHandler.js'; import { isAuthenticationRequiredError } from '../../utils/authErrors.js'; import { getErrorMessage } from '../../utils/errorMessage.js'; @@ -67,6 +72,7 @@ export class SessionMessageHandler extends BaseMessageHandler { endLine?: number; } | undefined, + data?.attachments as ImageAttachment[] | undefined, ); break; @@ -280,20 +286,21 @@ export class SessionMessageHandler extends BaseMessageHandler { startLine?: number; endLine?: number; }, + attachments?: ImageAttachment[], ): Promise { console.log('[SessionMessageHandler] handleSendMessage called with:', text); - // Guard: do not process empty or whitespace-only messages. // This prevents ghost user-message bubbles when slash-command completions // or model-selector interactions clear the input but still trigger a submit. const trimmedText = text.replace(/\u200B/g, '').trim(); - if (!trimmedText) { + const hasAttachments = (attachments?.length ?? 0) > 0; + if (!trimmedText && !hasAttachments) { console.warn('[SessionMessageHandler] Ignoring empty message'); return; } - // Format message with file context if present - let formattedText = text; + let displayText = trimmedText ? text : ''; + let promptText = text; if (context && context.length > 0) { const contextParts = context .map((ctx) => { @@ -304,7 +311,28 @@ export class SessionMessageHandler extends BaseMessageHandler { }) .join('\n'); - formattedText = `${contextParts}\n\n${text}`; + promptText = `${contextParts}\n\n${text}`; + } + + const { + formattedText, + displayText: updatedDisplayText, + savedImageCount, + promptImages, + } = await processImageAttachments(promptText, attachments); + promptText = formattedText; + displayText = updatedDisplayText; + + if (hasAttachments && !trimmedText && savedImageCount === 0) { + const errorMsg = + 'Failed to attach the pasted image. Nothing was sent. Please paste the image again.'; + console.warn('[SessionMessageHandler]', errorMsg); + vscode.window.showErrorMessage(errorMsg); + this.sendToWebView({ + type: 'error', + data: { message: errorMsg }, + }); + return; } // Ensure we have an active conversation @@ -359,7 +387,8 @@ export class SessionMessageHandler extends BaseMessageHandler { // Generate title for first message, but only if it hasn't been set yet if (isFirstMessage && !this.isTitleSet) { - const title = text.substring(0, 50) + (text.length > 50 ? '...' : ''); + const title = + displayText.substring(0, 50) + (displayText.length > 50 ? '...' : ''); this.sendToWebView({ type: 'sessionTitleUpdated', data: { @@ -373,7 +402,7 @@ export class SessionMessageHandler extends BaseMessageHandler { // Save user message const userMessage: ChatMessage = { role: 'user', - content: text, + content: displayText, timestamp: Date.now(), }; @@ -382,7 +411,6 @@ export class SessionMessageHandler extends BaseMessageHandler { userMessage, ); - // Send to WebView this.sendToWebView({ type: 'message', data: { ...userMessage, fileContext }, @@ -445,7 +473,9 @@ export class SessionMessageHandler extends BaseMessageHandler { }, }); - await this.agentManager.sendMessage(formattedText); + await this.agentManager.sendMessage( + buildPromptBlocks(promptText, promptImages), + ); // Save assistant message if (this.currentStreamContent && this.currentConversationId) { diff --git a/packages/vscode-ide-companion/src/webview/hooks/message/useMessageHandling.ts b/packages/vscode-ide-companion/src/webview/hooks/message/useMessageHandling.ts index 24c3ce561..ff6af0125 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/message/useMessageHandling.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/message/useMessageHandling.ts @@ -10,6 +10,10 @@ export interface TextMessage { role: 'user' | 'assistant' | 'thinking'; content: string; timestamp: number; + kind?: 'image'; + imagePath?: string; + imageSrc?: string; + imageMissing?: boolean; fileContext?: { fileName: string; filePath: string; diff --git a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts index 6fad7cba5..bfa85625b 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts @@ -21,7 +21,7 @@ interface CompletionTriggerState { * Based on vscode-copilot-chat's AttachContextAction */ export function useCompletionTrigger( - inputRef: RefObject, + inputRef: RefObject, getCompletionItems: ( trigger: '@' | '/', query: string, diff --git a/packages/vscode-ide-companion/src/webview/hooks/useImage.test.ts b/packages/vscode-ide-companion/src/webview/hooks/useImage.test.ts new file mode 100644 index 000000000..9099c4086 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/hooks/useImage.test.ts @@ -0,0 +1,44 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { build } from 'esbuild'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { escapePath } from '../../utils/imageSupport.js'; +import { splitMessageContentForImages } from './useImage.js'; + +describe('splitMessageContentForImages', () => { + it('restores escaped image paths with spaces back to their original file path', () => { + const imagePath = '/tmp/My Images/pasted image.png'; + const escapedImageReference = `@${escapePath(imagePath)}`; + + const result = splitMessageContentForImages( + `Please inspect this screenshot.\n\n${escapedImageReference}`, + ); + + expect(result.text).toBe('Please inspect this screenshot.'); + expect(result.imagePaths).toEqual([imagePath]); + }); +}); + +describe('useImage browser bundle', () => { + it('bundles without resolving node-only qwen-code-core modules', async () => { + const entryPoint = fileURLToPath(new URL('./useImage.ts', import.meta.url)); + + await expect( + build({ + entryPoints: [entryPoint], + bundle: true, + format: 'esm', + logLevel: 'silent', + platform: 'browser', + write: false, + }), + ).resolves.toMatchObject({ + outputFiles: expect.any(Array), + }); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/hooks/useImage.ts b/packages/vscode-ide-companion/src/webview/hooks/useImage.ts new file mode 100644 index 000000000..61991f3cb --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/hooks/useImage.ts @@ -0,0 +1,501 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useCallback, useRef, useState } from 'react'; +import type { ImageAttachment } from '../../utils/imageSupport.js'; +import { + MAX_IMAGE_SIZE, + MAX_TOTAL_IMAGE_SIZE, + isDisplayableImagePath, + isSupportedPastedImageMimeType, + getImageExtensionForMimeType, + unescapePath, +} from '../../utils/imageSupport.js'; + +export type { ImageAttachment }; + +// ======================== Message Types ======================== + +export interface WebViewMessageBase { + role: 'user' | 'assistant' | 'thinking'; + content: string; + timestamp: number; + fileContext?: { + fileName: string; + filePath: string; + startLine?: number; + endLine?: number; + }; +} + +export interface WebViewImageMessage extends WebViewMessageBase { + kind: 'image'; + imagePath: string; + imageSrc?: string; + imageMissing?: boolean; +} + +export type WebViewMessage = WebViewMessageBase | WebViewImageMessage; + +// ======================== Message Parsing ======================== + +interface ParsedImageReference { + imagePath: string; + start: number; + end: number; +} + +function normalizeWhitespace(value: string): string { + return value + .replace(/[ \t]+/g, ' ') + .replace(/ ?\n ?/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +export function splitMessageContentForImages(content: string): { + text: string; + imagePaths: string[]; +} { + if (!content) { + return { text: '', imagePaths: [] }; + } + + const imageReferences = parseImageReferences(content); + + if (imageReferences.length === 0) { + return { text: content, imagePaths: [] }; + } + + let cleanedContent = ''; + let lastIndex = 0; + + for (const reference of imageReferences) { + cleanedContent += content.slice(lastIndex, reference.start); + lastIndex = reference.end; + } + + cleanedContent += content.slice(lastIndex); + + const cleaned = normalizeWhitespace(cleanedContent); + const imagePaths = imageReferences.map((reference) => reference.imagePath); + + return { text: cleaned, imagePaths }; +} + +function parseImageReferences(content: string): ParsedImageReference[] { + const references: ParsedImageReference[] = []; + let currentIndex = 0; + + while (currentIndex < content.length) { + let atIndex = -1; + let nextSearchIndex = currentIndex; + + while (nextSearchIndex < content.length) { + if ( + content[nextSearchIndex] === '@' && + (nextSearchIndex === 0 || content[nextSearchIndex - 1] !== '\\') + ) { + atIndex = nextSearchIndex; + break; + } + nextSearchIndex += 1; + } + + if (atIndex === -1) { + break; + } + + let pathEndIndex = atIndex + 1; + let inEscape = false; + + while (pathEndIndex < content.length) { + const char = content[pathEndIndex]; + + if (inEscape) { + inEscape = false; + } else if (char === '\\') { + inEscape = true; + } else if (/[,\s;!?()[\]{}]/.test(char)) { + break; + } else if (char === '.') { + const nextChar = + pathEndIndex + 1 < content.length ? content[pathEndIndex + 1] : ''; + if (nextChar === '' || /\s/.test(nextChar)) { + break; + } + } + + pathEndIndex += 1; + } + + const rawReference = content.slice(atIndex, pathEndIndex); + const unescapedReference = unescapePath(rawReference); + const imagePath = unescapedReference.startsWith('@') + ? unescapedReference.slice(1) + : unescapedReference; + + if (isDisplayableImagePath(imagePath)) { + references.push({ + imagePath, + start: atIndex, + end: pathEndIndex, + }); + } + + currentIndex = pathEndIndex; + } + + return references; +} + +export function expandUserMessageWithImages(message: WebViewMessageBase): { + messages: WebViewMessage[]; + imagePaths: string[]; +} { + const { text, imagePaths } = splitMessageContentForImages(message.content); + if (imagePaths.length === 0) { + return { messages: [message], imagePaths: [] }; + } + + const expanded: WebViewMessage[] = imagePaths.map((imagePath) => ({ + role: 'user', + content: '', + timestamp: message.timestamp, + kind: 'image', + imagePath, + })); + + if (text) { + expanded.push({ + ...message, + content: text, + }); + } + + return { messages: expanded, imagePaths }; +} + +export function applyImageResolution( + messages: WebViewMessage[], + resolutions: Map, +): WebViewMessage[] { + if (messages.length === 0 || resolutions.size === 0) { + return messages; + } + + let changed = false; + const next = messages.map((message) => { + if (!('kind' in message) || message.kind !== 'image') { + return message; + } + + const resolved = resolutions.get(message.imagePath); + if (resolved === undefined) { + return message; + } + + const imageMissing = resolved === null; + const imageSrc = resolved ?? undefined; + if ( + message.imageSrc === imageSrc && + message.imageMissing === imageMissing + ) { + return message; + } + + changed = true; + return { + ...message, + imageSrc, + imageMissing, + }; + }); + + return changed ? next : messages; +} + +// ======================== useImagePaste ======================== + +async function fileToBase64(file: File | Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(file); + }); +} + +function isSupportedImage(file: File): boolean { + return isSupportedPastedImageMimeType(file.type); +} + +function isWithinSizeLimit(file: File): boolean { + return file.size <= MAX_IMAGE_SIZE; +} + +function formatFileSize(bytes: number): string { + if (bytes === 0) { + return '0 B'; + } + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +async function createImageAttachment( + file: File, +): Promise { + if (!isSupportedImage(file)) { + return null; + } + + if (!isWithinSizeLimit(file)) { + return null; + } + + try { + const base64Data = await fileToBase64(file); + return { + id: `img_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + name: file.name || `image_${Date.now()}`, + type: file.type, + size: file.size, + data: base64Data, + timestamp: Date.now(), + }; + } catch { + return null; + } +} + +function generatePastedImageName(mimeType: string): string { + const now = new Date(); + const timeStr = `${now.getHours().toString().padStart(2, '0')}${now + .getMinutes() + .toString() + .padStart(2, '0')}${now.getSeconds().toString().padStart(2, '0')}`; + return `pasted_image_${timeStr}${getImageExtensionForMimeType(mimeType)}`; +} + +export function useImagePaste({ + onError, +}: { onError?: (error: string) => void } = {}) { + const [attachedImages, setAttachedImages] = useState([]); + const processingRef = useRef(false); + + const handleRemoveImage = useCallback((imageId: string) => { + setAttachedImages((prev) => prev.filter((img) => img.id !== imageId)); + }, []); + + const clearImages = useCallback(() => { + setAttachedImages([]); + }, []); + + const handlePaste = useCallback( + async (event: React.ClipboardEvent | ClipboardEvent) => { + if (processingRef.current) { + return; + } + + const clipboardData = event.clipboardData; + if (!clipboardData?.files?.length) { + return; + } + + processingRef.current = true; + event.preventDefault(); + event.stopPropagation(); + + const imageAttachments: ImageAttachment[] = []; + const errors: string[] = []; + let runningTotal = attachedImages.reduce((sum, img) => sum + img.size, 0); + + try { + for (let i = 0; i < clipboardData.files.length; i++) { + const file = clipboardData.files[i]; + + if (!file.type.startsWith('image/')) { + continue; + } + + if (!isSupportedImage(file)) { + errors.push(`Unsupported image type: ${file.type}`); + continue; + } + + if (!isWithinSizeLimit(file)) { + errors.push( + `Image "${file.name || 'pasted image'}" is too large (${formatFileSize(file.size)}). Maximum size is 10MB.`, + ); + continue; + } + + if (runningTotal + file.size > MAX_TOTAL_IMAGE_SIZE) { + errors.push( + `Skipping image "${file.name || 'pasted image'}" – total attachment size would exceed ${formatFileSize(MAX_TOTAL_IMAGE_SIZE)}.`, + ); + continue; + } + + try { + // Clipboard pastes default to "image.png"; generate a timestamped name instead. + const imageFile = + file.name && file.name !== 'image.png' + ? file + : new File([file], generatePastedImageName(file.type), { + type: file.type, + }); + + const attachment = await createImageAttachment(imageFile); + if (attachment) { + imageAttachments.push(attachment); + runningTotal += attachment.size; + } + } catch { + errors.push( + `Failed to process image "${file.name || 'pasted image'}"`, + ); + } + } + + if (errors.length > 0) { + onError?.(errors.join('\n')); + } + + if (imageAttachments.length > 0) { + setAttachedImages((prev) => [...prev, ...imageAttachments]); + } + } finally { + processingRef.current = false; + } + }, + [attachedImages, onError], + ); + + return { attachedImages, handleRemoveImage, clearImages, handlePaste }; +} + +// ======================== useImageResolution ======================== + +export function useImageResolution({ + vscode, +}: { + vscode: { postMessage: (message: unknown) => void }; +}) { + const imageResolutionRef = useRef>(new Map()); + const pendingImagePathsRef = useRef>(new Set()); + const imageRequestIdRef = useRef(0); + + const expandMessages = useCallback( + ( + messages: WebViewMessageBase[], + ): { messages: WebViewMessage[]; imagePaths: string[] } => { + const expanded: WebViewMessage[] = []; + const allImagePaths: string[] = []; + + for (const message of messages) { + if (message.role === 'user') { + const result = expandUserMessageWithImages(message); + expanded.push(...result.messages); + allImagePaths.push(...result.imagePaths); + } else { + expanded.push(message); + } + } + + return { messages: expanded, imagePaths: allImagePaths }; + }, + [], + ); + + const applyCurrentImageResolutions = useCallback( + (messages: WebViewMessage[]): WebViewMessage[] => + applyImageResolution(messages, imageResolutionRef.current), + [], + ); + + const requestImageResolutions = useCallback( + (imagePaths: string[]) => { + if (imagePaths.length === 0) { + return; + } + + const pending = imagePaths.filter( + (p) => + !imageResolutionRef.current.has(p) && + !pendingImagePathsRef.current.has(p), + ); + + if (pending.length === 0) { + return; + } + + for (const p of pending) { + pendingImagePathsRef.current.add(p); + } + + imageRequestIdRef.current += 1; + vscode.postMessage({ + type: 'resolveImagePaths', + data: { paths: pending, requestId: imageRequestIdRef.current }, + }); + }, + [vscode], + ); + + const materializeMessages = useCallback( + (messages: WebViewMessageBase[]): WebViewMessage[] => { + const expanded = expandMessages(messages); + requestImageResolutions(expanded.imagePaths); + return applyCurrentImageResolutions(expanded.messages); + }, + [applyCurrentImageResolutions, expandMessages, requestImageResolutions], + ); + + const materializeMessage = useCallback( + (message: WebViewMessageBase): WebViewMessage[] => { + const expanded = + message.role === 'user' + ? expandUserMessageWithImages(message) + : { messages: [message], imagePaths: [] as string[] }; + requestImageResolutions(expanded.imagePaths); + return applyCurrentImageResolutions(expanded.messages); + }, + [applyCurrentImageResolutions, requestImageResolutions], + ); + + const mergeResolvedImages = useCallback( + ( + messages: WebViewMessage[], + resolved: Array<{ path: string; src?: string | null }>, + ): WebViewMessage[] => { + for (const item of resolved) { + pendingImagePathsRef.current.delete(item.path); + imageResolutionRef.current.set( + item.path, + item.src === null || item.src === undefined ? null : item.src, + ); + } + + return applyCurrentImageResolutions(messages); + }, + [applyCurrentImageResolutions], + ); + + const clearImageResolutions = useCallback(() => { + imageResolutionRef.current.clear(); + pendingImagePathsRef.current.clear(); + }, []); + + return { + materializeMessages, + materializeMessage, + mergeResolvedImages, + clearImageResolutions, + }; +} diff --git a/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.ts b/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.ts index a91594c05..3145e9d15 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.ts @@ -7,12 +7,15 @@ import { useCallback } from 'react'; import type { VSCodeAPI } from './useVSCode.js'; import { getRandomLoadingMessage } from '../../constants/loadingMessages.js'; +import type { ImageAttachment } from './useImage.js'; interface UseMessageSubmitProps { vscode: VSCodeAPI; inputText: string; setInputText: (text: string) => void; - inputFieldRef: React.RefObject; + attachedImages?: ImageAttachment[]; + clearImages?: () => void; + inputFieldRef: React.RefObject; isStreaming: boolean; isWaitingForResponse: boolean; // When true, do NOT auto-attach the active editor file/selection to context @@ -31,6 +34,26 @@ interface UseMessageSubmitProps { }; } +export const shouldSendMessage = ({ + inputText, + attachedImages, + isStreaming, + isWaitingForResponse, +}: { + inputText: string; + attachedImages?: ImageAttachment[]; + isStreaming: boolean; + isWaitingForResponse: boolean; +}): boolean => { + if (isStreaming || isWaitingForResponse) { + return false; + } + + const hasText = inputText.replace(/\u200B/g, '').trim().length > 0; + const hasAttachments = (attachedImages?.length ?? 0) > 0; + return hasText || hasAttachments; +}; + /** * Message submit Hook * Handles message submission logic and context parsing @@ -39,6 +62,8 @@ export const useMessageSubmit = ({ vscode, inputText, setInputText, + attachedImages = [], + clearImages, inputFieldRef, isStreaming, isWaitingForResponse, @@ -50,7 +75,14 @@ export const useMessageSubmit = ({ (e: React.FormEvent) => { e.preventDefault(); - if (!inputText.trim() || isStreaming || isWaitingForResponse) { + if ( + !shouldSendMessage({ + inputText, + attachedImages, + isStreaming, + isWaitingForResponse, + }) + ) { return; } @@ -142,6 +174,7 @@ export const useMessageSubmit = ({ text: inputText, context: context.length > 0 ? context : undefined, fileContext: fileContextForMessage, + attachments: attachedImages.length > 0 ? attachedImages : undefined, }, }); @@ -153,9 +186,14 @@ export const useMessageSubmit = ({ inputFieldRef.current.setAttribute('data-empty', 'true'); } fileContext.clearFileReferences(); + if (clearImages) { + clearImages(); + } }, [ inputText, + attachedImages, + clearImages, isStreaming, setInputText, inputFieldRef, diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts index 52d1655e7..8d5eef683 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts @@ -16,6 +16,11 @@ import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js'; import type { PlanEntry } from '../../types/chatTypes.js'; import type { ModelInfo, AvailableCommand } from '@agentclientprotocol/sdk'; import type { Question } from '../../types/acpTypes.js'; +import { + useImageResolution, + type WebViewMessage, + type WebViewMessageBase, +} from './useImage.js'; const FORCE_CLEAR_STREAM_END_REASONS = new Set([ 'user_cancelled', @@ -66,23 +71,11 @@ interface UseWebViewMessagesProps { // Message handling messageHandling: { setMessages: ( - messages: Array<{ - role: 'user' | 'assistant' | 'thinking'; - content: string; - timestamp: number; - fileContext?: { - fileName: string; - filePath: string; - startLine?: number; - endLine?: number; - }; - }>, + messages: + | WebViewMessage[] + | ((prev: WebViewMessage[]) => WebViewMessage[]), ) => void; - addMessage: (message: { - role: 'user' | 'assistant' | 'thinking'; - content: string; - timestamp: number; - }) => void; + addMessage: (message: WebViewMessage) => void; clearMessages: () => void; startStreaming: (timestamp?: number) => void; appendStreamChunk: (chunk: string) => void; @@ -124,7 +117,7 @@ interface UseWebViewMessagesProps { ) => void; // Input - inputFieldRef: React.RefObject; + inputFieldRef: React.RefObject; setInputText: (text: string) => void; // Edit mode setter (maps ACP modes to UI modes) setEditMode?: (mode: ApprovalModeValue) => void; @@ -164,6 +157,17 @@ export const useWebViewMessages = ({ }: UseWebViewMessagesProps) => { // VS Code API for posting messages back to the extension host const vscode = useVSCode(); + + // Image resolution handling + const { + materializeMessages, + materializeMessage, + mergeResolvedImages, + clearImageResolutions, + } = useImageResolution({ + vscode, + }); + // Track active long-running tool calls (execute/bash/command) so we can // keep the bottom "waiting" message visible until all of them complete. const activeExecToolCallsRef = useRef>(new Set()); @@ -422,7 +426,10 @@ export const useWebViewMessages = ({ case 'conversationLoaded': { const conversation = message.data as Conversation; - handlers.messageHandling.setMessages(conversation.messages); + clearImageResolutions(); + handlers.messageHandling.setMessages( + materializeMessages(conversation.messages as WebViewMessageBase[]), + ); break; } @@ -431,11 +438,15 @@ export const useWebViewMessages = ({ role?: 'user' | 'assistant' | 'thinking'; content?: string; timestamp?: number; + fileContext?: { + fileName: string; + filePath: string; + startLine?: number; + endLine?: number; + }; }; - handlers.messageHandling.addMessage( - msg as unknown as Parameters< - typeof handlers.messageHandling.addMessage - >[0], + materializeMessage(msg as WebViewMessageBase).forEach((entry) => + handlers.messageHandling.addMessage(entry), ); // Robustness: if an assistant message arrives outside the normal stream // pipeline (no explicit streamEnd), ensure we clear streaming/waiting states @@ -864,7 +875,12 @@ export const useWebViewMessages = ({ vscode.postMessage({ type: 'updatePanelTitle', data: { title } }); } if (message.data.messages) { - handlers.messageHandling.setMessages(message.data.messages); + clearImageResolutions(); + handlers.messageHandling.setMessages( + materializeMessages( + message.data.messages as WebViewMessageBase[], + ), + ); } else { handlers.messageHandling.clearMessages(); } @@ -901,6 +917,7 @@ export const useWebViewMessages = ({ handlers.messageHandling.clearMessages(); handlers.clearToolCalls(); handlers.sessionManagement.setCurrentSessionId(null); + clearImageResolutions(); handlers.sessionManagement.setCurrentSessionTitle( 'Past Conversations', ); @@ -986,6 +1003,18 @@ export const useWebViewMessages = ({ break; } + case 'imagePathsResolved': { + const resolved = + ( + message.data as + | { resolved?: Array<{ path: string; src?: string | null }> } + | undefined + )?.resolved ?? []; + handlers.messageHandling.setMessages((prevMessages) => + mergeResolvedImages(prevMessages, resolved), + ); + break; + } case 'cancelStreaming': // Handle cancel streaming response from extension // Note: The "Interrupted" message is already added by handleCancel in App.tsx @@ -999,7 +1028,16 @@ export const useWebViewMessages = ({ break; } }, - [inputFieldRef, setInputText, vscode, setEditMode], + [ + inputFieldRef, + setInputText, + vscode, + setEditMode, + materializeMessages, + materializeMessage, + mergeResolvedImages, + clearImageResolutions, + ], ); useEffect(() => { diff --git a/packages/vscode-ide-companion/src/webview/providers/PanelManager.ts b/packages/vscode-ide-companion/src/webview/providers/PanelManager.ts index 0c02dc3ca..c63e7037d 100644 --- a/packages/vscode-ide-companion/src/webview/providers/PanelManager.ts +++ b/packages/vscode-ide-companion/src/webview/providers/PanelManager.ts @@ -5,6 +5,24 @@ */ import * as vscode from 'vscode'; +import { Storage } from '@qwen-code/qwen-code-core'; + +export function getLocalResourceRoots( + extensionUri: vscode.Uri, + workspaceFolders: readonly vscode.WorkspaceFolder[] | undefined, +): vscode.Uri[] { + const roots = [ + vscode.Uri.joinPath(extensionUri, 'dist'), + vscode.Uri.joinPath(extensionUri, 'assets'), + vscode.Uri.file(Storage.getGlobalTempDir()), + ]; + + if (workspaceFolders && workspaceFolders.length > 0) { + roots.push(...workspaceFolders.map((folder) => folder.uri)); + } + + return roots; +} /** * Panel and Tab Manager @@ -62,10 +80,10 @@ export class PanelManager { { enableScripts: true, retainContextWhenHidden: true, - localResourceRoots: [ - vscode.Uri.joinPath(this.extensionUri, 'dist'), - vscode.Uri.joinPath(this.extensionUri, 'assets'), - ], + localResourceRoots: getLocalResourceRoots( + this.extensionUri, + vscode.workspace.workspaceFolders, + ), }, ); // Track the group column hosting this panel @@ -90,10 +108,10 @@ export class PanelManager { { enableScripts: true, retainContextWhenHidden: true, - localResourceRoots: [ - vscode.Uri.joinPath(this.extensionUri, 'dist'), - vscode.Uri.joinPath(this.extensionUri, 'assets'), - ], + localResourceRoots: getLocalResourceRoots( + this.extensionUri, + vscode.workspace.workspaceFolders, + ), }, ); // Lock the group after creation @@ -111,10 +129,10 @@ export class PanelManager { { enableScripts: true, retainContextWhenHidden: true, - localResourceRoots: [ - vscode.Uri.joinPath(this.extensionUri, 'dist'), - vscode.Uri.joinPath(this.extensionUri, 'assets'), - ], + localResourceRoots: getLocalResourceRoots( + this.extensionUri, + vscode.workspace.workspaceFolders, + ), }, ); diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewContent.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewContent.ts index 0f54b03b8..17fbae9fb 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewContent.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewContent.ts @@ -50,7 +50,7 @@ export class WebViewContent { - + Qwen Code diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts new file mode 100644 index 000000000..ffd587470 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts @@ -0,0 +1,290 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockCreateImagePathResolver, + mockGetGlobalTempDir, + mockGetPanel, + mockOnDidChangeActiveTextEditor, + mockOnDidChangeTextEditorSelection, +} = vi.hoisted(() => ({ + mockCreateImagePathResolver: vi.fn(), + mockGetGlobalTempDir: vi.fn(() => '/global-temp'), + mockGetPanel: vi.fn<() => { webview: { postMessage: unknown } } | null>( + () => null, + ), + mockOnDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })), + mockOnDidChangeTextEditorSelection: vi.fn(() => ({ dispose: vi.fn() })), +})); + +vi.mock('@qwen-code/qwen-code-core', () => ({ + Storage: { + getGlobalTempDir: mockGetGlobalTempDir, + }, +})); + +vi.mock('vscode', () => ({ + Uri: { + joinPath: vi.fn((base: { fsPath?: string }, ...parts: string[]) => ({ + fsPath: `${base.fsPath ?? ''}/${parts.join('/')}`.replace(/\/+/g, '/'), + })), + file: vi.fn((filePath: string) => ({ fsPath: filePath })), + }, + window: { + onDidChangeActiveTextEditor: mockOnDidChangeActiveTextEditor, + onDidChangeTextEditorSelection: mockOnDidChangeTextEditorSelection, + activeTextEditor: undefined, + }, + workspace: { + workspaceFolders: [{ uri: { fsPath: '/workspace-root' } }], + }, + commands: { + executeCommand: vi.fn(), + }, +})); + +vi.mock('../../services/qwenAgentManager.js', () => ({ + QwenAgentManager: class { + isConnected = false; + currentSessionId = null; + onMessage = vi.fn(); + onStreamChunk = vi.fn(); + onThoughtChunk = vi.fn(); + onModeInfo = vi.fn(); + onModeChanged = vi.fn(); + onUsageUpdate = vi.fn(); + onModelInfo = vi.fn(); + onModelChanged = vi.fn(); + onAvailableCommands = vi.fn(); + onAvailableModels = vi.fn(); + onEndTurn = vi.fn(); + onToolCall = vi.fn(); + onPlan = vi.fn(); + onPermissionRequest = vi.fn(); + onAskUserQuestion = vi.fn(); + disconnect = vi.fn(); + }, +})); + +vi.mock('../../services/conversationStore.js', () => ({ + ConversationStore: class { + constructor(_context: unknown) {} + }, +})); + +vi.mock('./PanelManager.js', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + PanelManager: class { + constructor(_extensionUri: unknown, _onPanelDispose: () => void) {} + getPanel() { + return mockGetPanel(); + } + }, + }; +}); + +vi.mock('./MessageHandler.js', () => ({ + MessageHandler: class { + constructor( + _agentManager: unknown, + _conversationStore: unknown, + _currentConversationId: string | null, + _sendToWebView: (message: unknown) => void, + ) {} + setLoginHandler = vi.fn(); + setPermissionHandler = vi.fn(); + setAskUserQuestionHandler = vi.fn(); + setupFileWatchers = vi.fn(() => ({ dispose: vi.fn() })); + appendStreamContent = vi.fn(); + route = vi.fn(); + }, +})); + +vi.mock('./WebViewContent.js', () => ({ + WebViewContent: { + generate: vi.fn(() => ''), + }, +})); + +vi.mock('../utils/imageHandler.js', () => ({ + createImagePathResolver: mockCreateImagePathResolver, +})); + +vi.mock('../../utils/authErrors.js', () => ({ + isAuthenticationRequiredError: vi.fn(() => false), +})); + +vi.mock('../../utils/errorMessage.js', () => ({ + getErrorMessage: vi.fn((error: unknown) => String(error)), +})); + +import { WebViewProvider } from './WebViewProvider.js'; + +describe('WebViewProvider.attachToView', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetPanel.mockReturnValue(null); + mockCreateImagePathResolver.mockReturnValue((paths: string[]) => + paths.map((entry) => ({ + path: entry, + src: `webview:${entry}`, + })), + ); + vi.spyOn( + WebViewProvider.prototype as unknown as { + initializeAgentConnection: () => Promise; + }, + 'initializeAgentConnection', + ).mockResolvedValue(undefined); + }); + + it('configures sidebar views with workspace/temp roots and resolves image paths through the attached webview', async () => { + let messageHandler: + | ((message: { type: string; data?: unknown }) => Promise) + | undefined; + + const postMessage = vi.fn(); + const webview = { + options: undefined as unknown, + html: '', + postMessage, + asWebviewUri: vi.fn((uri: { fsPath: string }) => ({ + toString: () => `webview:${uri.fsPath}`, + })), + onDidReceiveMessage: vi.fn( + ( + handler: (message: { type: string; data?: unknown }) => Promise, + ) => { + messageHandler = handler; + return { dispose: vi.fn() }; + }, + ), + }; + + const provider = new WebViewProvider( + { subscriptions: [] } as never, + { fsPath: '/extension-root' } as never, + ); + + await provider.attachToView( + { + webview, + visible: true, + onDidChangeVisibility: vi.fn(() => ({ dispose: vi.fn() })), + onDidDispose: vi.fn(() => ({ dispose: vi.fn() })), + } as never, + 'qwen-code.chatView.sidebar', + ); + + const roots = ( + webview.options as { localResourceRoots?: Array<{ fsPath: string }> } + ).localResourceRoots; + expect(roots).toEqual( + expect.arrayContaining([ + expect.objectContaining({ fsPath: '/extension-root/dist' }), + expect.objectContaining({ fsPath: '/extension-root/assets' }), + expect.objectContaining({ fsPath: '/global-temp' }), + expect.objectContaining({ fsPath: '/workspace-root' }), + ]), + ); + + expect(messageHandler).toBeTypeOf('function'); + + await messageHandler?.({ + type: 'resolveImagePaths', + data: { paths: ['clipboard/example.png'], requestId: 7 }, + }); + + expect(mockCreateImagePathResolver).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceRoots: ['/workspace-root'], + toWebviewUri: expect.any(Function), + }), + ); + expect(postMessage).toHaveBeenCalledWith({ + type: 'imagePathsResolved', + data: { + resolved: [ + { + path: 'clipboard/example.png', + src: 'webview:clipboard/example.png', + }, + ], + requestId: 7, + }, + }); + }); + + it('routes resolved image paths back to the requesting attached webview even when a panel exists', async () => { + let messageHandler: + | ((message: { type: string; data?: unknown }) => Promise) + | undefined; + + const attachedPostMessage = vi.fn(); + const panelPostMessage = vi.fn(); + mockGetPanel.mockReturnValue({ + webview: { + postMessage: panelPostMessage, + }, + }); + + const webview = { + options: undefined as unknown, + html: '', + postMessage: attachedPostMessage, + asWebviewUri: vi.fn((uri: { fsPath: string }) => ({ + toString: () => `attached:${uri.fsPath}`, + })), + onDidReceiveMessage: vi.fn( + ( + handler: (message: { type: string; data?: unknown }) => Promise, + ) => { + messageHandler = handler; + return { dispose: vi.fn() }; + }, + ), + }; + + const provider = new WebViewProvider( + { subscriptions: [] } as never, + { fsPath: '/extension-root' } as never, + ); + + await provider.attachToView( + { + webview, + visible: true, + onDidChangeVisibility: vi.fn(() => ({ dispose: vi.fn() })), + onDidDispose: vi.fn(() => ({ dispose: vi.fn() })), + } as never, + 'qwen-code.chatView.sidebar', + ); + + await messageHandler?.({ + type: 'resolveImagePaths', + data: { paths: ['/global-temp/clipboard/example.png'], requestId: 8 }, + }); + + expect(attachedPostMessage).toHaveBeenCalledWith({ + type: 'imagePathsResolved', + data: { + resolved: [ + { + path: '/global-temp/clipboard/example.png', + src: 'webview:/global-temp/clipboard/example.png', + }, + ], + requestId: 8, + }, + }); + expect(panelPostMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts index c54fa4af4..e5e69e66a 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts @@ -16,10 +16,11 @@ import type { PermissionResponseMessage, AskUserQuestionResponseMessage, } from '../../types/webviewMessageTypes.js'; -import { PanelManager } from './PanelManager.js'; +import { PanelManager, getLocalResourceRoots } from './PanelManager.js'; import { MessageHandler } from './MessageHandler.js'; import { WebViewContent } from './WebViewContent.js'; import { getFileName } from '../utils/webviewUtils.js'; +import { createImagePathResolver } from '../utils/imageHandler.js'; import { type ApprovalModeValue } from '../../types/approvalModeValueTypes.js'; import { isAuthenticationRequiredError } from '../../utils/authErrors.js'; import { getErrorMessage } from '../../utils/errorMessage.js'; @@ -476,10 +477,10 @@ export class WebViewProvider { // Configure webview options webview.options = { enableScripts: true, - localResourceRoots: [ - vscode.Uri.joinPath(this.extensionUri, 'dist'), - vscode.Uri.joinPath(this.extensionUri, 'assets'), - ], + localResourceRoots: getLocalResourceRoots( + this.extensionUri, + vscode.workspace.workspaceFolders, + ), }; // Store reference so sendMessageToWebView can reach it @@ -500,6 +501,10 @@ export class WebViewProvider { this.handleWebviewReady(); return; } + if (message.type === 'resolveImagePaths') { + this.handleResolveImagePaths(message.data, webview); + return; + } if (this.handleNewChatByContext(message)) { return; } @@ -653,6 +658,10 @@ export class WebViewProvider { this.handleWebviewReady(); return; } + if (message.type === 'resolveImagePaths') { + this.handleResolveImagePaths(message.data, newPanel.webview); + return; + } // Allow webview to request updating the VS Code tab title if (message.type === 'updatePanelTitle') { const title = String( @@ -1229,12 +1238,42 @@ export class WebViewProvider { */ private sendMessageToWebView(message: unknown): void { this.updateAuthStateFromMessage(message); - const panel = this.panelManager.getPanel(); - if (panel) { - panel.webview.postMessage(message); - } else if (this.attachedWebview) { - this.attachedWebview.postMessage(message); + this.getActiveWebview()?.postMessage(message); + } + + private handleResolveImagePaths( + data: unknown, + targetWebview?: vscode.Webview, + ): void { + const webview = targetWebview ?? this.getActiveWebview(); + if (!webview) { + return; } + + const payload = data as + | { paths?: string[]; requestId?: number } + | undefined; + const paths = Array.isArray(payload?.paths) ? (payload?.paths ?? []) : []; + + const workspaceFolders = vscode.workspace.workspaceFolders ?? []; + const workspaceRoots = workspaceFolders.map((folder) => folder.uri.fsPath); + + const resolveImagePaths = createImagePathResolver({ + workspaceRoots, + toWebviewUri: (filePath: string) => + webview.asWebviewUri(vscode.Uri.file(filePath)).toString(), + }); + + const resolved = resolveImagePaths(paths); + + webview.postMessage({ + type: 'imagePathsResolved', + data: { resolved, requestId: payload?.requestId }, + }); + } + + private getActiveWebview(): vscode.Webview | null { + return this.panelManager.getPanel()?.webview ?? this.attachedWebview; } /** @@ -1380,6 +1419,10 @@ export class WebViewProvider { this.handleWebviewReady(); return; } + if (message.type === 'resolveImagePaths') { + this.handleResolveImagePaths(message.data, panel.webview); + return; + } if (message.type === 'updatePanelTitle') { const title = String( (message.data as { title?: unknown } | undefined)?.title ?? '', diff --git a/packages/vscode-ide-companion/src/webview/providers/chatViewRegistration.test.ts b/packages/vscode-ide-companion/src/webview/providers/chatViewRegistration.test.ts index d0ca0db65..dcfa74f00 100644 --- a/packages/vscode-ide-companion/src/webview/providers/chatViewRegistration.test.ts +++ b/packages/vscode-ide-companion/src/webview/providers/chatViewRegistration.test.ts @@ -67,6 +67,7 @@ describe('registerChatViewProviders', () => { 'qwen-code.chatView.sidebar', 'qwen-code.chatView.secondary', ]); + expect(calls[0]?.[1]).not.toBe(calls[1]?.[1]); expect(calls[0]?.[2]).toEqual({ webviewOptions: { retainContextWhenHidden: true }, }); diff --git a/packages/vscode-ide-companion/src/webview/providers/chatViewRegistration.ts b/packages/vscode-ide-companion/src/webview/providers/chatViewRegistration.ts index cd3a20565..d3eb5eb83 100644 --- a/packages/vscode-ide-companion/src/webview/providers/chatViewRegistration.ts +++ b/packages/vscode-ide-companion/src/webview/providers/chatViewRegistration.ts @@ -43,17 +43,18 @@ export function registerChatViewProviders(params: { ); } - const chatViewProvider = new ChatWebviewViewProvider(createViewProvider); + const sidebarViewProvider = new ChatWebviewViewProvider(createViewProvider); + const secondaryViewProvider = new ChatWebviewViewProvider(createViewProvider); context.subscriptions.push( vscode.window.registerWebviewViewProvider( CHAT_VIEW_ID_SIDEBAR, - chatViewProvider, + sidebarViewProvider, { webviewOptions: { retainContextWhenHidden: true } }, ), vscode.window.registerWebviewViewProvider( CHAT_VIEW_ID_SECONDARY, - chatViewProvider, + secondaryViewProvider, { webviewOptions: { retainContextWhenHidden: true } }, ), ); diff --git a/packages/vscode-ide-companion/src/webview/utils/imageHandler.test.ts b/packages/vscode-ide-companion/src/webview/utils/imageHandler.test.ts new file mode 100644 index 000000000..5d60b2341 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/utils/imageHandler.test.ts @@ -0,0 +1,219 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + normalizeImageAttachment, + escapePath, + unescapePath, +} from '../../utils/imageSupport.js'; + +const mockMkdir = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); +const mockWriteFile = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); +const mockReaddir = vi.hoisted(() => vi.fn().mockResolvedValue([])); +const mockStat = vi.hoisted(() => vi.fn()); +const mockUnlink = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); + +vi.mock('fs/promises', () => ({ + mkdir: mockMkdir, + writeFile: mockWriteFile, + readdir: mockReaddir, + stat: mockStat, + unlink: mockUnlink, +})); + +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + Storage: { getGlobalTempDir: () => '/mock/tmp' }, + }; +}); + +vi.mock('vscode', () => ({ + workspace: { + workspaceFolders: [], + }, +})); + +import { + processImageAttachments, + saveImageToFile, + buildPromptBlocks, +} from './imageHandler.js'; + +describe('imageHandler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('decodes base64 data URL and writes correct buffer to disk', async () => { + const filePath = await saveImageToFile( + 'data:image/png;base64,YWJj', + 'image/png', + ); + + expect(filePath).toBeTruthy(); + expect(mockMkdir).toHaveBeenCalledWith( + path.join('/mock/tmp', 'clipboard'), + { recursive: true }, + ); + expect(mockWriteFile).toHaveBeenCalledOnce(); + + const [writtenPath, buffer] = mockWriteFile.mock.calls[0]; + expect(buffer).toEqual(Buffer.from('abc')); + expect(path.basename(writtenPath)).toMatch( + /^clipboard-\d+-[a-f0-9-]+\.png$/, + ); + }); + + it('decodes raw base64 (without data URL prefix)', async () => { + const filePath = await saveImageToFile('YWJj', 'image/png'); + + expect(filePath).toBeTruthy(); + const [, buffer] = mockWriteFile.mock.calls[0]; + expect(buffer).toEqual(Buffer.from('abc')); + }); + + it('prunes old clipboard images after saving', async () => { + mockReaddir.mockResolvedValueOnce(['clipboard-1.png', 'clipboard-2.png']); + mockStat + .mockResolvedValueOnce({ mtimeMs: 100 }) + .mockResolvedValueOnce({ mtimeMs: 200 }); + + await saveImageToFile('data:image/png;base64,YWJj', 'image/png'); + + expect(mockReaddir).toHaveBeenCalled(); + }); + + it('generates unique file names for images saved in the same millisecond', async () => { + vi.spyOn(Date, 'now').mockReturnValue(1234567890); + + await saveImageToFile('data:image/png;base64,YWJj', 'image/png'); + await saveImageToFile('data:image/png;base64,ZGVm', 'image/png'); + + const firstName = path.basename(mockWriteFile.mock.calls[0][0]); + const secondName = path.basename(mockWriteFile.mock.calls[1][0]); + expect(firstName).not.toBe(secondName); + }); + + it('returns null when file write throws', async () => { + mockWriteFile.mockRejectedValueOnce(new Error('disk full')); + const result = await saveImageToFile( + 'data:image/png;base64,YWJj', + 'image/png', + ); + expect(result).toBeNull(); + }); + + it('returns saved prompt image metadata for validated attachments', async () => { + const result = await processImageAttachments('Inspect this image', [ + { + id: 'img-1', + name: 'pasted.png', + type: 'image/png', + size: 3, + data: 'data:image/png;base64,YWJj', + timestamp: Date.now(), + }, + ]); + + expect(result.savedImageCount).toBe(1); + expect(result.promptImages).toEqual([ + expect.objectContaining({ + name: 'pasted.png', + mimeType: 'image/png', + path: expect.stringContaining(`${path.sep}clipboard-`), + }), + ]); + expect(result.formattedText).toContain('@'); + }); +}); + +describe('buildPromptBlocks', () => { + it('builds ACP resource_link blocks from saved image attachments', () => { + expect( + buildPromptBlocks('Please inspect this screenshot.', [ + { + path: '/tmp/My Images/pasted image.png', + name: 'pasted image.png', + mimeType: 'image/png', + }, + ]), + ).toEqual([ + { type: 'text', text: 'Please inspect this screenshot.' }, + { + type: 'resource_link', + name: 'pasted image.png', + mimeType: 'image/png', + uri: 'file:///tmp/My Images/pasted image.png', + }, + ]); + }); + + it('returns only resource links when the prompt has images only', () => { + expect( + buildPromptBlocks('', [ + { + path: '/tmp/clipboard/pasted.webp', + name: 'pasted.webp', + mimeType: 'image/webp', + }, + ]), + ).toEqual([ + { + type: 'resource_link', + name: 'pasted.webp', + mimeType: 'image/webp', + uri: 'file:///tmp/clipboard/pasted.webp', + }, + ]); + }); +}); + +describe('normalizeImageAttachment', () => { + it('rejects attachments with unsupported image mime types', () => { + expect( + normalizeImageAttachment({ + id: 'img-1', + name: 'animated.gif', + type: 'image/gif', + size: 43, + data: 'data:image/gif;base64,R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs=', + timestamp: Date.now(), + }), + ).toBeNull(); + }); + + it('rejects attachments whose decoded payload exceeds the enforced byte limit', () => { + expect( + normalizeImageAttachment( + { + id: 'img-2', + name: 'oversized.png', + type: 'image/png', + size: 1, + data: 'data:image/png;base64,QUJDREU=', + timestamp: Date.now(), + }, + { maxBytes: 4 }, + ), + ).toBeNull(); + }); +}); + +describe('pathEscaping', () => { + it('round-trips shell-escaped file paths', () => { + const originalPath = '/tmp/My Images/(draft) final.png'; + expect(unescapePath(escapePath(originalPath))).toBe(originalPath); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/utils/imageHandler.ts b/packages/vscode-ide-companion/src/webview/utils/imageHandler.ts new file mode 100644 index 000000000..026560785 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/utils/imageHandler.ts @@ -0,0 +1,261 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'fs'; +import * as fsp from 'fs/promises'; +import * as path from 'path'; +import { randomUUID } from 'node:crypto'; +import type { ContentBlock } from '@agentclientprotocol/sdk'; +import { Storage } from '@qwen-code/qwen-code-core'; +import type { + ImageAttachment, + SavedImageAttachment, +} from '../../utils/imageSupport.js'; +import { + MAX_IMAGE_SIZE, + MAX_TOTAL_IMAGE_SIZE, + getImageExtensionForMimeType, + escapePath, + normalizeImageAttachment, +} from '../../utils/imageSupport.js'; + +// ---------- Clipboard image storage ---------- + +const CLIPBOARD_DIR_NAME = 'clipboard'; +const DEFAULT_MAX_IMAGES = 100; + +function getClipboardImageDir(): string { + return path.join(Storage.getGlobalTempDir(), CLIPBOARD_DIR_NAME); +} + +async function saveImageBufferToClipboardDir( + buffer: Buffer, + fileName: string, +): Promise { + const dir = getClipboardImageDir(); + await fsp.mkdir(dir, { recursive: true }); + const filePath = path.join(dir, fileName); + await fsp.writeFile(filePath, buffer); + return filePath; +} + +async function pruneClipboardImages( + maxImages: number = DEFAULT_MAX_IMAGES, +): Promise { + try { + const dir = getClipboardImageDir(); + const files = await fsp.readdir(dir); + const imageFiles: Array<{ filePath: string; mtimeMs: number }> = []; + + for (const file of files) { + if (file.startsWith('clipboard-')) { + const filePath = path.join(dir, file); + const stats = await fsp.stat(filePath); + imageFiles.push({ filePath, mtimeMs: stats.mtimeMs }); + } + } + + if (imageFiles.length > maxImages) { + imageFiles.sort((a, b) => b.mtimeMs - a.mtimeMs); + for (const { filePath } of imageFiles.slice(maxImages)) { + await fsp.unlink(filePath); + } + } + } catch { + // Ignore errors in cleanup — directory may not exist yet + } +} + +// ---------- Image saving & processing ---------- + +export function appendImageReferences( + text: string, + imageReferences: string[], +): string { + if (imageReferences.length === 0) { + return text; + } + const imageText = imageReferences.join(' '); + if (!text.trim()) { + return imageText; + } + return `${text}\n\n${imageText}`; +} + +export async function saveImageToFile( + base64Data: string, + mimeType: string, +): Promise { + try { + let pureBase64 = base64Data; + const dataUrlMatch = base64Data.match(/^data:[^;]+;base64,(.+)$/); + if (dataUrlMatch) { + pureBase64 = dataUrlMatch[1]; + } + + const buffer = Buffer.from(pureBase64, 'base64'); + const timestamp = Date.now(); + const ext = getImageExtensionForMimeType(mimeType); + const fileName = `clipboard-${timestamp}-${randomUUID()}${ext}`; + + const filePath = await saveImageBufferToClipboardDir(buffer, fileName); + await pruneClipboardImages(); + return filePath; + } catch (error) { + console.error('[ImageHandler] Failed to save image:', error); + return null; + } +} + +export async function processImageAttachments( + text: string, + attachments?: ImageAttachment[], +): Promise<{ + formattedText: string; + displayText: string; + savedImageCount: number; + promptImages: SavedImageAttachment[]; +}> { + let formattedText = text; + let displayText = text; + let savedImageCount = 0; + let remainingBytes = MAX_TOTAL_IMAGE_SIZE; + const promptImages: SavedImageAttachment[] = []; + + if (attachments && attachments.length > 0) { + const imageReferences: string[] = []; + + for (const attachment of attachments) { + const normalizedAttachment = normalizeImageAttachment(attachment, { + maxBytes: Math.min(MAX_IMAGE_SIZE, remainingBytes), + }); + if (!normalizedAttachment) { + console.warn( + '[ImageHandler] Rejected invalid image attachment:', + attachment.name, + ); + continue; + } + + const imagePath = await saveImageToFile( + normalizedAttachment.data, + normalizedAttachment.type, + ); + if (imagePath) { + imageReferences.push(`@${escapePath(imagePath)}`); + promptImages.push({ + path: imagePath, + name: normalizedAttachment.name, + mimeType: normalizedAttachment.type, + }); + remainingBytes -= normalizedAttachment.size; + savedImageCount += 1; + } else { + console.warn('[ImageHandler] Failed to save image:', attachment.name); + } + } + + if (imageReferences.length > 0) { + formattedText = appendImageReferences(formattedText, imageReferences); + displayText = appendImageReferences(displayText, imageReferences); + } + } + + return { formattedText, displayText, savedImageCount, promptImages }; +} + +// ---------- ACP prompt builder ---------- + +export function buildPromptBlocks( + text: string, + images: SavedImageAttachment[] = [], +): ContentBlock[] { + const blocks: ContentBlock[] = []; + + if (text || images.length === 0) { + blocks.push({ type: 'text', text }); + } + + for (const image of images) { + blocks.push({ + type: 'resource_link', + name: image.name, + mimeType: image.mimeType, + uri: `file://${image.path}`, + }); + } + + return blocks; +} + +// ---------- Image path resolution ---------- + +export function resolveImagePathsForWebview({ + paths, + workspaceRoots, + globalTempDir, + existsSync, + toWebviewUri, +}: { + paths: string[]; + workspaceRoots: string[]; + globalTempDir: string; + existsSync: (path: string) => boolean; + toWebviewUri: (path: string) => string; +}): Array<{ path: string; src: string | null }> { + const allowedRoots = [...workspaceRoots, globalTempDir].filter(Boolean); + const root = workspaceRoots[0]; + + return paths.map((imagePath) => { + if (!imagePath || typeof imagePath !== 'string') { + return { path: imagePath, src: null }; + } + + const resolvedPath = path.isAbsolute(imagePath) + ? path.normalize(imagePath) + : root + ? path.normalize(path.resolve(root, imagePath)) + : null; + + if (!resolvedPath) { + return { path: imagePath, src: null }; + } + + const isAllowed = allowedRoots.some((allowedRoot) => { + const normalizedRoot = path.normalize(allowedRoot); + return ( + resolvedPath === normalizedRoot || + resolvedPath.startsWith(normalizedRoot + path.sep) + ); + }); + + if (!isAllowed || !existsSync(resolvedPath)) { + return { path: imagePath, src: null }; + } + + return { path: imagePath, src: toWebviewUri(resolvedPath) }; + }); +} + +export function createImagePathResolver({ + workspaceRoots, + toWebviewUri, +}: { + workspaceRoots: string[]; + toWebviewUri: (filePath: string) => string; +}) { + return function resolveImagePaths( + paths: string[], + ): Array<{ path: string; src: string | null }> { + return resolveImagePathsForWebview({ + paths, + workspaceRoots, + globalTempDir: Storage.getGlobalTempDir(), + existsSync: fs.existsSync, + toWebviewUri, + }); + }; +} diff --git a/packages/webui/src/components/layout/InputForm.tsx b/packages/webui/src/components/layout/InputForm.tsx index 7edfac03b..e73700e12 100644 --- a/packages/webui/src/components/layout/InputForm.tsx +++ b/packages/webui/src/components/layout/InputForm.tsx @@ -64,7 +64,7 @@ export interface InputFormProps { /** Current input text */ inputText: string; /** Ref for the input field */ - inputFieldRef: React.RefObject; + inputFieldRef: React.RefObject; /** Whether AI is currently generating */ isStreaming: boolean; /** Whether waiting for response */ @@ -117,8 +117,14 @@ export interface InputFormProps { onCompletionFill?: (item: CompletionItem) => void; /** Completion close callback */ onCompletionClose?: () => void; + /** Optional paste handler for the contentEditable input */ + onPaste?: (e: React.ClipboardEvent) => void; + /** Optional content rendered between the input and actions */ + extraContent?: ReactNode; /** Placeholder text */ placeholder?: string; + /** Whether the current draft is eligible to submit */ + canSubmit?: boolean; } /** @@ -174,9 +180,14 @@ export const InputForm: FC = ({ onCompletionSelect, onCompletionFill, onCompletionClose, + onPaste, + extraContent, placeholder = 'Ask Qwen Code …', + canSubmit, }) => { const composerDisabled = isStreaming || isWaitingForResponse; + const hasDraftContent = + canSubmit ?? inputText.replace(/\u200B/g, '').trim().length > 0; const completionItemsResolved = completionItems ?? []; const completionActive = completionIsOpen && @@ -275,10 +286,15 @@ export const InputForm: FC = ({ onCompositionStart={onCompositionStart} onCompositionEnd={onCompositionEnd} onKeyDown={handleKeyDown} + onPaste={onPaste} suppressContentEditableWarning /> + {extraContent ? ( +
{extraContent}
+ ) : null} +
{/* Edit mode button */} +
+ + ))} + + ); +}; + +// ======================== ImageMessageRenderer ======================== + +export interface ImageMessageLike { + kind: 'image'; + imagePath: string; + imageSrc?: string; + imageMissing?: boolean; +} + +export interface ImageMessageRendererProps { + msg: ImageMessageLike; + imageIndex: number; +} + +export const ImageMessageRenderer: FC = ({ + msg, + imageIndex, +}) => { + if (msg.kind !== 'image' || !msg.imagePath) { + return null; + } + + const label = `[Image #${imageIndex}]`; + const showImage = Boolean(msg.imageSrc) && !msg.imageMissing; + + return ( +
+
+
+ {label} +
+ {showImage ? ( + {msg.imagePath} + ) : ( +
+ @{msg.imagePath} +
+ )} +
+
+ ); +}; diff --git a/packages/webui/src/index.ts b/packages/webui/src/index.ts index 39e0a8cbf..777d2cced 100644 --- a/packages/webui/src/index.ts +++ b/packages/webui/src/index.ts @@ -92,6 +92,16 @@ export type { Question, QuestionOption, } from './components/messages/AskUserQuestionDialog'; +export { + ImagePreview, + ImageMessageRenderer, +} from './components/messages/ImageComponents'; +export type { + ImagePreviewProps, + ImagePreviewItem, + ImageMessageRendererProps, + ImageMessageLike, +} from './components/messages/ImageComponents'; // ChatViewer - standalone chat display component export { diff --git a/scripts/prepare-package.js b/scripts/prepare-package.js index 497fdaff9..02a8fb017 100644 --- a/scripts/prepare-package.js +++ b/scripts/prepare-package.js @@ -150,7 +150,15 @@ const distPackageJson = { bin: { qwen: 'cli.js', }, - files: ['cli.js', 'vendor', '*.sb', 'README.md', 'LICENSE', 'locales'], + files: [ + 'cli.js', + 'vendor', + '*.sb', + 'README.md', + 'LICENSE', + 'locales', + 'bundled', + ], config: rootPackageJson.config, dependencies: {}, optionalDependencies: {