Compare commits

..

4 Commits

Author SHA1 Message Date
xwj02155382
48bc0f35d7 perf: add cache for commandExists to fix CI timeout
- Add commandExistsCache Map to avoid repeated execSync calls
- Cache command existence check results to improve test performance
- Fix CI test timeout issue (was timing out after 7m)

The commandExists() function was being called frequently during tests,
causing slow test execution due to repeated system command calls.
By caching the results, we significantly improve performance in test
environments while maintaining the same functionality.
2025-12-26 13:52:37 +08:00
xwj02155382
e30c2dbe23 Merge branch 'fix/editor-launch-issues' of https://github.com/xuewenjie123/qwen-code into fix/editor-launch-issues 2025-12-26 11:22:22 +08:00
xwj02155382
e9204ecba9 fix: resolve editor launch issue on macOS for subagent editing
- Fixed ENOENT error when launching external editors (VS Code, etc.)
- Added proper editor command mapping (vscode -> code, neovim -> nvim, etc.)
- Implemented command existence check to find available editor executable
- Supports both macOS and Windows platform-specific commands

Fixes #1180
2025-12-26 11:11:24 +08:00
xwj02155382
f24bda3d7b fix: resolve editor launch issue on macOS for subagent editing
- Fixed ENOENT error when launching external editors (VS Code, etc.)
- Added proper editor command mapping (vscode -> code, neovim -> nvim, etc.)
- Implemented command existence check to find available editor executable
- Supports both macOS and Windows platform-specific commands

Fixes #1180
2025-12-26 10:17:52 +08:00
61 changed files with 467 additions and 2743 deletions

View File

@@ -91,8 +91,6 @@ jobs:
with:
node-version-file: '.nvmrc'
cache: 'npm'
registry-url: 'https://registry.npmjs.org'
scope: '@qwen-code'
- name: 'Install Dependencies'
run: |-
@@ -128,14 +126,6 @@ jobs:
IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}'
MANUAL_VERSION: '${{ inputs.version }}'
- name: 'Set SDK package version (local only)'
env:
RELEASE_VERSION: '${{ steps.version.outputs.RELEASE_VERSION }}'
run: |-
# Ensure the package version matches the computed release version.
# This is required for nightly/preview because npm does not allow re-publishing the same version.
npm version -w @qwen-code/sdk "${RELEASE_VERSION}" --no-git-tag-version --allow-same-version
- name: 'Build CLI Bundle'
run: |
npm run build
@@ -168,21 +158,7 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: 'Build SDK'
working-directory: 'packages/sdk-typescript'
run: |-
npm run build
- name: 'Publish @qwen-code/sdk'
working-directory: 'packages/sdk-typescript'
run: |-
npm publish --access public --tag=${{ steps.version.outputs.NPM_TAG }} ${{ steps.vars.outputs.is_dry_run == 'true' && '--dry-run' || '' }}
env:
NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}'
- name: 'Create and switch to a release branch'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
id: 'release_branch'
env:
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
@@ -191,22 +167,50 @@ jobs:
git switch -c "${BRANCH_NAME}"
echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}"
- name: 'Commit and Push package version (stable only)'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
- name: 'Update package version'
env:
RELEASE_VERSION: '${{ steps.version.outputs.RELEASE_VERSION }}'
run: |-
# Use npm workspaces so the root lockfile is updated consistently.
npm version -w @qwen-code/sdk "${RELEASE_VERSION}" --no-git-tag-version --allow-same-version
- name: 'Commit and Conditionally Push package version'
env:
BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
IS_DRY_RUN: '${{ steps.vars.outputs.is_dry_run }}'
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
run: |-
# Only persist version bumps after a successful publish.
git add packages/sdk-typescript/package.json package-lock.json
if git diff --staged --quiet; then
echo "No version changes to commit"
else
git commit -m "chore(release): sdk-typescript ${RELEASE_TAG}"
fi
echo "Pushing release branch to remote..."
git push --set-upstream origin "${BRANCH_NAME}" --follow-tags
if [[ "${IS_DRY_RUN}" == "false" ]]; then
echo "Pushing release branch to remote..."
git push --set-upstream origin "${BRANCH_NAME}" --follow-tags
else
echo "Dry run enabled. Skipping push."
fi
- name: 'Build SDK'
working-directory: 'packages/sdk-typescript'
run: |-
npm run build
- name: 'Configure npm for publishing'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
with:
node-version-file: '.nvmrc'
registry-url: 'https://registry.npmjs.org'
scope: '@qwen-code'
- name: 'Publish @qwen-code/sdk'
working-directory: 'packages/sdk-typescript'
run: |-
npm publish --access public --tag=${{ steps.version.outputs.NPM_TAG }} ${{ steps.vars.outputs.is_dry_run == 'true' && '--dry-run' || '' }}
env:
NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}'
- name: 'Create GitHub Release and Tag'
if: |-
@@ -216,29 +220,16 @@ jobs:
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
PREVIOUS_RELEASE_TAG: '${{ steps.version.outputs.PREVIOUS_RELEASE_TAG }}'
IS_NIGHTLY: '${{ steps.vars.outputs.is_nightly }}'
IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}'
REF: '${{ github.event.inputs.ref || github.sha }}'
run: |-
# For stable releases, use the release branch; for nightly/preview, use the current ref
if [[ "${IS_NIGHTLY}" == "true" || "${IS_PREVIEW}" == "true" ]]; then
TARGET="${REF}"
PRERELEASE_FLAG="--prerelease"
else
TARGET="${RELEASE_BRANCH}"
PRERELEASE_FLAG=""
fi
gh release create "sdk-typescript-${RELEASE_TAG}" \
--target "${TARGET}" \
--target "$RELEASE_BRANCH" \
--title "SDK TypeScript Release ${RELEASE_TAG}" \
--notes-start-tag "sdk-typescript-${PREVIOUS_RELEASE_TAG}" \
--generate-notes \
${PRERELEASE_FLAG}
--generate-notes
- name: 'Create PR to merge release branch into main'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
${{ steps.vars.outputs.is_dry_run == 'false' }}
id: 'pr'
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
@@ -260,7 +251,7 @@ jobs:
- name: 'Wait for CI checks to complete'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
${{ steps.vars.outputs.is_dry_run == 'false' }}
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
PR_URL: '${{ steps.pr.outputs.PR_URL }}'
@@ -271,7 +262,7 @@ jobs:
- name: 'Enable auto-merge for release PR'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
${{ steps.vars.outputs.is_dry_run == 'false' }}
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
PR_URL: '${{ steps.pr.outputs.PR_URL }}'

View File

@@ -314,88 +314,4 @@ describe('System Control (E2E)', () => {
);
});
});
describe('supportedCommands API', () => {
it('should return list of supported slash commands', async () => {
const sessionId = crypto.randomUUID();
const generator = (async function* () {
yield {
type: 'user',
session_id: sessionId,
message: { role: 'user', content: 'Hello' },
parent_tool_use_id: null,
} as SDKUserMessage;
})();
const q = query({
prompt: generator,
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
model: 'qwen3-max',
debug: false,
},
});
try {
const result = await q.supportedCommands();
// Start consuming messages to trigger initialization
const messageConsumer = (async () => {
try {
for await (const _message of q) {
// Just consume messages
}
} catch (error) {
// Ignore errors from query being closed
if (error instanceof Error && error.message !== 'Query is closed') {
throw error;
}
}
})();
// Verify result structure
expect(result).toBeDefined();
expect(result).toHaveProperty('commands');
expect(Array.isArray(result?.['commands'])).toBe(true);
const commands = result?.['commands'] as string[];
// Verify default allowed built-in commands are present
expect(commands).toContain('init');
expect(commands).toContain('summary');
expect(commands).toContain('compress');
// Verify commands are sorted
const sortedCommands = [...commands].sort();
expect(commands).toEqual(sortedCommands);
// Verify all commands are strings
commands.forEach((cmd) => {
expect(typeof cmd).toBe('string');
expect(cmd.length).toBeGreaterThan(0);
});
await q.close();
await messageConsumer;
} catch (error) {
await q.close();
throw error;
}
});
it('should throw error when supportedCommands is called on closed query', async () => {
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
model: 'qwen3-max',
},
});
await q.close();
await expect(q.supportedCommands()).rejects.toThrow('Query is closed');
});
});
});

View File

@@ -98,14 +98,6 @@ export class AgentSideConnection implements Client {
);
}
/**
* Sends a custom notification to the client.
* Used for extension-specific notifications that are not part of the core ACP protocol.
*/
async sendCustomNotification<T>(method: string, params: T): Promise<void> {
return await this.#connection.sendNotification(method, params);
}
/**
* Request permission before running a tool
*
@@ -382,7 +374,6 @@ export interface Client {
): Promise<schema.RequestPermissionResponse>;
sessionUpdate(params: schema.SessionNotification): Promise<void>;
authenticateUpdate(params: schema.AuthenticateUpdate): Promise<void>;
sendCustomNotification<T>(method: string, params: T): Promise<void>;
writeTextFile(
params: schema.WriteTextFileRequest,
): Promise<schema.WriteTextFileResponse>;

View File

@@ -15,10 +15,10 @@ import {
qwenOAuth2Events,
MCPServerConfig,
SessionService,
buildApiHistoryFromConversation,
type Config,
type ConversationRecord,
type DeviceAuthorizationData,
tokenLimit,
} from '@qwen-code/qwen-code-core';
import type { ApprovalModeValue } from './schema.js';
import * as acp from './acp.js';
@@ -165,30 +165,9 @@ class GeminiAgent {
this.setupFileSystem(config);
const session = await this.createAndStoreSession(config);
const configuredModel = (
config.getModel() ||
this.config.getModel() ||
''
).trim();
const modelId = configuredModel || 'default';
const modelName = configuredModel || modelId;
return {
sessionId: session.getId(),
models: {
currentModelId: modelId,
availableModels: [
{
modelId,
name: modelName,
description: null,
_meta: {
contextLimit: tokenLimit(modelId),
},
},
],
_meta: null,
},
};
}
@@ -348,20 +327,12 @@ class GeminiAgent {
const sessionId = config.getSessionId();
const geminiClient = config.getGeminiClient();
// Use GeminiClient to manage chat lifecycle properly
// This ensures geminiClient.chat is in sync with the session's chat
//
// Note: When loading a session, config.initialize() has already been called
// in newSessionConfig(), which in turn calls geminiClient.initialize().
// The GeminiClient.initialize() method checks config.getResumedSessionData()
// and automatically loads the conversation history into the chat instance.
// So we only need to initialize if it hasn't been done yet.
if (!geminiClient.isInitialized()) {
await geminiClient.initialize();
}
// Now get the chat instance that's managed by GeminiClient
const chat = geminiClient.getChat();
const history = conversation
? buildApiHistoryFromConversation(conversation)
: undefined;
const chat = history
? await geminiClient.startChat(history)
: await geminiClient.startChat();
const session = new Session(
sessionId,

View File

@@ -93,7 +93,6 @@ export type ModeInfo = z.infer<typeof modeInfoSchema>;
export type ModesData = z.infer<typeof modesDataSchema>;
export type AgentInfo = z.infer<typeof agentInfoSchema>;
export type ModelInfo = z.infer<typeof modelInfoSchema>;
export type PromptCapabilities = z.infer<typeof promptCapabilitiesSchema>;
@@ -255,26 +254,8 @@ export const authenticateUpdateSchema = z.object({
export type AuthenticateUpdate = z.infer<typeof authenticateUpdateSchema>;
export const acpMetaSchema = z.record(z.unknown()).nullable().optional();
export const modelIdSchema = z.string();
export const modelInfoSchema = z.object({
_meta: acpMetaSchema,
description: z.string().nullable().optional(),
modelId: modelIdSchema,
name: z.string(),
});
export const sessionModelStateSchema = z.object({
_meta: acpMetaSchema,
availableModels: z.array(modelInfoSchema),
currentModelId: modelIdSchema,
});
export const newSessionResponseSchema = z.object({
sessionId: z.string(),
models: sessionModelStateSchema,
});
export const loadSessionResponseSchema = z.null();
@@ -533,13 +514,6 @@ export const currentModeUpdateSchema = z.object({
export type CurrentModeUpdate = z.infer<typeof currentModeUpdateSchema>;
export const currentModelUpdateSchema = z.object({
sessionUpdate: z.literal('current_model_update'),
model: modelInfoSchema,
});
export type CurrentModelUpdate = z.infer<typeof currentModelUpdateSchema>;
export const sessionUpdateSchema = z.union([
z.object({
content: contentBlockSchema,
@@ -581,7 +555,6 @@ export const sessionUpdateSchema = z.union([
sessionUpdate: z.literal('plan'),
}),
currentModeUpdateSchema,
currentModelUpdateSchema,
availableCommandsUpdateSchema,
]);

View File

@@ -41,11 +41,9 @@ import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { z } from 'zod';
import { getErrorMessage } from '../../utils/errors.js';
import { normalizePartList } from '../../utils/nonInteractiveHelpers.js';
import {
handleSlashCommand,
getAvailableCommands,
type NonInteractiveSlashCommandResult,
} from '../../nonInteractiveCliCommands.js';
import type {
AvailableCommand,
@@ -65,6 +63,12 @@ import { PlanEmitter } from './emitters/PlanEmitter.js';
import { MessageEmitter } from './emitters/MessageEmitter.js';
import { SubAgentTracker } from './SubAgentTracker.js';
/**
* Built-in commands that are allowed in ACP integration mode.
* Only safe, read-only commands that don't require interactive UI.
*/
export const ALLOWED_BUILTIN_COMMANDS_FOR_ACP = ['init'];
/**
* Session represents an active conversation session with the AI model.
* It uses modular components for consistent event emission:
@@ -163,26 +167,24 @@ export class Session implements SessionContext {
const firstTextBlock = params.prompt.find((block) => block.type === 'text');
const inputText = firstTextBlock?.text || '';
let parts: Part[] | null;
let parts: Part[];
if (isSlashCommand(inputText)) {
// Handle slash command - uses default allowed commands (init, summary, compress)
// Handle slash command - allow specific built-in commands for ACP integration
const slashCommandResult = await handleSlashCommand(
inputText,
pendingSend,
this.config,
this.settings,
ALLOWED_BUILTIN_COMMANDS_FOR_ACP,
);
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' };
if (slashCommandResult) {
// Use the result from the slash command
parts = slashCommandResult as Part[];
} else {
// Slash command didn't return a prompt, continue with normal processing
parts = await this.#resolvePrompt(params.prompt, pendingSend.signal);
}
} else {
// Normal processing for non-slash commands
@@ -293,10 +295,11 @@ export class Session implements SessionContext {
async sendAvailableCommandsUpdate(): Promise<void> {
const abortController = new AbortController();
try {
// Use default allowed commands from getAvailableCommands
const slashCommands = await getAvailableCommands(
this.config,
this.settings,
abortController.signal,
ALLOWED_BUILTIN_COMMANDS_FOR_ACP,
);
// Convert SlashCommand[] to AvailableCommand[] format for ACP protocol
@@ -644,103 +647,6 @@ export class Session implements SessionContext {
}
}
/**
* Processes the result of a slash command execution.
*
* Supported result types in ACP mode:
* - submit_prompt: Submits content to the model
* - stream_messages: Streams multiple messages to the client (ACP-specific)
* - unsupported: Command cannot be executed in ACP mode
* - no_command: No command was found, use original prompt
*
* Note: 'message' type is not supported in ACP mode - commands should use
* 'stream_messages' instead for consistent async handling.
*
* @param result The result from handleSlashCommand
* @param originalPrompt The original prompt blocks
* @returns Parts to use for the prompt, or null if command was handled without needing model interaction
*/
async #processSlashCommandResult(
result: NonInteractiveSlashCommandResult,
originalPrompt: acp.ContentBlock[],
): Promise<Part[] | null> {
switch (result.type) {
case 'submit_prompt':
// Command wants to submit a prompt to the model
// Convert PartListUnion to Part[]
return normalizePartList(result.content);
case 'message': {
// 'message' type is not ideal for ACP mode, but we handle it for compatibility
// by converting it to a stream_messages-like notification
await this.client.sendCustomNotification('_qwencode/slash_command', {
sessionId: this.sessionId,
command: originalPrompt
.filter((block) => block.type === 'text')
.map((block) => (block.type === 'text' ? block.text : ''))
.join(' '),
messageType: result.messageType,
message: result.content || '',
});
if (result.messageType === 'error') {
// Throw error to stop execution
throw new Error(result.content || 'Slash command failed.');
}
// For info messages, return null to indicate command was handled
return null;
}
case 'stream_messages': {
// Command returns multiple messages via async generator (ACP-preferred)
const command = originalPrompt
.filter((block) => block.type === 'text')
.map((block) => (block.type === 'text' ? block.text : ''))
.join(' ');
// Stream all messages to the client
for await (const msg of result.messages) {
await this.client.sendCustomNotification('_qwencode/slash_command', {
sessionId: this.sessionId,
command,
messageType: msg.messageType,
message: msg.content,
});
// If we encounter an error message, throw after sending
if (msg.messageType === 'error') {
throw new Error(msg.content || 'Slash command failed.');
}
}
// All messages sent successfully, return null to indicate command was handled
return null;
}
case 'unsupported': {
// Command returned an unsupported result type
const unsupportedError = `Slash command not supported in ACP integration: ${result.reason}`;
throw new Error(unsupportedError);
}
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}`);
});
default: {
// Exhaustiveness check
const _exhaustive: never = result;
const unknownError = `Unknown slash command result type: ${(_exhaustive as NonInteractiveSlashCommandResult).type}`;
throw new Error(unknownError);
}
}
}
async #resolvePrompt(
message: acp.ContentBlock[],
abortSignal: AbortSignal,

View File

@@ -258,8 +258,6 @@ export default {
', Tab to change focus': ', Tab to change focus',
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.':
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.',
'The command "/{{command}}" is not supported in non-interactive mode.':
'The command "/{{command}}" is not supported in non-interactive mode.',
// ============================================================================
// Settings Labels
// ============================================================================
@@ -592,12 +590,6 @@ export default {
'No conversation found to summarize.': 'No conversation found to summarize.',
'Failed to generate project context summary: {{error}}':
'Failed to generate project context summary: {{error}}',
'Saved project summary to {{filePathForDisplay}}.':
'Saved project summary to {{filePathForDisplay}}.',
'Saving project summary...': 'Saving project summary...',
'Generating project summary...': 'Generating project summary...',
'Failed to generate summary - no text content received from LLM response':
'Failed to generate summary - no text content received from LLM response',
// ============================================================================
// Commands - Model

View File

@@ -260,8 +260,7 @@ export default {
', Tab to change focus': ', Tab для смены фокуса',
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.':
'Для применения изменений необходимо перезапустить Qwen Code. Нажмите r для выхода и применения изменений.',
'The command "/{{command}}" is not supported in non-interactive mode.':
'Команда "/{{command}}" не поддерживается в неинтерактивном режиме.',
// ============================================================================
// Метки настроек
// ============================================================================
@@ -605,12 +604,6 @@ export default {
'Не найдено диалогов для создания сводки.',
'Failed to generate project context summary: {{error}}':
'Не удалось сгенерировать сводку контекста проекта: {{error}}',
'Saved project summary to {{filePathForDisplay}}.':
'Сводка проекта сохранена в {{filePathForDisplay}}',
'Saving project summary...': 'Сохранение сводки проекта...',
'Generating project summary...': 'Генерация сводки проекта...',
'Failed to generate summary - no text content received from LLM response':
'Не удалось сгенерировать сводку - не получен текстовый контент из ответа LLM',
// ============================================================================
// Команды - Модель

View File

@@ -249,8 +249,6 @@ export default {
', Tab to change focus': 'Tab 切换焦点',
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.':
'要查看更改,必须重启 Qwen Code。按 r 退出并立即应用更改。',
'The command "/{{command}}" is not supported in non-interactive mode.':
'不支持在非交互模式下使用命令 "/{{command}}"。',
// ============================================================================
// Settings Labels
// ============================================================================
@@ -562,12 +560,6 @@ export default {
'No conversation found to summarize.': '未找到要总结的对话',
'Failed to generate project context summary: {{error}}':
'生成项目上下文摘要失败:{{error}}',
'Saved project summary to {{filePathForDisplay}}.':
'项目摘要已保存到 {{filePathForDisplay}}',
'Saving project summary...': '正在保存项目摘要...',
'Generating project summary...': '正在生成项目摘要...',
'Failed to generate summary - no text content received from LLM response':
'生成摘要失败 - 未从 LLM 响应中接收到文本内容',
// ============================================================================
// Commands - Model

View File

@@ -20,7 +20,8 @@ import type {
CLIControlSetModelRequest,
CLIMcpServerConfig,
} from '../../types.js';
import { getAvailableCommands } from '../../../nonInteractiveCliCommands.js';
import { CommandService } from '../../../services/CommandService.js';
import { BuiltinCommandLoader } from '../../../services/BuiltinCommandLoader.js';
import {
MCPServerConfig,
AuthProviderType,
@@ -406,7 +407,7 @@ export class SystemController extends BaseController {
}
/**
* Load slash command names using getAvailableCommands
* Load slash command names using CommandService
*
* @param signal - AbortSignal to respect for cancellation
* @returns Promise resolving to array of slash command names
@@ -417,14 +418,21 @@ export class SystemController extends BaseController {
}
try {
const commands = await getAvailableCommands(this.context.config, signal);
const service = await CommandService.create(
[new BuiltinCommandLoader(this.context.config)],
signal,
);
if (signal.aborted) {
return [];
}
// Extract command names and sort
return commands.map((cmd) => cmd.name).sort();
const names = new Set<string>();
const commands = service.getCommands();
for (const command of commands) {
names.add(command.name);
}
return Array.from(names).sort();
} catch (error) {
// Check if the error is due to abort
if (signal.aborted) {

View File

@@ -68,7 +68,6 @@ describe('runNonInteractive', () => {
let mockShutdownTelemetry: Mock;
let consoleErrorSpy: MockInstance;
let processStdoutSpy: MockInstance;
let processStderrSpy: MockInstance;
let mockGeminiClient: {
sendMessageStream: Mock;
getChatRecordingService: Mock;
@@ -87,9 +86,6 @@ describe('runNonInteractive', () => {
processStdoutSpy = vi
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);
processStderrSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
vi.spyOn(process, 'exit').mockImplementation((code) => {
throw new Error(`process.exit(${code}) called`);
});
@@ -143,8 +139,6 @@ describe('runNonInteractive', () => {
setModel: vi.fn(async (model: string) => {
currentModel = model;
}),
getExperimentalZedIntegration: vi.fn().mockReturnValue(false),
isInteractive: vi.fn().mockReturnValue(false),
} as unknown as Config;
mockSettings = {
@@ -858,7 +852,7 @@ describe('runNonInteractive', () => {
expect(processStdoutSpy).toHaveBeenCalledWith('Response from command');
});
it('should handle command that requires confirmation by returning early', async () => {
it('should throw FatalInputError if a command requires confirmation', async () => {
const mockCommand = {
name: 'confirm',
description: 'a command that needs confirmation',
@@ -870,16 +864,15 @@ describe('runNonInteractive', () => {
};
mockGetCommands.mockReturnValue([mockCommand]);
await runNonInteractive(
mockConfig,
mockSettings,
'/confirm',
'prompt-id-confirm',
);
// Should write error message to stderr
expect(processStderrSpy).toHaveBeenCalledWith(
'Shell command confirmation is not supported in non-interactive mode. Use YOLO mode or pre-approve commands.\n',
await expect(
runNonInteractive(
mockConfig,
mockSettings,
'/confirm',
'prompt-id-confirm',
),
).rejects.toThrow(
'Exiting due to a confirmation prompt requested by the command.',
);
});
@@ -916,30 +909,7 @@ describe('runNonInteractive', () => {
expect(processStdoutSpy).toHaveBeenCalledWith('Response to unknown');
});
it('should handle known but unsupported slash commands like /help by returning early', async () => {
// Mock a built-in command that exists but is not in the allowed list
const mockHelpCommand = {
name: 'help',
description: 'Show help',
kind: CommandKind.BUILT_IN,
action: vi.fn(),
};
mockGetCommands.mockReturnValue([mockHelpCommand]);
await runNonInteractive(
mockConfig,
mockSettings,
'/help',
'prompt-id-help',
);
// Should write error message to stderr
expect(processStderrSpy).toHaveBeenCalledWith(
'The command "/help" is not supported in non-interactive mode.\n',
);
});
it('should handle unhandled command result types by returning early with error', async () => {
it('should throw for unhandled command result types', async () => {
const mockCommand = {
name: 'noaction',
description: 'unhandled type',
@@ -950,16 +920,15 @@ describe('runNonInteractive', () => {
};
mockGetCommands.mockReturnValue([mockCommand]);
await runNonInteractive(
mockConfig,
mockSettings,
'/noaction',
'prompt-id-unhandled',
);
// Should write error message to stderr
expect(processStderrSpy).toHaveBeenCalledWith(
'Unknown command result type: unhandled\n',
await expect(
runNonInteractive(
mockConfig,
mockSettings,
'/noaction',
'prompt-id-unhandled',
),
).rejects.toThrow(
'Exiting due to command result that is not supported in non-interactive mode.',
);
});

View File

@@ -42,55 +42,6 @@ import {
computeUsageFromMetrics,
} from './utils/nonInteractiveHelpers.js';
/**
* Emits a final message for slash command results.
* Note: systemMessage should already be emitted before calling this function.
*/
async function emitNonInteractiveFinalMessage(params: {
message: string;
isError: boolean;
adapter?: JsonOutputAdapterInterface;
config: Config;
startTimeMs: number;
}): Promise<void> {
const { message, isError, adapter, config } = params;
if (!adapter) {
// Text output mode: write directly to stdout/stderr
const target = isError ? process.stderr : process.stdout;
target.write(`${message}\n`);
return;
}
// JSON output mode: emit assistant message and result
// (systemMessage should already be emitted by caller)
adapter.startAssistantMessage();
adapter.processEvent({
type: GeminiEventType.Content,
value: message,
} as unknown as Parameters<JsonOutputAdapterInterface['processEvent']>[0]);
adapter.finalizeAssistantMessage();
const metrics = uiTelemetryService.getMetrics();
const usage = computeUsageFromMetrics(metrics);
const outputFormat = config.getOutputFormat();
const stats =
outputFormat === OutputFormat.JSON
? uiTelemetryService.getMetrics()
: undefined;
adapter.emitResult({
isError,
durationMs: Date.now() - params.startTimeMs,
apiDurationMs: 0,
numTurns: 0,
errorMessage: isError ? message : undefined,
usage,
stats,
summary: message,
});
}
/**
* Provides optional overrides for `runNonInteractive` execution.
*
@@ -164,16 +115,6 @@ export async function runNonInteractive(
process.on('SIGINT', shutdownHandler);
process.on('SIGTERM', shutdownHandler);
// Emit systemMessage first (always the first message in JSON mode)
if (adapter) {
const systemMessage = await buildSystemMessage(
config,
sessionId,
permissionMode,
);
adapter.emitMessage(systemMessage);
}
let initialPartList: PartListUnion | null = extractPartsFromUserMessage(
options.userMessage,
);
@@ -187,45 +128,10 @@ export async function runNonInteractive(
config,
settings,
);
switch (slashCommandResult.type) {
case 'submit_prompt':
// A slash command can replace the prompt entirely; fall back to @-command processing otherwise.
initialPartList = slashCommandResult.content;
slashHandled = true;
break;
case 'message': {
// systemMessage already emitted above
await emitNonInteractiveFinalMessage({
message: slashCommandResult.content,
isError: slashCommandResult.messageType === 'error',
adapter,
config,
startTimeMs: startTime,
});
return;
}
case 'stream_messages':
throw new FatalInputError(
'Stream messages mode is not supported in non-interactive CLI',
);
case 'unsupported': {
await emitNonInteractiveFinalMessage({
message: slashCommandResult.reason,
isError: true,
adapter,
config,
startTimeMs: startTime,
});
return;
}
case 'no_command':
break;
default: {
const _exhaustive: never = slashCommandResult;
throw new FatalInputError(
`Unhandled slash command result type: ${(_exhaustive as { type: string }).type}`,
);
}
if (slashCommandResult) {
// A slash command can replace the prompt entirely; fall back to @-command processing otherwise.
initialPartList = slashCommandResult as PartListUnion;
slashHandled = true;
}
}
@@ -257,6 +163,15 @@ export async function runNonInteractive(
const initialParts = normalizePartList(initialPartList);
let currentMessages: Content[] = [{ role: 'user', parts: initialParts }];
if (adapter) {
const systemMessage = await buildSystemMessage(
config,
sessionId,
permissionMode,
);
adapter.emitMessage(systemMessage);
}
let isFirstTurn = true;
while (true) {
turnCount++;

View File

@@ -1,242 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { handleSlashCommand } from './nonInteractiveCliCommands.js';
import type { Config } from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from './config/settings.js';
import { CommandKind } from './ui/commands/types.js';
// Mock the CommandService
const mockGetCommands = vi.hoisted(() => vi.fn());
const mockCommandServiceCreate = vi.hoisted(() => vi.fn());
vi.mock('./services/CommandService.js', () => ({
CommandService: {
create: mockCommandServiceCreate,
},
}));
describe('handleSlashCommand', () => {
let mockConfig: Config;
let mockSettings: LoadedSettings;
let abortController: AbortController;
beforeEach(() => {
mockCommandServiceCreate.mockResolvedValue({
getCommands: mockGetCommands,
});
mockConfig = {
getExperimentalZedIntegration: vi.fn().mockReturnValue(false),
isInteractive: vi.fn().mockReturnValue(false),
getSessionId: vi.fn().mockReturnValue('test-session'),
getFolderTrustFeature: vi.fn().mockReturnValue(false),
getFolderTrust: vi.fn().mockReturnValue(false),
getProjectRoot: vi.fn().mockReturnValue('/test/project'),
storage: {},
} as unknown as Config;
mockSettings = {
system: { path: '', settings: {} },
systemDefaults: { path: '', settings: {} },
user: { path: '', settings: {} },
workspace: { path: '', settings: {} },
} as LoadedSettings;
abortController = new AbortController();
});
it('should return no_command for non-slash input', async () => {
const result = await handleSlashCommand(
'regular text',
abortController,
mockConfig,
mockSettings,
);
expect(result.type).toBe('no_command');
});
it('should return no_command for unknown slash commands', async () => {
mockGetCommands.mockReturnValue([]);
const result = await handleSlashCommand(
'/unknowncommand',
abortController,
mockConfig,
mockSettings,
);
expect(result.type).toBe('no_command');
});
it('should return unsupported for known built-in commands not in allowed list', async () => {
const mockHelpCommand = {
name: 'help',
description: 'Show help',
kind: CommandKind.BUILT_IN,
action: vi.fn(),
};
mockGetCommands.mockReturnValue([mockHelpCommand]);
const result = await handleSlashCommand(
'/help',
abortController,
mockConfig,
mockSettings,
[], // Empty allowed list
);
expect(result.type).toBe('unsupported');
if (result.type === 'unsupported') {
expect(result.reason).toContain('/help');
expect(result.reason).toContain('not supported');
}
});
it('should return unsupported for /help when using default allowed list', async () => {
const mockHelpCommand = {
name: 'help',
description: 'Show help',
kind: CommandKind.BUILT_IN,
action: vi.fn(),
};
mockGetCommands.mockReturnValue([mockHelpCommand]);
const result = await handleSlashCommand(
'/help',
abortController,
mockConfig,
mockSettings,
// Default allowed list: ['init', 'summary', 'compress']
);
expect(result.type).toBe('unsupported');
if (result.type === 'unsupported') {
expect(result.reason).toBe(
'The command "/help" is not supported in non-interactive mode.',
);
}
});
it('should execute allowed built-in commands', async () => {
const mockInitCommand = {
name: 'init',
description: 'Initialize project',
kind: CommandKind.BUILT_IN,
action: vi.fn().mockResolvedValue({
type: 'message',
messageType: 'info',
content: 'Project initialized',
}),
};
mockGetCommands.mockReturnValue([mockInitCommand]);
const result = await handleSlashCommand(
'/init',
abortController,
mockConfig,
mockSettings,
['init'], // init is in the allowed list
);
expect(result.type).toBe('message');
if (result.type === 'message') {
expect(result.content).toBe('Project initialized');
}
});
it('should execute file commands regardless of allowed list', async () => {
const mockFileCommand = {
name: 'custom',
description: 'Custom file command',
kind: CommandKind.FILE,
action: vi.fn().mockResolvedValue({
type: 'submit_prompt',
content: [{ text: 'Custom prompt' }],
}),
};
mockGetCommands.mockReturnValue([mockFileCommand]);
const result = await handleSlashCommand(
'/custom',
abortController,
mockConfig,
mockSettings,
[], // Empty allowed list, but FILE commands should still work
);
expect(result.type).toBe('submit_prompt');
if (result.type === 'submit_prompt') {
expect(result.content).toEqual([{ text: 'Custom prompt' }]);
}
});
it('should return unsupported for other built-in commands like /quit', async () => {
const mockQuitCommand = {
name: 'quit',
description: 'Quit application',
kind: CommandKind.BUILT_IN,
action: vi.fn(),
};
mockGetCommands.mockReturnValue([mockQuitCommand]);
const result = await handleSlashCommand(
'/quit',
abortController,
mockConfig,
mockSettings,
);
expect(result.type).toBe('unsupported');
if (result.type === 'unsupported') {
expect(result.reason).toContain('/quit');
expect(result.reason).toContain('not supported');
}
});
it('should handle command with no action', async () => {
const mockCommand = {
name: 'noaction',
description: 'Command without action',
kind: CommandKind.FILE,
// No action property
};
mockGetCommands.mockReturnValue([mockCommand]);
const result = await handleSlashCommand(
'/noaction',
abortController,
mockConfig,
mockSettings,
);
expect(result.type).toBe('no_command');
});
it('should return message when command returns void', async () => {
const mockCommand = {
name: 'voidcmd',
description: 'Command that returns void',
kind: CommandKind.FILE,
action: vi.fn().mockResolvedValue(undefined),
};
mockGetCommands.mockReturnValue([mockCommand]);
const result = await handleSlashCommand(
'/voidcmd',
abortController,
mockConfig,
mockSettings,
);
expect(result.type).toBe('message');
if (result.type === 'message') {
expect(result.content).toBe('Command executed successfully.');
expect(result.messageType).toBe('info');
}
});
});

View File

@@ -7,6 +7,7 @@
import type { PartListUnion } from '@google/genai';
import { parseSlashCommand } from './utils/commands.js';
import {
FatalInputError,
Logger,
uiTelemetryService,
type Config,
@@ -18,164 +19,10 @@ import {
CommandKind,
type CommandContext,
type SlashCommand,
type SlashCommandActionReturn,
} from './ui/commands/types.js';
import { createNonInteractiveUI } from './ui/noninteractive/nonInteractiveUi.js';
import type { LoadedSettings } from './config/settings.js';
import type { SessionStatsState } from './ui/contexts/SessionContext.js';
import { t } from './i18n/index.js';
/**
* Built-in commands that are allowed in non-interactive modes (CLI and ACP).
* Only safe, read-only commands that don't require interactive UI.
*
* These commands are:
* - init: Initialize project configuration
* - summary: Generate session summary
* - compress: Compress conversation history
*/
export const ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE = [
'init',
'summary',
'compress',
] as const;
/**
* Result of handling a slash command in non-interactive mode.
*
* Supported types:
* - 'submit_prompt': Submits content to the model (supports all modes)
* - 'message': Returns a single message (supports non-interactive JSON/text only)
* - 'stream_messages': Streams multiple messages (supports ACP only)
* - 'unsupported': Command cannot be executed in this mode
* - 'no_command': No command was found or executed
*/
export type NonInteractiveSlashCommandResult =
| {
type: 'submit_prompt';
content: PartListUnion;
}
| {
type: 'message';
messageType: 'info' | 'error';
content: string;
}
| {
type: 'stream_messages';
messages: AsyncGenerator<
{ messageType: 'info' | 'error'; content: string },
void,
unknown
>;
}
| {
type: 'unsupported';
reason: string;
originalType: string;
}
| {
type: 'no_command';
};
/**
* Converts a SlashCommandActionReturn to a NonInteractiveSlashCommandResult.
*
* Only the following result types are supported in non-interactive mode:
* - submit_prompt: Submits content to the model (all modes)
* - message: Returns a single message (non-interactive JSON/text only)
* - stream_messages: Streams multiple messages (ACP only)
*
* All other result types are converted to 'unsupported'.
*
* @param result The result from executing a slash command action
* @returns A NonInteractiveSlashCommandResult describing the outcome
*/
function handleCommandResult(
result: SlashCommandActionReturn,
): NonInteractiveSlashCommandResult {
switch (result.type) {
case 'submit_prompt':
return {
type: 'submit_prompt',
content: result.content,
};
case 'message':
return {
type: 'message',
messageType: result.messageType,
content: result.content,
};
case 'stream_messages':
return {
type: 'stream_messages',
messages: result.messages,
};
/**
* Currently return types below are never generated due to the
* whitelist of allowed slash commands in ACP and non-interactive mode.
* We'll try to add more supported return types in the future.
*/
case 'tool':
return {
type: 'unsupported',
reason:
'Tool execution from slash commands is not supported in non-interactive mode.',
originalType: 'tool',
};
case 'quit':
return {
type: 'unsupported',
reason:
'Quit command is not supported in non-interactive mode. The process will exit naturally after completion.',
originalType: 'quit',
};
case 'dialog':
return {
type: 'unsupported',
reason: `Dialog '${result.dialog}' cannot be opened in non-interactive mode.`,
originalType: 'dialog',
};
case 'load_history':
return {
type: 'unsupported',
reason:
'Loading history is not supported in non-interactive mode. Each invocation starts with a fresh context.',
originalType: 'load_history',
};
case 'confirm_shell_commands':
return {
type: 'unsupported',
reason:
'Shell command confirmation is not supported in non-interactive mode. Use YOLO mode or pre-approve commands.',
originalType: 'confirm_shell_commands',
};
case 'confirm_action':
return {
type: 'unsupported',
reason:
'Action confirmation is not supported in non-interactive mode. Commands requiring confirmation cannot be executed.',
originalType: 'confirm_action',
};
default: {
// Exhaustiveness check
const _exhaustive: never = result;
return {
type: 'unsupported',
reason: `Unknown command result type: ${(_exhaustive as SlashCommandActionReturn).type}`,
originalType: 'unknown',
};
}
}
}
/**
* Filters commands based on the allowed built-in command names.
@@ -215,146 +62,122 @@ function filterCommandsForNonInteractive(
* @param config The configuration object
* @param settings The loaded settings
* @param allowedBuiltinCommandNames Optional array of built-in command names that are
* allowed. Defaults to ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE (init, summary, compress).
* Pass an empty array to only allow file commands.
* @returns A Promise that resolves to a `NonInteractiveSlashCommandResult` describing
* the outcome of the command execution.
* allowed. If not provided or empty, only file commands are available.
* @returns A Promise that resolves to `PartListUnion` if a valid command is
* found and results in a prompt, or `undefined` otherwise.
* @throws {FatalInputError} if the command result is not supported in
* non-interactive mode.
*/
export const handleSlashCommand = async (
rawQuery: string,
abortController: AbortController,
config: Config,
settings: LoadedSettings,
allowedBuiltinCommandNames: string[] = [
...ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE,
],
): Promise<NonInteractiveSlashCommandResult> => {
allowedBuiltinCommandNames?: string[],
): Promise<PartListUnion | undefined> => {
const trimmed = rawQuery.trim();
if (!trimmed.startsWith('/')) {
return { type: 'no_command' };
return;
}
const isAcpMode = config.getExperimentalZedIntegration();
const isInteractive = config.isInteractive();
const executionMode = isAcpMode
? 'acp'
: isInteractive
? 'interactive'
: 'non_interactive';
const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []);
// Load all commands to check if the command exists but is not allowed
const allLoaders = [
new BuiltinCommandLoader(config),
new FileCommandLoader(config),
];
// Only load BuiltinCommandLoader if there are allowed built-in commands
const loaders =
allowedBuiltinSet.size > 0
? [new BuiltinCommandLoader(config), new FileCommandLoader(config)]
: [new FileCommandLoader(config)];
const commandService = await CommandService.create(
allLoaders,
loaders,
abortController.signal,
);
const allCommands = commandService.getCommands();
const commands = commandService.getCommands();
const filteredCommands = filterCommandsForNonInteractive(
allCommands,
commands,
allowedBuiltinSet,
);
// First, try to parse with filtered commands
const { commandToExecute, args } = parseSlashCommand(
rawQuery,
filteredCommands,
);
if (!commandToExecute) {
// Check if this is a known command that's just not allowed
const { commandToExecute: knownCommand } = parseSlashCommand(
rawQuery,
allCommands,
);
if (knownCommand) {
// Command exists but is not allowed in non-interactive mode
return {
type: 'unsupported',
reason: t(
'The command "/{{command}}" is not supported in non-interactive mode.',
{ command: knownCommand.name },
),
originalType: 'filtered_command',
if (commandToExecute) {
if (commandToExecute.action) {
// Not used by custom commands but may be in the future.
const sessionStats: SessionStatsState = {
sessionId: config?.getSessionId(),
sessionStartTime: new Date(),
metrics: uiTelemetryService.getMetrics(),
lastPromptTokenCount: 0,
promptCount: 1,
};
const logger = new Logger(config?.getSessionId() || '', config?.storage);
const context: CommandContext = {
services: {
config,
settings,
git: undefined,
logger,
},
ui: createNonInteractiveUI(),
session: {
stats: sessionStats,
sessionShellAllowlist: new Set(),
},
invocation: {
raw: trimmed,
name: commandToExecute.name,
args,
},
};
const result = await commandToExecute.action(context, args);
if (result) {
switch (result.type) {
case 'submit_prompt':
return result.content;
case 'confirm_shell_commands':
// This result indicates a command attempted to confirm shell commands.
// However note that currently, ShellTool is excluded in non-interactive
// mode unless 'YOLO mode' is active, so confirmation actually won't
// occur because of YOLO mode.
// This ensures that if a command *does* request confirmation (e.g.
// in the future with more granular permissions), it's handled appropriately.
throw new FatalInputError(
'Exiting due to a confirmation prompt requested by the command.',
);
default:
throw new FatalInputError(
'Exiting due to command result that is not supported in non-interactive mode.',
);
}
}
}
return { type: 'no_command' };
}
if (!commandToExecute.action) {
return { type: 'no_command' };
}
// Not used by custom commands but may be in the future.
const sessionStats: SessionStatsState = {
sessionId: config?.getSessionId(),
sessionStartTime: new Date(),
metrics: uiTelemetryService.getMetrics(),
lastPromptTokenCount: 0,
promptCount: 1,
};
const logger = new Logger(config?.getSessionId() || '', config?.storage);
const context: CommandContext = {
executionMode,
services: {
config,
settings,
git: undefined,
logger,
},
ui: createNonInteractiveUI(),
session: {
stats: sessionStats,
sessionShellAllowlist: new Set(),
},
invocation: {
raw: trimmed,
name: commandToExecute.name,
args,
},
};
const result = await commandToExecute.action(context, args);
if (!result) {
// Command executed but returned no result (e.g., void return)
return {
type: 'message',
messageType: 'info',
content: 'Command executed successfully.',
};
}
// Handle different result types
return handleCommandResult(result);
return;
};
/**
* Retrieves all available slash commands for the current configuration.
*
* @param config The configuration object
* @param settings The loaded settings
* @param abortSignal Signal to cancel the loading process
* @param allowedBuiltinCommandNames Optional array of built-in command names that are
* allowed. Defaults to ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE (init, summary, compress).
* Pass an empty array to only include file commands.
* allowed. If not provided or empty, only file commands are available.
* @returns A Promise that resolves to an array of SlashCommand objects
*/
export const getAvailableCommands = async (
config: Config,
settings: LoadedSettings,
abortSignal: AbortSignal,
allowedBuiltinCommandNames: string[] = [
...ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE,
],
allowedBuiltinCommandNames?: string[],
): Promise<SlashCommand[]> => {
try {
const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []);

View File

@@ -19,9 +19,7 @@ export const compressCommand: SlashCommand = {
kind: CommandKind.BUILT_IN,
action: async (context) => {
const { ui } = context;
const executionMode = context.executionMode ?? 'interactive';
if (executionMode === 'interactive' && ui.pendingItem) {
if (ui.pendingItem) {
ui.addItem(
{
type: MessageType.ERROR,
@@ -42,80 +40,13 @@ export const compressCommand: SlashCommand = {
},
};
const config = context.services.config;
const geminiClient = config?.getGeminiClient();
if (!config || !geminiClient) {
return {
type: 'message',
messageType: 'error',
content: t('Config not loaded.'),
};
}
const doCompress = async () => {
const promptId = `compress-${Date.now()}`;
return await geminiClient.tryCompressChat(promptId, true);
};
if (executionMode === 'acp') {
const messages = async function* () {
try {
yield {
messageType: 'info' as const,
content: 'Compressing context...',
};
const compressed = await doCompress();
if (!compressed) {
yield {
messageType: 'error' as const,
content: t('Failed to compress chat history.'),
};
return;
}
yield {
messageType: 'info' as const,
content: `Context compressed (${compressed.originalTokenCount} -> ${compressed.newTokenCount}).`,
};
} catch (e) {
yield {
messageType: 'error' as const,
content: t('Failed to compress chat history: {{error}}', {
error: e instanceof Error ? e.message : String(e),
}),
};
}
};
return { type: 'stream_messages', messages: messages() };
}
try {
if (executionMode === 'interactive') {
ui.setPendingItem(pendingMessage);
}
const compressed = await doCompress();
if (!compressed) {
if (executionMode === 'interactive') {
ui.addItem(
{
type: MessageType.ERROR,
text: t('Failed to compress chat history.'),
},
Date.now(),
);
return;
}
return {
type: 'message',
messageType: 'error',
content: t('Failed to compress chat history.'),
};
}
if (executionMode === 'interactive') {
ui.setPendingItem(pendingMessage);
const promptId = `compress-${Date.now()}`;
const compressed = await context.services.config
?.getGeminiClient()
?.tryCompressChat(promptId, true);
if (compressed) {
ui.addItem(
{
type: MessageType.COMPRESSION,
@@ -128,39 +59,27 @@ export const compressCommand: SlashCommand = {
} as HistoryItemCompression,
Date.now(),
);
return;
}
return {
type: 'message',
messageType: 'info',
content: `Context compressed (${compressed.originalTokenCount} -> ${compressed.newTokenCount}).`,
};
} catch (e) {
if (executionMode === 'interactive') {
} else {
ui.addItem(
{
type: MessageType.ERROR,
text: t('Failed to compress chat history: {{error}}', {
error: e instanceof Error ? e.message : String(e),
}),
text: t('Failed to compress chat history.'),
},
Date.now(),
);
return;
}
return {
type: 'message',
messageType: 'error',
content: t('Failed to compress chat history: {{error}}', {
error: e instanceof Error ? e.message : String(e),
}),
};
} catch (e) {
ui.addItem(
{
type: MessageType.ERROR,
text: t('Failed to compress chat history: {{error}}', {
error: e instanceof Error ? e.message : String(e),
}),
},
Date.now(),
);
} finally {
if (executionMode === 'interactive') {
ui.setPendingItem(null);
}
ui.setPendingItem(null);
}
},
};

View File

@@ -26,8 +26,6 @@ export const summaryCommand: SlashCommand = {
action: async (context): Promise<SlashCommandActionReturn> => {
const { config } = context.services;
const { ui } = context;
const executionMode = context.executionMode ?? 'interactive';
if (!config) {
return {
type: 'message',
@@ -45,8 +43,8 @@ export const summaryCommand: SlashCommand = {
};
}
// Check if already generating summary (interactive UI only)
if (executionMode === 'interactive' && ui.pendingItem) {
// Check if already generating summary
if (ui.pendingItem) {
ui.addItem(
{
type: 'error' as const,
@@ -65,22 +63,29 @@ export const summaryCommand: SlashCommand = {
};
}
const getChatHistory = () => {
try {
// Get the current chat history
const chat = geminiClient.getChat();
return chat.getHistory();
};
const history = chat.getHistory();
const validateChatHistory = (
history: ReturnType<typeof getChatHistory>,
) => {
if (history.length <= 2) {
throw new Error(t('No conversation found to summarize.'));
return {
type: 'message',
messageType: 'info',
content: t('No conversation found to summarize.'),
};
}
};
const generateSummaryMarkdown = async (
history: ReturnType<typeof getChatHistory>,
): Promise<string> => {
// Show loading state
const pendingMessage: HistoryItemSummary = {
type: 'summary',
summary: {
isPending: true,
stage: 'generating',
},
};
ui.setPendingItem(pendingMessage);
// Build the conversation context for summary generation
const conversationContext = history.map((message) => ({
role: message.role,
@@ -116,21 +121,19 @@ export const summaryCommand: SlashCommand = {
if (!markdownSummary) {
throw new Error(
t(
'Failed to generate summary - no text content received from LLM response',
),
'Failed to generate summary - no text content received from LLM response',
);
}
return markdownSummary;
};
// Update loading message to show saving progress
ui.setPendingItem({
type: 'summary',
summary: {
isPending: true,
stage: 'saving',
},
});
const saveSummaryToDisk = async (
markdownSummary: string,
): Promise<{
filePathForDisplay: string;
fullPath: string;
}> => {
// Ensure .qwen directory exists
const projectRoot = config.getProjectRoot();
const qwenDir = path.join(projectRoot, '.qwen');
@@ -152,163 +155,45 @@ export const summaryCommand: SlashCommand = {
await fsPromises.writeFile(summaryPath, summaryContent, 'utf8');
return {
filePathForDisplay: '.qwen/PROJECT_SUMMARY.md',
fullPath: summaryPath,
};
};
const emitInteractivePending = (stage: 'generating' | 'saving') => {
if (executionMode !== 'interactive') {
return;
}
const pendingMessage: HistoryItemSummary = {
type: 'summary',
summary: {
isPending: true,
stage,
},
};
ui.setPendingItem(pendingMessage);
};
const completeInteractive = (filePathForDisplay: string) => {
if (executionMode !== 'interactive') {
return;
}
// Clear pending item and show success message
ui.setPendingItem(null);
const completedSummaryItem: HistoryItemSummary = {
type: 'summary',
summary: {
isPending: false,
stage: 'completed',
filePath: filePathForDisplay,
filePath: '.qwen/PROJECT_SUMMARY.md',
},
};
ui.addItem(completedSummaryItem, Date.now());
};
const formatErrorMessage = (error: unknown): string =>
t('Failed to generate project context summary: {{error}}', {
error: error instanceof Error ? error.message : String(error),
});
const failInteractive = (error: unknown) => {
if (executionMode !== 'interactive') {
return;
}
return {
type: 'message',
messageType: 'info',
content: '', // Empty content since we show the message in UI component
};
} catch (error) {
// Clear pending item on error
ui.setPendingItem(null);
ui.addItem(
{
type: 'error' as const,
text: `${formatErrorMessage(error)}`,
text: `${t(
'Failed to generate project context summary: {{error}}',
{
error: error instanceof Error ? error.message : String(error),
},
)}`,
},
Date.now(),
);
};
const formatSuccessMessage = (filePathForDisplay: string): string =>
t('Saved project summary to {{filePathForDisplay}}.', {
filePathForDisplay,
});
const returnNoConversationMessage = (): SlashCommandActionReturn => {
const msg = t('No conversation found to summarize.');
if (executionMode === 'acp') {
const messages = async function* () {
yield {
messageType: 'info' as const,
content: msg,
};
};
return {
type: 'stream_messages',
messages: messages(),
};
}
return {
type: 'message',
messageType: 'info',
content: msg,
};
};
const executeSummaryGeneration = async (
history: ReturnType<typeof getChatHistory>,
): Promise<{
markdownSummary: string;
filePathForDisplay: string;
}> => {
emitInteractivePending('generating');
const markdownSummary = await generateSummaryMarkdown(history);
emitInteractivePending('saving');
const { filePathForDisplay } = await saveSummaryToDisk(markdownSummary);
completeInteractive(filePathForDisplay);
return { markdownSummary, filePathForDisplay };
};
// Validate chat history once at the beginning
const history = getChatHistory();
try {
validateChatHistory(history);
} catch (_error) {
return returnNoConversationMessage();
}
if (executionMode === 'acp') {
const messages = async function* () {
try {
yield {
messageType: 'info' as const,
content: t('Generating project summary...'),
};
const { filePathForDisplay } =
await executeSummaryGeneration(history);
yield {
messageType: 'info' as const,
content: formatSuccessMessage(filePathForDisplay),
};
} catch (error) {
failInteractive(error);
yield {
messageType: 'error' as const,
content: formatErrorMessage(error),
};
}
};
return {
type: 'stream_messages',
messages: messages(),
};
}
try {
const { filePathForDisplay } = await executeSummaryGeneration(history);
if (executionMode === 'non_interactive') {
return {
type: 'message',
messageType: 'info',
content: formatSuccessMessage(filePathForDisplay),
};
}
// Interactive mode: UI components already display progress and completion.
return {
type: 'message',
messageType: 'info',
content: '',
};
} catch (error) {
failInteractive(error);
return {
type: 'message',
messageType: 'error',
content: formatErrorMessage(error),
content: t('Failed to generate project context summary: {{error}}', {
error: error instanceof Error ? error.message : String(error),
}),
};
}
},

View File

@@ -22,14 +22,6 @@ import type {
// Grouped dependencies for clarity and easier mocking
export interface CommandContext {
/**
* Execution mode for the current invocation.
*
* - interactive: React/Ink UI mode
* - non_interactive: non-interactive CLI mode (text/json)
* - acp: ACP/Zed integration mode
*/
executionMode?: 'interactive' | 'non_interactive' | 'acp';
// Invocation properties for when commands are called.
invocation?: {
/** The raw, untrimmed input string from the user. */
@@ -116,19 +108,6 @@ export interface MessageActionReturn {
content: string;
}
/**
* The return type for a command action that streams multiple messages.
* Used for long-running operations that need to send progress updates.
*/
export interface StreamMessagesActionReturn {
type: 'stream_messages';
messages: AsyncGenerator<
{ messageType: 'info' | 'error'; content: string },
void,
unknown
>;
}
/**
* The return type for a command action that needs to open a dialog.
*/
@@ -195,7 +174,6 @@ export interface ConfirmActionReturn {
export type SlashCommandActionReturn =
| ToolActionReturn
| MessageActionReturn
| StreamMessagesActionReturn
| QuitActionReturn
| OpenDialogActionReturn
| LoadHistoryActionReturn

View File

@@ -520,13 +520,6 @@ export const useSlashCommandProcessor = (
true,
);
}
case 'stream_messages': {
// stream_messages is only used in ACP/Zed integration mode
// and should not be returned in interactive UI mode
throw new Error(
'stream_messages result type is not supported in interactive mode',
);
}
default: {
const unhandled: never = result;
throw new Error(

View File

@@ -7,15 +7,76 @@
import { useCallback } from 'react';
import { useStdin } from 'ink';
import type { EditorType } from '@qwen-code/qwen-code-core';
import { spawnSync } from 'child_process';
import { spawnSync, execSync } from 'child_process';
import { useSettings } from '../contexts/SettingsContext.js';
/**
* Editor command configurations for different platforms.
* Each editor can have multiple possible command names, listed in order of preference.
*/
const editorCommands: Record<
EditorType,
{ win32: string[]; default: string[] }
> = {
vscode: { win32: ['code.cmd'], default: ['code'] },
vscodium: { win32: ['codium.cmd'], default: ['codium'] },
windsurf: { win32: ['windsurf'], default: ['windsurf'] },
cursor: { win32: ['cursor'], default: ['cursor'] },
vim: { win32: ['vim'], default: ['vim'] },
neovim: { win32: ['nvim'], default: ['nvim'] },
zed: { win32: ['zed'], default: ['zed', 'zeditor'] },
emacs: { win32: ['emacs.exe'], default: ['emacs'] },
trae: { win32: ['trae'], default: ['trae'] },
};
/**
* Cache for command existence checks to avoid repeated execSync calls.
*/
const commandExistsCache = new Map<string, boolean>();
/**
* Check if a command exists in the system.
* Results are cached to improve performance in test environments.
*/
function commandExists(cmd: string): boolean {
if (commandExistsCache.has(cmd)) {
return commandExistsCache.get(cmd)!;
}
try {
execSync(
process.platform === 'win32' ? `where.exe ${cmd}` : `command -v ${cmd}`,
{ stdio: 'ignore' },
);
commandExistsCache.set(cmd, true);
return true;
} catch {
commandExistsCache.set(cmd, false);
return false;
}
}
/**
* Get the actual executable command for an editor type.
*/
function getExecutableCommand(editorType: EditorType): string {
const commandConfig = editorCommands[editorType];
const commands =
process.platform === 'win32' ? commandConfig.win32 : commandConfig.default;
// Try to find the first available command
const availableCommand = commands.find((cmd) => commandExists(cmd));
// Return the first available command, or fall back to the last one in the list
return availableCommand || commands[commands.length - 1];
}
/**
* Determines the editor command to use based on user preferences and platform.
*/
function getEditorCommand(preferredEditor?: EditorType): string {
if (preferredEditor) {
return preferredEditor;
return getExecutableCommand(preferredEditor);
}
// Platform-specific defaults with UI preference for macOS
@@ -63,8 +124,14 @@ export function useLaunchEditor() {
try {
setRawMode?.(false);
// On Windows, .cmd and .bat files need shell: true
const needsShell =
process.platform === 'win32' &&
(editorCommand.endsWith('.cmd') || editorCommand.endsWith('.bat'));
const { status, error } = spawnSync(editorCommand, editorArgs, {
stdio: 'inherit',
shell: needsShell,
});
if (error) throw error;

View File

@@ -35,33 +35,22 @@ import {
} from './nonInteractiveHelpers.js';
// Mock dependencies
vi.mock('../nonInteractiveCliCommands.js', () => ({
getAvailableCommands: vi
.fn()
.mockImplementation(
async (
_config: unknown,
_signal: AbortSignal,
allowedBuiltinCommandNames?: string[],
) => {
const allowedSet = new Set(allowedBuiltinCommandNames ?? []);
const allCommands = [
{ name: 'help', kind: 'built-in' },
{ name: 'commit', kind: 'file' },
{ name: 'memory', kind: 'built-in' },
{ name: 'init', kind: 'built-in' },
{ name: 'summary', kind: 'built-in' },
{ name: 'compress', kind: 'built-in' },
];
vi.mock('../services/CommandService.js', () => ({
CommandService: {
create: vi.fn().mockResolvedValue({
getCommands: vi
.fn()
.mockReturnValue([
{ name: 'help' },
{ name: 'commit' },
{ name: 'memory' },
]),
}),
},
}));
// Filter commands: always include file commands, only include allowed built-in commands
return allCommands.filter(
(cmd) =>
cmd.kind === 'file' ||
(cmd.kind === 'built-in' && allowedSet.has(cmd.name)),
);
},
),
vi.mock('../services/BuiltinCommandLoader.js', () => ({
BuiltinCommandLoader: vi.fn().mockImplementation(() => ({})),
}));
vi.mock('../ui/utils/computeStats.js', () => ({
@@ -522,12 +511,10 @@ describe('buildSystemMessage', () => {
});
it('should build system message with all fields', async () => {
const allowedBuiltinCommands = ['init', 'summary', 'compress'];
const result = await buildSystemMessage(
mockConfig,
'test-session-id',
'auto' as PermissionMode,
allowedBuiltinCommands,
);
expect(result).toEqual({
@@ -543,7 +530,7 @@ describe('buildSystemMessage', () => {
],
model: 'test-model',
permission_mode: 'auto',
slash_commands: ['commit', 'compress', 'init', 'summary'],
slash_commands: ['commit', 'help', 'memory'],
qwen_code_version: '1.0.0',
agents: [],
});
@@ -559,7 +546,6 @@ describe('buildSystemMessage', () => {
config,
'test-session-id',
'auto' as PermissionMode,
['init', 'summary'],
);
expect(result.tools).toEqual([]);
@@ -575,7 +561,6 @@ describe('buildSystemMessage', () => {
config,
'test-session-id',
'auto' as PermissionMode,
['init', 'summary'],
);
expect(result.mcp_servers).toEqual([]);
@@ -591,37 +576,10 @@ describe('buildSystemMessage', () => {
config,
'test-session-id',
'auto' as PermissionMode,
['init', 'summary'],
);
expect(result.qwen_code_version).toBe('unknown');
});
it('should only include allowed built-in commands and all file commands', async () => {
const allowedBuiltinCommands = ['init', 'summary'];
const result = await buildSystemMessage(
mockConfig,
'test-session-id',
'auto' as PermissionMode,
allowedBuiltinCommands,
);
// Should include: 'commit' (FILE), 'init' (BUILT_IN, allowed), 'summary' (BUILT_IN, allowed)
// Should NOT include: 'help', 'memory', 'compress' (BUILT_IN but not in allowed set)
expect(result.slash_commands).toEqual(['commit', 'init', 'summary']);
});
it('should include only file commands when no built-in commands are allowed', async () => {
const result = await buildSystemMessage(
mockConfig,
'test-session-id',
'auto' as PermissionMode,
[], // Empty array - no built-in commands allowed
);
// Should only include 'commit' (FILE command)
expect(result.slash_commands).toEqual(['commit']);
});
});
describe('createTaskToolProgressHandler', () => {

View File

@@ -25,9 +25,10 @@ import type {
PermissionMode,
CLISystemMessage,
} from '../nonInteractive/types.js';
import { CommandService } from '../services/CommandService.js';
import { BuiltinCommandLoader } from '../services/BuiltinCommandLoader.js';
import type { JsonOutputAdapterInterface } from '../nonInteractive/io/BaseJsonOutputAdapter.js';
import { computeSessionStats } from '../ui/utils/computeStats.js';
import { getAvailableCommands } from '../nonInteractiveCliCommands.js';
/**
* Normalizes various part list formats into a consistent Part[] array.
@@ -186,27 +187,24 @@ export function computeUsageFromMetrics(metrics: SessionMetrics): Usage {
}
/**
* Load slash command names using getAvailableCommands
* Load slash command names using CommandService
*
* @param config - Config instance
* @param allowedBuiltinCommandNames - Optional array of allowed built-in command names.
* If not provided, uses the default from getAvailableCommands.
* @returns Promise resolving to array of slash command names
*/
async function loadSlashCommandNames(
config: Config,
allowedBuiltinCommandNames?: string[],
): Promise<string[]> {
async function loadSlashCommandNames(config: Config): Promise<string[]> {
const controller = new AbortController();
try {
const commands = await getAvailableCommands(
config,
const service = await CommandService.create(
[new BuiltinCommandLoader(config)],
controller.signal,
allowedBuiltinCommandNames,
);
// Extract command names and sort
return commands.map((cmd) => cmd.name).sort();
const names = new Set<string>();
const commands = service.getCommands();
for (const command of commands) {
names.add(command.name);
}
return Array.from(names).sort();
} catch (error) {
if (config.getDebugMode()) {
console.error(
@@ -235,15 +233,12 @@ async function loadSlashCommandNames(
* @param config - Config instance
* @param sessionId - Session identifier
* @param permissionMode - Current permission/approval mode
* @param allowedBuiltinCommandNames - Optional array of allowed built-in command names.
* If not provided, defaults to empty array (only file commands will be included).
* @returns Promise resolving to CLISystemMessage
*/
export async function buildSystemMessage(
config: Config,
sessionId: string,
permissionMode: PermissionMode,
allowedBuiltinCommandNames?: string[],
): Promise<CLISystemMessage> {
const toolRegistry = config.getToolRegistry();
const tools = toolRegistry ? toolRegistry.getAllToolNames() : [];
@@ -256,11 +251,8 @@ export async function buildSystemMessage(
}))
: [];
// Load slash commands with filtering based on allowed built-in commands
const slashCommands = await loadSlashCommandNames(
config,
allowedBuiltinCommandNames,
);
// Load slash commands
const slashCommands = await loadSlashCommandNames(config);
// Load subagent names from config
let agentNames: string[] = [];

View File

@@ -272,6 +272,8 @@ export class Query implements AsyncIterable<SDKMessage> {
// Get only successfully connected SDK servers for CLI
const sdkMcpServersForCli = this.getSdkMcpServersForCli();
const mcpServersForCli = this.getMcpServersForCli();
logger.debug('SDK MCP servers for CLI:', sdkMcpServersForCli);
logger.debug('External MCP servers for CLI:', mcpServersForCli);
await this.sendControlRequest(ControlRequestType.INITIALIZE, {
hooks: null,
@@ -627,11 +629,6 @@ export class Query implements AsyncIterable<SDKMessage> {
return Promise.reject(new Error('Query is closed'));
}
if (subtype !== ControlRequestType.INITIALIZE) {
// Ensure all other control requests get processed after initialization
await this.initialized;
}
const requestId = randomUUID();
const request: CLIControlRequest = {

View File

@@ -50,9 +50,6 @@ vi.mock('vscode', () => ({
registerTextDocumentContentProvider: vi.fn(),
onDidChangeWorkspaceFolders: vi.fn(),
onDidGrantWorkspaceTrust: vi.fn(),
registerFileSystemProvider: vi.fn(() => ({
dispose: vi.fn(),
})),
},
commands: {
registerCommand: vi.fn(),

View File

@@ -16,7 +16,6 @@ import {
} from '@qwen-code/qwen-code-core/src/ide/detect-ide.js';
import { WebViewProvider } from './webview/WebViewProvider.js';
import { registerNewCommands } from './commands/index.js';
import { ReadonlyFileSystemProvider } from './services/readonlyFileSystemProvider.js';
const CLI_IDE_COMPANION_IDENTIFIER = 'qwenlm.qwen-code-vscode-ide-companion';
const INFO_MESSAGE_SHOWN_KEY = 'qwenCodeInfoMessageShown';
@@ -111,19 +110,6 @@ export async function activate(context: vscode.ExtensionContext) {
checkForUpdates(context, log);
// Create and register readonly file system provider
// The provider registers itself as a singleton in the constructor
const readonlyProvider = new ReadonlyFileSystemProvider();
context.subscriptions.push(
vscode.workspace.registerFileSystemProvider(
ReadonlyFileSystemProvider.getScheme(),
readonlyProvider,
{ isCaseSensitive: true, isReadonly: true },
),
readonlyProvider,
);
log('Readonly file system provider registered');
const diffContentProvider = new DiffContentProvider();
const diffManager = new DiffManager(
log,

View File

@@ -38,10 +38,6 @@ vi.mock('node:os', async (importOriginal) => {
};
});
vi.mock('@qwen-code/qwen-code-core/src/ide/detect-ide.js', () => ({
detectIdeFromEnv: vi.fn(() => ({ name: 'vscode', displayName: 'VS Code' })),
}));
const vscodeMock = vi.hoisted(() => ({
workspace: {
workspaceFolders: [

View File

@@ -146,8 +146,6 @@ export class AcpConnection {
console.error(
`[ACP qwen] Process exited with code: ${code}, signal: ${signal}`,
);
// Clear pending requests when process exits
this.pendingRequests.clear();
});
// Wait for process to start

View File

@@ -8,7 +8,6 @@ import type {
AcpSessionUpdate,
AcpPermissionRequest,
AuthenticateUpdateNotification,
ModelInfo,
} from '../types/acpTypes.js';
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
import { QwenSessionReader, type QwenSession } from './qwenSessionReader.js';
@@ -18,7 +17,6 @@ import type {
PlanEntry,
ToolCallUpdateData,
QwenAgentCallbacks,
UsageStatsPayload,
} from '../types/chatTypes.js';
import {
QwenConnectionHandler,
@@ -26,7 +24,6 @@ import {
} from '../services/qwenConnectionHandler.js';
import { QwenSessionUpdateHandler } from './qwenSessionUpdateHandler.js';
import { authMethod } from '../types/acpTypes.js';
import { extractModelInfoFromNewSessionResult } from '../utils/acpModelInfo.js';
import { isAuthenticationRequiredError } from '../utils/authErrors.js';
import { handleAuthenticateUpdate } from '../utils/authNotificationHandler.js';
@@ -198,16 +195,12 @@ export class QwenAgentManager {
options?: AgentConnectOptions,
): Promise<QwenConnectionResult> {
this.currentWorkingDir = workingDir;
const res = await this.connectionHandler.connect(
return this.connectionHandler.connect(
this.connection,
workingDir,
cliEntryPath,
options,
);
if (res.modelInfo && this.callbacks.onModelInfo) {
this.callbacks.onModelInfo(res.modelInfo);
}
return res;
}
/**
@@ -1098,10 +1091,9 @@ export class QwenAgentManager {
this.sessionCreateInFlight = (async () => {
try {
let newSessionResult: unknown;
// Try to create a new ACP session. If Qwen asks for auth, let it handle authentication.
try {
newSessionResult = await this.connection.newSession(workingDir);
await this.connection.newSession(workingDir);
} catch (err) {
const requiresAuth = isAuthenticationRequiredError(err);
@@ -1123,7 +1115,7 @@ export class QwenAgentManager {
);
// Add a slight delay to ensure auth state is settled
await new Promise((resolve) => setTimeout(resolve, 300));
newSessionResult = await this.connection.newSession(workingDir);
await this.connection.newSession(workingDir);
} catch (reauthErr) {
console.error(
'[QwenAgentManager] Re-authentication failed:',
@@ -1135,13 +1127,6 @@ export class QwenAgentManager {
throw err;
}
}
const modelInfo =
extractModelInfoFromNewSessionResult(newSessionResult);
if (modelInfo && this.callbacks.onModelInfo) {
this.callbacks.onModelInfo(modelInfo);
}
const newSessionId = this.connection.currentSessionId;
console.log(
'[QwenAgentManager] New session created with ID:',
@@ -1272,22 +1257,6 @@ export class QwenAgentManager {
this.sessionUpdateHandler.updateCallbacks(this.callbacks);
}
/**
* Register callback for usage metadata updates
*/
onUsageUpdate(callback: (stats: UsageStatsPayload) => void): void {
this.callbacks.onUsageUpdate = callback;
this.sessionUpdateHandler.updateCallbacks(this.callbacks);
}
/**
* Register callback for model info updates
*/
onModelInfo(callback: (info: ModelInfo) => void): void {
this.callbacks.onModelInfo = callback;
this.sessionUpdateHandler.updateCallbacks(this.callbacks);
}
/**
* Disconnect
*/

View File

@@ -13,13 +13,10 @@
import type { AcpConnection } from './acpConnection.js';
import { isAuthenticationRequiredError } from '../utils/authErrors.js';
import { authMethod } from '../types/acpTypes.js';
import { extractModelInfoFromNewSessionResult } from '../utils/acpModelInfo.js';
import type { ModelInfo } from '../types/acpTypes.js';
export interface QwenConnectionResult {
sessionCreated: boolean;
requiresAuth: boolean;
modelInfo?: ModelInfo;
}
/**
@@ -47,7 +44,6 @@ export class QwenConnectionHandler {
const autoAuthenticate = options?.autoAuthenticate ?? true;
let sessionCreated = false;
let requiresAuth = false;
let modelInfo: ModelInfo | undefined;
// Build extra CLI arguments (only essential parameters)
const extraArgs: string[] = [];
@@ -70,15 +66,13 @@ export class QwenConnectionHandler {
console.log(
'[QwenAgentManager] Creating new session (letting CLI handle authentication)...',
);
const newSessionResult = await this.newSessionWithRetry(
await this.newSessionWithRetry(
connection,
workingDir,
3,
authMethod,
autoAuthenticate,
);
modelInfo =
extractModelInfoFromNewSessionResult(newSessionResult) || undefined;
console.log('[QwenAgentManager] New session created successfully');
sessionCreated = true;
} catch (sessionError) {
@@ -105,7 +99,7 @@ export class QwenConnectionHandler {
console.log(`\n========================================`);
console.log(`[QwenAgentManager] ✅ CONNECT() COMPLETED SUCCESSFULLY`);
console.log(`========================================\n`);
return { sessionCreated, requiresAuth, modelInfo };
return { sessionCreated, requiresAuth };
}
/**
@@ -121,15 +115,15 @@ export class QwenConnectionHandler {
maxRetries: number,
authMethod: string,
autoAuthenticate: boolean,
): Promise<unknown> {
): Promise<void> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
console.log(
`[QwenAgentManager] Creating session (attempt ${attempt}/${maxRetries})...`,
);
const res = await connection.newSession(workingDir);
await connection.newSession(workingDir);
console.log('[QwenAgentManager] Session created successfully');
return res;
return;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
@@ -161,11 +155,11 @@ export class QwenConnectionHandler {
'[QwenAgentManager] newSessionWithRetry Authentication successful',
);
// Retry immediately after successful auth
const res = await connection.newSession(workingDir);
await connection.newSession(workingDir);
console.log(
'[QwenAgentManager] Session created successfully after auth',
);
return res;
return;
} catch (authErr) {
console.error(
'[QwenAgentManager] Re-authentication failed:',
@@ -186,7 +180,5 @@ export class QwenConnectionHandler {
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw new Error('Session creation failed unexpectedly');
}
}

View File

@@ -10,12 +10,9 @@
* Handles session updates from ACP and dispatches them to appropriate callbacks
*/
import type { AcpSessionUpdate, SessionUpdateMeta } from '../types/acpTypes.js';
import type { AcpSessionUpdate } from '../types/acpTypes.js';
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
import type {
QwenAgentCallbacks,
UsageStatsPayload,
} from '../types/chatTypes.js';
import type { QwenAgentCallbacks } from '../types/chatTypes.js';
/**
* Qwen Session Update Handler class
@@ -60,7 +57,6 @@ export class QwenSessionUpdateHandler {
if (update.content?.text && this.callbacks.onStreamChunk) {
this.callbacks.onStreamChunk(update.content.text);
}
this.emitUsageMeta(update._meta);
break;
case 'agent_thought_chunk':
@@ -75,7 +71,6 @@ export class QwenSessionUpdateHandler {
this.callbacks.onStreamChunk(update.content.text);
}
}
this.emitUsageMeta(update._meta);
break;
case 'tool_call': {
@@ -165,17 +160,4 @@ export class QwenSessionUpdateHandler {
break;
}
}
private emitUsageMeta(meta?: SessionUpdateMeta): void {
if (!meta || !this.callbacks.onUsageUpdate) {
return;
}
const payload: UsageStatsPayload = {
usage: meta.usage || undefined,
durationMs: meta.durationMs ?? undefined,
};
this.callbacks.onUsageUpdate(payload);
}
}

View File

@@ -1,204 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as vscode from 'vscode';
/**
* Readonly file system provider for temporary files
* Uses custom URI scheme to create readonly documents in VS Code
*/
export class ReadonlyFileSystemProvider
implements vscode.FileSystemProvider, vscode.Disposable
{
private static readonly scheme = 'qwen-readonly';
private static instance: ReadonlyFileSystemProvider | null = null;
private readonly files = new Map<string, Uint8Array>();
private readonly emitter = new vscode.EventEmitter<
vscode.FileChangeEvent[]
>();
private readonly disposables: vscode.Disposable[] = [];
readonly onDidChangeFile = this.emitter.event;
constructor() {
// Ensure only one instance exists
if (ReadonlyFileSystemProvider.instance !== null) {
console.warn(
'[ReadonlyFileSystemProvider] Instance already exists, replacing with new instance',
);
}
this.disposables.push(this.emitter);
// Register as global singleton
ReadonlyFileSystemProvider.instance = this;
}
static getScheme(): string {
return ReadonlyFileSystemProvider.scheme;
}
/**
* Get the global singleton instance
* Returns null if not initialized yet
*/
static getInstance(): ReadonlyFileSystemProvider | null {
return ReadonlyFileSystemProvider.instance;
}
/**
* Create a URI for a readonly temporary file (static version)
*/
static createUri(fileName: string, content: string): vscode.Uri {
// For tool-call related filenames, keep the URI stable so repeated clicks focus the same document.
// Note: toolCallId can include underscores (e.g. "call_..."), so match everything after the prefix.
const isToolCallFile =
/^(bash-input|bash-output|execute-input|execute-output)-.+$/.test(
fileName,
);
if (isToolCallFile) {
return vscode.Uri.from({
scheme: ReadonlyFileSystemProvider.scheme,
path: `/${fileName}`,
});
}
// For other cases, keep the original approach with timestamp to avoid collisions.
const timestamp = Date.now();
const hash = Buffer.from(content.substring(0, 100)).toString('base64url');
const uniqueId = `${timestamp}-${hash.substring(0, 8)}`;
return vscode.Uri.from({
scheme: ReadonlyFileSystemProvider.scheme,
path: `/${fileName}-${uniqueId}`,
});
}
/**
* Create a URI for a readonly temporary file (instance method)
*/
createUri(fileName: string, content: string): vscode.Uri {
return ReadonlyFileSystemProvider.createUri(fileName, content);
}
/**
* Set content for a URI
*/
setContent(uri: vscode.Uri, content: string): void {
const buffer = Buffer.from(content, 'utf8');
const key = uri.toString();
const existed = this.files.has(key);
this.files.set(key, buffer);
this.emitter.fire([
{
type: existed
? vscode.FileChangeType.Changed
: vscode.FileChangeType.Created,
uri,
},
]);
}
/**
* Get content for a URI
*/
getContent(uri: vscode.Uri): string | undefined {
const buffer = this.files.get(uri.toString());
return buffer ? Buffer.from(buffer).toString('utf8') : undefined;
}
// FileSystemProvider implementation
watch(): vscode.Disposable {
// No watching needed for readonly files
return new vscode.Disposable(() => {});
}
stat(uri: vscode.Uri): vscode.FileStat {
const buffer = this.files.get(uri.toString());
if (!buffer) {
throw vscode.FileSystemError.FileNotFound(uri);
}
return {
type: vscode.FileType.File,
ctime: Date.now(),
mtime: Date.now(),
size: buffer.byteLength,
};
}
readDirectory(): Array<[string, vscode.FileType]> {
// Not needed for our use case
return [];
}
createDirectory(): void {
throw vscode.FileSystemError.NoPermissions('Readonly file system');
}
readFile(uri: vscode.Uri): Uint8Array {
const buffer = this.files.get(uri.toString());
if (!buffer) {
throw vscode.FileSystemError.FileNotFound(uri);
}
return buffer;
}
writeFile(
uri: vscode.Uri,
content: Uint8Array,
options: { create: boolean; overwrite: boolean },
): void {
// Check if file exists
const exists = this.files.has(uri.toString());
// For readonly files, only allow creation, not modification
if (exists && !options.overwrite) {
throw vscode.FileSystemError.FileExists(uri);
}
if (!exists && !options.create) {
throw vscode.FileSystemError.FileNotFound(uri);
}
this.files.set(uri.toString(), content);
this.emitter.fire([
{
type: exists
? vscode.FileChangeType.Changed
: vscode.FileChangeType.Created,
uri,
},
]);
}
delete(uri: vscode.Uri): void {
if (!this.files.has(uri.toString())) {
throw vscode.FileSystemError.FileNotFound(uri);
}
this.files.delete(uri.toString());
this.emitter.fire([{ type: vscode.FileChangeType.Deleted, uri }]);
}
rename(): void {
throw vscode.FileSystemError.NoPermissions('Readonly file system');
}
/**
* Clear all cached files
*/
clear(): void {
this.files.clear();
}
dispose(): void {
this.clear();
this.disposables.forEach((d) => d.dispose());
// Clear global instance on dispose
if (ReadonlyFileSystemProvider.instance === this) {
ReadonlyFileSystemProvider.instance = null;
}
}
}

View File

@@ -48,35 +48,6 @@ export interface ContentBlock {
uri?: string;
}
export interface UsageMetadata {
promptTokens?: number | null;
completionTokens?: number | null;
thoughtsTokens?: number | null;
totalTokens?: number | null;
cachedTokens?: number | null;
}
export interface SessionUpdateMeta {
usage?: UsageMetadata | null;
durationMs?: number | null;
}
export type AcpMeta = Record<string, unknown>;
export type ModelId = string;
export interface ModelInfo {
_meta?: AcpMeta | null;
description?: string | null;
modelId: ModelId;
name: string;
}
export interface SessionModelState {
_meta?: AcpMeta | null;
availableModels: ModelInfo[];
currentModelId: ModelId;
}
export interface UserMessageChunkUpdate extends BaseSessionUpdate {
update: {
sessionUpdate: 'user_message_chunk';
@@ -88,7 +59,6 @@ export interface AgentMessageChunkUpdate extends BaseSessionUpdate {
update: {
sessionUpdate: 'agent_message_chunk';
content: ContentBlock;
_meta?: SessionUpdateMeta;
};
}
@@ -96,7 +66,6 @@ export interface AgentThoughtChunkUpdate extends BaseSessionUpdate {
update: {
sessionUpdate: 'agent_thought_chunk';
content: ContentBlock;
_meta?: SessionUpdateMeta;
};
}

View File

@@ -3,7 +3,7 @@
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { AcpPermissionRequest, ModelInfo } from './acpTypes.js';
import type { AcpPermissionRequest } from './acpTypes.js';
import type { ApprovalModeValue } from './approvalModeValueTypes.js';
export interface ChatMessage {
@@ -28,18 +28,6 @@ export interface ToolCallUpdateData {
locations?: Array<{ path: string; line?: number | null }>;
}
export interface UsageStatsPayload {
usage?: {
promptTokens?: number | null;
completionTokens?: number | null;
thoughtsTokens?: number | null;
totalTokens?: number | null;
cachedTokens?: number | null;
} | null;
durationMs?: number | null;
tokenLimit?: number | null;
}
export interface QwenAgentCallbacks {
onMessage?: (message: ChatMessage) => void;
onStreamChunk?: (chunk: string) => void;
@@ -57,8 +45,6 @@ export interface QwenAgentCallbacks {
}>;
}) => void;
onModeChanged?: (modeId: ApprovalModeValue) => void;
onUsageUpdate?: (stats: UsageStatsPayload) => void;
onModelInfo?: (info: ModelInfo) => void;
}
export interface ToolCallUpdate {

View File

@@ -1,77 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { extractModelInfoFromNewSessionResult } from './acpModelInfo.js';
describe('extractModelInfoFromNewSessionResult', () => {
it('extracts from NewSessionResponse.models (SessionModelState)', () => {
expect(
extractModelInfoFromNewSessionResult({
sessionId: 's',
models: {
currentModelId: 'qwen3-coder-plus',
availableModels: [
{
modelId: 'qwen3-coder-plus',
name: 'Qwen3 Coder Plus',
description: null,
_meta: { contextLimit: 123 },
},
],
},
}),
).toEqual({
modelId: 'qwen3-coder-plus',
name: 'Qwen3 Coder Plus',
description: null,
_meta: { contextLimit: 123 },
});
});
it('skips invalid model entries and returns first valid one', () => {
expect(
extractModelInfoFromNewSessionResult({
models: {
currentModelId: 'ok',
availableModels: [
{ name: '', modelId: '' },
{ name: 'Ok', modelId: 'ok', _meta: { contextLimit: null } },
],
},
}),
).toEqual({ name: 'Ok', modelId: 'ok', _meta: { contextLimit: null } });
});
it('falls back to single `model` object', () => {
expect(
extractModelInfoFromNewSessionResult({
model: {
name: 'Single',
modelId: 'single',
_meta: { contextLimit: 999 },
},
}),
).toEqual({
name: 'Single',
modelId: 'single',
_meta: { contextLimit: 999 },
});
});
it('falls back to legacy `modelInfo`', () => {
expect(
extractModelInfoFromNewSessionResult({
modelInfo: { name: 'legacy' },
}),
).toEqual({ name: 'legacy', modelId: 'legacy' });
});
it('returns null when missing', () => {
expect(extractModelInfoFromNewSessionResult({})).toBeNull();
expect(extractModelInfoFromNewSessionResult(null)).toBeNull();
});
});

View File

@@ -1,135 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { AcpMeta, ModelInfo } from '../types/acpTypes.js';
const asMeta = (value: unknown): AcpMeta | null | undefined => {
if (value === null) {
return null;
}
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as AcpMeta;
}
return undefined;
};
const normalizeModelInfo = (value: unknown): ModelInfo | null => {
if (!value || typeof value !== 'object') {
return null;
}
const obj = value as Record<string, unknown>;
const nameRaw = obj['name'];
const modelIdRaw = obj['modelId'];
const descriptionRaw = obj['description'];
const name = typeof nameRaw === 'string' ? nameRaw.trim() : '';
const modelId =
typeof modelIdRaw === 'string' && modelIdRaw.trim().length > 0
? modelIdRaw.trim()
: name;
if (!modelId || modelId.trim().length === 0 || !name) {
return null;
}
const description =
typeof descriptionRaw === 'string' || descriptionRaw === null
? descriptionRaw
: undefined;
const metaFromWire = asMeta(obj['_meta']);
// Back-compat: older implementations used `contextLimit` at the top-level.
const legacyContextLimit = obj['contextLimit'];
const contextLimit =
typeof legacyContextLimit === 'number' || legacyContextLimit === null
? legacyContextLimit
: undefined;
let mergedMeta: AcpMeta | null | undefined = metaFromWire;
if (typeof contextLimit !== 'undefined') {
if (mergedMeta === null) {
mergedMeta = { contextLimit };
} else if (typeof mergedMeta === 'undefined') {
mergedMeta = { contextLimit };
} else {
mergedMeta = { ...mergedMeta, contextLimit };
}
}
return {
modelId,
name,
...(typeof description !== 'undefined' ? { description } : {}),
...(typeof mergedMeta !== 'undefined' ? { _meta: mergedMeta } : {}),
};
};
/**
* Extract model info from ACP `session/new` result.
*
* Per Agent Client Protocol draft schema, NewSessionResponse includes `models`.
* We also accept legacy shapes for compatibility.
*/
export const extractModelInfoFromNewSessionResult = (
result: unknown,
): ModelInfo | null => {
if (!result || typeof result !== 'object') {
return null;
}
const obj = result as Record<string, unknown>;
const models = obj['models'];
// ACP draft: NewSessionResponse.models is a SessionModelState object.
if (models && typeof models === 'object' && !Array.isArray(models)) {
const state = models as Record<string, unknown>;
const availableModels = state['availableModels'];
const currentModelId = state['currentModelId'];
if (Array.isArray(availableModels)) {
const normalizedModels = availableModels
.map(normalizeModelInfo)
.filter((m): m is ModelInfo => Boolean(m));
if (normalizedModels.length > 0) {
if (typeof currentModelId === 'string' && currentModelId.length > 0) {
const selected = normalizedModels.find(
(m) => m.modelId === currentModelId,
);
if (selected) {
return selected;
}
}
return normalizedModels[0];
}
}
}
// Legacy: some implementations returned `models` as a raw array.
if (Array.isArray(models)) {
for (const entry of models) {
const normalized = normalizeModelInfo(entry);
if (normalized) {
return normalized;
}
}
}
// Some implementations may return a single model object.
const model = normalizeModelInfo(obj['model']);
if (model) {
return model;
}
// Legacy: modelInfo on initialize; allow as a fallback.
const legacy = normalizeModelInfo(obj['modelInfo']);
if (legacy) {
return legacy;
}
return null;
};

View File

@@ -53,40 +53,11 @@ export function findLeftGroupOfChatWebview(): vscode.ViewColumn | undefined {
}
}
/**
* Wait for a condition to become true, driven by tab-group change events.
* Falls back to a timeout to avoid hanging forever.
*/
function waitForTabGroupsCondition(
condition: () => boolean,
timeout: number = 2000,
): Promise<boolean> {
if (condition()) {
return Promise.resolve(true);
}
return new Promise<boolean>((resolve) => {
const subscription = vscode.window.tabGroups.onDidChangeTabGroups(() => {
if (!condition()) {
return;
}
clearTimeout(timeoutHandle);
subscription.dispose();
resolve(true);
});
const timeoutHandle = setTimeout(() => {
subscription.dispose();
resolve(false);
}, timeout);
});
}
/**
* Ensure there is an editor group directly to the left of the Qwen chat webview.
* - If one exists, return its ViewColumn.
* - If none exists, focus the chat panel and create a new group on its left,
* then return the new group's ViewColumn.
* then return the new group's ViewColumn (which equals the chat's previous column).
* - If the chat webview cannot be located, returns undefined.
*/
export async function ensureLeftGroupOfChatWebview(): Promise<
@@ -116,7 +87,7 @@ export async function ensureLeftGroupOfChatWebview(): Promise<
return undefined;
}
const initialGroupCount = vscode.window.tabGroups.all.length;
const previousChatColumn = webviewGroup.viewColumn;
// Make the chat group active by revealing the panel
try {
@@ -133,22 +104,6 @@ export async function ensureLeftGroupOfChatWebview(): Promise<
return undefined;
}
// Wait for the new group to actually be created (check that group count increased)
const groupCreated = await waitForTabGroupsCondition(
() => vscode.window.tabGroups.all.length > initialGroupCount,
1000, // 1 second timeout
);
if (!groupCreated) {
// Fallback if group creation didn't complete in time
return vscode.ViewColumn.One;
}
// After creating a new group to the left, the new group takes ViewColumn.One
// and all existing groups shift right. So the new left group is always ViewColumn.One.
// However, to be safe, let's query for it again.
const newLeftGroup = findLeftGroupOfChatWebview();
// Restore focus to chat (optional), so we don't disturb user focus
try {
await vscode.commands.executeCommand(openChatCommand);
@@ -156,7 +111,6 @@ export async function ensureLeftGroupOfChatWebview(): Promise<
// Ignore
}
// If we successfully found the new left group, return it
// Otherwise, fallback to ViewColumn.One (the newly created group should be first)
return newLeftGroup ?? vscode.ViewColumn.One;
// The new left group's column equals the chat's previous column
return previousChatColumn;
}

View File

@@ -27,7 +27,7 @@ import type { TextMessage } from './hooks/message/useMessageHandling.js';
import type { ToolCallData } from './components/messages/toolcalls/ToolCall.js';
import { PermissionDrawer } from './components/PermissionDrawer/PermissionDrawer.js';
import { ToolCall } from './components/messages/toolcalls/ToolCall.js';
import { hasToolCallOutput } from './utils/utils.js';
import { hasToolCallOutput } from './components/messages/toolcalls/shared/utils.js';
import { EmptyState } from './components/layout/EmptyState.js';
import { Onboarding } from './components/layout/Onboarding.js';
import { type CompletionItem } from '../types/completionItemTypes.js';
@@ -45,12 +45,7 @@ import { SessionSelector } from './components/layout/SessionSelector.js';
import { FileIcon, UserIcon } from './components/icons/index.js';
import { ApprovalMode, NEXT_APPROVAL_MODE } from '../types/acpTypes.js';
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
import type { PlanEntry, UsageStatsPayload } from '../types/chatTypes.js';
import type { ModelInfo } from '../types/acpTypes.js';
import {
DEFAULT_TOKEN_LIMIT,
tokenLimit,
} from '@qwen-code/qwen-code-core/src/core/tokenLimits.js';
import type { PlanEntry } from '../types/chatTypes.js';
export const App: React.FC = () => {
const vscode = useVSCode();
@@ -75,8 +70,6 @@ export const App: React.FC = () => {
const [planEntries, setPlanEntries] = useState<PlanEntry[]>([]);
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true); // Track if we're still initializing/loading
const [modelInfo, setModelInfo] = useState<ModelInfo | null>(null);
const [usageStats, setUsageStats] = useState<UsageStatsPayload | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(
null,
) as React.RefObject<HTMLDivElement>;
@@ -167,48 +160,6 @@ export const App: React.FC = () => {
const completion = useCompletionTrigger(inputFieldRef, getCompletionItems);
const contextUsage = useMemo(() => {
if (!usageStats && !modelInfo) {
return null;
}
const modelName =
modelInfo?.modelId && typeof modelInfo.modelId === 'string'
? modelInfo.modelId
: modelInfo?.name && typeof modelInfo.name === 'string'
? modelInfo.name
: undefined;
const derivedLimit =
modelName && modelName.length > 0 ? tokenLimit(modelName) : undefined;
const metaLimitRaw = modelInfo?._meta?.['contextLimit'];
const metaLimit =
typeof metaLimitRaw === 'number' || metaLimitRaw === null
? metaLimitRaw
: undefined;
const limit =
usageStats?.tokenLimit ??
metaLimit ??
derivedLimit ??
DEFAULT_TOKEN_LIMIT;
const used = usageStats?.usage?.promptTokens ?? 0;
if (typeof limit !== 'number' || limit <= 0 || used < 0) {
return null;
}
const percentLeft = Math.max(
0,
Math.min(100, Math.round(((limit - used) / limit) * 100)),
);
return {
percentLeft,
usedTokens: used,
tokenLimit: limit,
};
}, [usageStats, modelInfo]);
// Track a lightweight signature of workspace files to detect content changes even when length is unchanged
const workspaceFilesSignature = useMemo(
() =>
@@ -297,10 +248,6 @@ export const App: React.FC = () => {
setInputText,
setEditMode,
setIsAuthenticated,
setUsageStats: (stats) => setUsageStats(stats ?? null),
setModelInfo: (info) => {
setModelInfo(info);
},
});
// Auto-scroll handling: keep the view pinned to bottom when new content arrives,
@@ -813,7 +760,6 @@ export const App: React.FC = () => {
activeFileName={fileContext.activeFileName}
activeSelection={fileContext.activeSelection}
skipAutoActiveContext={skipAutoActiveContext}
contextUsage={contextUsage}
onInputChange={setInputText}
onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={() => setIsComposing(false)}

View File

@@ -118,20 +118,6 @@ export class WebViewProvider {
});
});
this.agentManager.onUsageUpdate((stats) => {
this.sendMessageToWebView({
type: 'usageStats',
data: stats,
});
});
this.agentManager.onModelInfo((info) => {
this.sendMessageToWebView({
type: 'modelInfo',
data: info,
});
});
// Setup end-turn handler from ACP stopReason notifications
this.agentManager.onEndTurn((reason) => {
// Ensure WebView exits streaming state even if no explicit streamEnd was emitted elsewhere

View File

@@ -1,61 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
interface TooltipProps {
children: React.ReactNode;
content: React.ReactNode;
position?: 'top' | 'bottom' | 'left' | 'right';
}
export const Tooltip: React.FC<TooltipProps> = ({
children,
content,
position = 'top',
}) => (
<div className="relative inline-block">
<div className="group relative">
{children}
<div
className={`
absolute z-50 px-2 py-1 text-xs rounded-md shadow-lg
bg-[var(--app-primary-background)] border border-[var(--app-input-border)]
text-[var(--app-primary-foreground)] whitespace-nowrap
opacity-0 group-hover:opacity-100 transition-opacity duration-150
-translate-x-1/2 left-1/2
${
position === 'top'
? '-translate-y-1 bottom-full mb-1'
: position === 'bottom'
? 'translate-y-1 top-full mt-1'
: position === 'left'
? '-translate-x-full left-0 translate-y-[-50%] top-1/2'
: 'translate-x-0 right-0 translate-y-[-50%] top-1/2'
}
pointer-events-none
`}
>
{content}
<div
className={`
absolute w-2 h-2 bg-[var(--app-primary-background)] border-l border-b border-[var(--app-input-border)]
-rotate-45
${
position === 'top'
? 'top-full left-1/2 -translate-x-1/2 -translate-y-1/2'
: position === 'bottom'
? 'bottom-full left-1/2 -translate-x-1/2 translate-y-1/2'
: position === 'left'
? 'right-full top-1/2 translate-x-1/2 -translate-y-1/2'
: 'left-full top-1/2 -translate-x-1/2 -translate-y-1/2'
}
`}
/>
</div>
</div>
</div>
);

View File

@@ -1,88 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Tooltip } from '../Tooltip.js';
interface ContextUsage {
percentLeft: number;
usedTokens: number;
tokenLimit: number;
}
interface ContextIndicatorProps {
contextUsage: ContextUsage | null;
}
export const ContextIndicator: React.FC<ContextIndicatorProps> = ({
contextUsage,
}) => {
if (!contextUsage) {
return null;
}
// Calculate used percentage for the progress indicator
// contextUsage.percentLeft is the percentage remaining, so 100 - percentLeft = percent used
const percentUsed = 100 - contextUsage.percentLeft;
const percentFormatted = Math.max(0, Math.min(100, Math.round(percentUsed)));
const radius = 9;
const circumference = 2 * Math.PI * radius;
// To show the used portion, we need to offset the unused portion
// If 20% is used, we want to show 20% filled, so offset the remaining 80%
const dashOffset = ((100 - percentUsed) / 100) * circumference;
const formatNumber = (value: number) => {
if (value >= 1000) {
return `${(Math.round((value / 1000) * 10) / 10).toFixed(1)}k`;
}
return Math.round(value).toLocaleString();
};
// Create tooltip content with proper formatting
const tooltipContent = (
<div className="flex flex-col gap-1">
<div className="font-medium">
{percentFormatted}% {formatNumber(contextUsage.usedTokens)} /{' '}
{formatNumber(contextUsage.tokenLimit)} context used
</div>
</div>
);
return (
<Tooltip content={tooltipContent} position="top">
<button
className="btn-icon-compact"
aria-label={`${percentFormatted}% • ${formatNumber(contextUsage.usedTokens)} / ${formatNumber(contextUsage.tokenLimit)} context used`}
>
<svg viewBox="0 0 24 24" aria-hidden="true" role="presentation">
<circle
className="context-indicator__track"
cx="12"
cy="12"
r={radius}
fill="none"
stroke="currentColor"
opacity="0.2"
/>
<circle
className="context-indicator__progress"
cx="12"
cy="12"
r={radius}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
style={{
transform: 'rotate(-90deg)',
transformOrigin: '50% 50%',
}}
/>
</svg>
</button>
</Tooltip>
);
};

View File

@@ -21,7 +21,6 @@ import { CompletionMenu } from '../layout/CompletionMenu.js';
import type { CompletionItem } from '../../../types/completionItemTypes.js';
import { getApprovalModeInfoFromString } from '../../../types/acpTypes.js';
import type { ApprovalModeValue } from '../../../types/approvalModeValueTypes.js';
import { ContextIndicator } from './ContextIndicator.js';
interface InputFormProps {
inputText: string;
@@ -37,11 +36,6 @@ interface InputFormProps {
activeSelection: { startLine: number; endLine: number } | null;
// Whether to auto-load the active editor selection/path into context
skipAutoActiveContext: boolean;
contextUsage: {
percentLeft: number;
usedTokens: number;
tokenLimit: number;
} | null;
onInputChange: (text: string) => void;
onCompositionStart: () => void;
onCompositionEnd: () => void;
@@ -102,7 +96,6 @@ export const InputForm: React.FC<InputFormProps> = ({
activeFileName,
activeSelection,
skipAutoActiveContext,
contextUsage,
onInputChange,
onCompositionStart,
onCompositionEnd,
@@ -247,9 +240,6 @@ export const InputForm: React.FC<InputFormProps> = ({
{/* Spacer */}
<div className="flex-1 min-w-0" />
{/* Context usage indicator */}
<ContextIndicator contextUsage={contextUsage} />
{/* @yiliang114. closed temporarily */}
{/* Thinking button */}
{/* <button

View File

@@ -3,10 +3,10 @@
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Bash tool call styles - Enhanced styling with semantic class names
* Execute tool call styles - Enhanced styling with semantic class names
*/
/* Root container for bash tool call output */
/* Root container for execute tool call output */
.bash-toolcall-card {
border: 0.5px solid var(--app-input-border);
border-radius: 5px;
@@ -100,9 +100,3 @@
.bash-toolcall-error-content {
color: #c74e39;
}
/* Row with copy button */
.bash-toolcall-row-with-copy {
position: relative;
grid-template-columns: max-content 1fr max-content;
}

View File

@@ -9,10 +9,9 @@
import type React from 'react';
import type { BaseToolCallProps } from '../shared/types.js';
import { ToolCallContainer } from '../shared/LayoutComponents.js';
import { safeTitle, groupContent } from '../../../../utils/utils.js';
import { safeTitle, groupContent } from '../shared/utils.js';
import { useVSCode } from '../../../../hooks/useVSCode.js';
import { createAndOpenTempFile } from '../../../../utils/diffUtils.js';
import { CopyButton } from '../shared/copyUtils.js';
import { createAndOpenTempFile } from '../../../../utils/tempFileManager.js';
import './Bash.css';
/**
@@ -38,14 +37,19 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
// Handle click on IN section
const handleInClick = () => {
createAndOpenTempFile(vscode, inputCommand, `bash-input-${toolCallId}`);
createAndOpenTempFile(
vscode.postMessage,
inputCommand,
'bash-input',
'.sh',
);
};
// Handle click on OUT section
const handleOutClick = () => {
if (textOutputs.length > 0) {
const output = textOutputs.join('\n');
createAndOpenTempFile(vscode, output, `bash-output-${toolCallId}`);
createAndOpenTempFile(vscode.postMessage, output, 'bash-output', '.txt');
}
};
@@ -80,7 +84,7 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
<div className="bash-toolcall-content">
{/* IN row */}
<div
className="bash-toolcall-row bash-toolcall-row-with-copy group"
className="bash-toolcall-row"
onClick={handleInClick}
style={{ cursor: 'pointer' }}
>
@@ -88,7 +92,6 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
<div className="bash-toolcall-row-content">
<pre className="bash-toolcall-pre">{inputCommand}</pre>
</div>
<CopyButton text={inputCommand} />
</div>
{/* ERROR row */}
@@ -128,7 +131,7 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
<div className="bash-toolcall-content">
{/* IN row */}
<div
className="bash-toolcall-row bash-toolcall-row-with-copy group"
className="bash-toolcall-row"
onClick={handleInClick}
style={{ cursor: 'pointer' }}
>
@@ -136,7 +139,6 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
<div className="bash-toolcall-row-content">
<pre className="bash-toolcall-pre">{inputCommand}</pre>
</div>
<CopyButton text={inputCommand} />
</div>
{/* OUT row */}

View File

@@ -11,7 +11,7 @@ import type { BaseToolCallProps } from '../shared/types.js';
import {
groupContent,
mapToolStatusToContainerStatus,
} from '../../../../utils/utils.js';
} from '../shared/utils.js';
import { FileLink } from '../../../layout/FileLink.js';
import type { ToolCallContainerProps } from '../shared/LayoutComponents.js';

View File

@@ -61,7 +61,11 @@
/* Truncated content styling */
.execute-toolcall-row-content:not(.execute-toolcall-full) {
max-height: 60px;
mask-image: linear-gradient(to bottom, var(--app-primary-background) 40px, transparent 60px);
mask-image: linear-gradient(
to bottom,
var(--app-primary-background) 40px,
transparent 60px
);
overflow: hidden;
}
@@ -83,6 +87,7 @@
/* Output content with subtle styling */
.execute-toolcall-output-subtle {
background-color: var(--app-code-background);
white-space: pre;
overflow-x: auto;
max-width: 100%;
@@ -95,9 +100,3 @@
.execute-toolcall-error-content {
color: #c74e39;
}
/* Row with copy button */
.execute-toolcall-row-with-copy {
position: relative;
grid-template-columns: max-content 1fr max-content;
}

View File

@@ -8,12 +8,9 @@
import type React from 'react';
import type { BaseToolCallProps } from '../shared/types.js';
import { safeTitle, groupContent } from '../../../../utils/utils.js';
import { safeTitle, groupContent } from '../shared/utils.js';
import './Execute.css';
import type { ToolCallContainerProps } from '../shared/LayoutComponents.js';
import { useVSCode } from '../../../../hooks/useVSCode.js';
import { createAndOpenTempFile } from '../../../../utils/diffUtils.js';
import { CopyButton } from '../shared/copyUtils.js';
export const ToolCallContainer: React.FC<ToolCallContainerProps> = ({
label,
@@ -51,7 +48,6 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
const commandText = safeTitle(
(rawInput as Record<string, unknown>)?.description || title,
);
const vscode = useVSCode();
// Group content by type
const { textOutputs, errors } = groupContent(content);
@@ -65,19 +61,6 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
inputCommand = rawInput;
}
// Handle click on IN section
const handleInClick = () => {
createAndOpenTempFile(vscode, inputCommand, `execute-input-${toolCallId}`);
};
// Handle click on OUT section
const handleOutClick = () => {
if (textOutputs.length > 0) {
const output = textOutputs.join('\n');
createAndOpenTempFile(vscode, output, `execute-output-${toolCallId}`);
}
};
// Map tool status to container status for proper bullet coloring
const containerStatus:
| 'success'
@@ -109,16 +92,11 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
<div className="execute-toolcall-card">
<div className="execute-toolcall-content">
{/* IN row */}
<div
className="execute-toolcall-row execute-toolcall-row-with-copy group"
onClick={handleInClick}
style={{ cursor: 'pointer' }}
>
<div className="execute-toolcall-row">
<div className="execute-toolcall-label">IN</div>
<div className="execute-toolcall-row-content">
<pre className="execute-toolcall-pre">{inputCommand}</pre>
</div>
<CopyButton text={inputCommand} />
</div>
{/* ERROR row */}
@@ -157,24 +135,15 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
<div className="execute-toolcall-card">
<div className="execute-toolcall-content">
{/* IN row */}
<div
className="execute-toolcall-row execute-toolcall-row-with-copy group"
onClick={handleInClick}
style={{ cursor: 'pointer' }}
>
<div className="execute-toolcall-row">
<div className="execute-toolcall-label">IN</div>
<div className="execute-toolcall-row-content">
<pre className="execute-toolcall-pre">{inputCommand}</pre>
</div>
<CopyButton text={inputCommand} />
</div>
{/* OUT row */}
<div
className="execute-toolcall-row"
onClick={handleOutClick}
style={{ cursor: 'pointer' }}
>
<div className="execute-toolcall-row">
<div className="execute-toolcall-label">OUT</div>
<div className="execute-toolcall-row-content">
<div className="execute-toolcall-output-subtle">
@@ -195,11 +164,7 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
status={containerStatus}
toolCallId={toolCallId}
>
<div
className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1"
onClick={handleInClick}
style={{ cursor: 'pointer' }}
>
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1">
<span className="flex-shrink-0 relative top-[-0.1em]"></span>
<span className="flex-shrink-0 w-full">{commandText}</span>
</div>

View File

@@ -14,7 +14,7 @@ import {
ToolCallRow,
LocationsList,
} from './shared/LayoutComponents.js';
import { safeTitle, groupContent } from '../../../utils/utils.js';
import { safeTitle, groupContent } from './shared/utils.js';
/**
* Generic tool call component that can display any tool call type

View File

@@ -12,7 +12,7 @@ import type { BaseToolCallProps } from '../shared/types.js';
import {
groupContent,
mapToolStatusToContainerStatus,
} from '../../../../utils/utils.js';
} from '../shared/utils.js';
import { FileLink } from '../../../layout/FileLink.js';
import { useVSCode } from '../../../../hooks/useVSCode.js';
import { handleOpenDiff } from '../../../../utils/diffUtils.js';

View File

@@ -13,7 +13,7 @@ import {
safeTitle,
groupContent,
mapToolStatusToContainerStatus,
} from '../../../../utils/utils.js';
} from '../shared/utils.js';
/**
* Specialized component for Search tool calls
@@ -195,7 +195,7 @@ export const SearchToolCall: React.FC<BaseToolCallProps> = ({
isLast={isLast}
>
<div className="flex flex-col">
{textOutputs.map((text: string, index: number) => (
{textOutputs.map((text, index) => (
<div
key={index}
className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1"

View File

@@ -13,7 +13,7 @@ import {
ToolCallCard,
ToolCallRow,
} from '../shared/LayoutComponents.js';
import { groupContent } from '../../../../utils/utils.js';
import { groupContent } from '../shared/utils.js';
/**
* Specialized component for Think tool calls

View File

@@ -9,7 +9,7 @@
import type React from 'react';
import type { BaseToolCallProps } from '../shared/types.js';
import type { ToolCallContainerProps } from '../shared/LayoutComponents.js';
import { groupContent, safeTitle } from '../../../../utils/utils.js';
import { groupContent, safeTitle } from '../shared/utils.js';
import { CheckboxDisplay } from './CheckboxDisplay.js';
import type { PlanEntry } from '../../../../../types/chatTypes.js';

View File

@@ -12,7 +12,7 @@ import { ToolCallContainer } from '../shared/LayoutComponents.js';
import {
groupContent,
mapToolStatusToContainerStatus,
} from '../../../../utils/utils.js';
} from '../shared/utils.js';
import { FileLink } from '../../../layout/FileLink.js';
/**

View File

@@ -8,7 +8,7 @@
import type React from 'react';
import type { BaseToolCallProps } from './shared/types.js';
import { shouldShowToolCall } from '../../../utils/utils.js';
import { shouldShowToolCall } from './shared/utils.js';
import { GenericToolCall } from './GenericToolCall.js';
import { ReadToolCall } from './Read/ReadToolCall.js';
import { WriteToolCall } from './Write/WriteToolCall.js';

View File

@@ -1,74 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Shared copy utilities for toolcall components
*/
import type React from 'react';
import { useState } from 'react';
/**
* Handle copy to clipboard
*/
export const handleCopyToClipboard = async (
text: string,
event: React.MouseEvent,
): Promise<void> => {
event.stopPropagation(); // Prevent triggering the row click
try {
await navigator.clipboard.writeText(text);
} catch (err) {
console.error('Failed to copy text:', err);
}
};
/**
* Copy button component props
*/
interface CopyButtonProps {
text: string;
}
/**
* Shared copy button component with Tailwind styles
* Note: Parent element should have 'group' class for hover effect
*/
export const CopyButton: React.FC<CopyButtonProps> = ({ text }) => {
const [showTooltip, setShowTooltip] = useState(false);
return (
<button
className="col-start-3 bg-transparent border-none px-2 py-1.5 cursor-pointer text-[var(--app-secondary-foreground)] opacity-0 transition-opacity duration-200 ease-out flex items-center justify-center rounded relative group-hover:opacity-70 hover:!opacity-100 hover:bg-[var(--app-input-border)] active:scale-95"
onClick={async (e) => {
await handleCopyToClipboard(text, e);
setShowTooltip(true);
setTimeout(() => setShowTooltip(false), 1000);
}}
title="Copy"
aria-label="Copy to clipboard"
>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 4V3C4 2.44772 4.44772 2 5 2H13C13.5523 2 14 2.44772 14 3V11C14 11.5523 13.5523 12 13 12H12M3 6H11C11.5523 6 12 6.44772 12 7V13C12 13.5523 11.5523 14 11 14H3C2.44772 14 2 13.5523 2 13V7C2 6.44772 2.44772 6 3 6Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{showTooltip && (
<span className="absolute -top-7 right-0 bg-[var(--app-tool-background)] text-[var(--app-primary-foreground)] px-2 py-1 rounded text-xs whitespace-nowrap border border-[var(--app-input-border)] pointer-events-none">
Copied!
</span>
)}
</button>
);
};

View File

@@ -9,102 +9,8 @@
import type {
ToolCallContent,
GroupedContent,
ToolCallData,
ToolCallStatus,
} from '../components/messages/toolcalls/shared/types.js';
/**
* Extract output from command execution result text
* Handles both JSON format and structured text format
*
* Example structured text:
* ```
* Command: lsof -i :5173
* Directory: (root)
* Output: COMMAND PID USER...
* Error: (none)
* Exit Code: 0
* ```
*/
export const extractCommandOutput = (text: string): string => {
// First try: Parse as JSON and extract output field
try {
const parsed = JSON.parse(text) as { output?: unknown; Output?: unknown };
const output = parsed.output ?? parsed.Output;
if (output !== undefined && output !== null) {
return typeof output === 'string'
? output
: JSON.stringify(output, null, 2);
}
} catch (_error) {
// Not JSON, continue with text parsing
}
// Second try: Extract from structured text format
// Look for "Output: " followed by content until "Error: " or end of string
// Only match if there's actual content after "Output:" (not just whitespace)
// Avoid treating the next line (e.g. "Error: ...") as output when the Output line is empty.
// Intentionally do not allow `\s*` here since it would consume newlines.
const outputMatch = text.match(/Output:[ \t]*(.+?)(?=\nError:|$)/i);
if (outputMatch && outputMatch[1]) {
const output = outputMatch[1].trim();
// Only return if there's meaningful content (not just "(none)" or empty)
if (output && output !== '(none)' && output.length > 0) {
return output;
}
}
// Third try: Check if text starts with structured format (Command:, Directory:, etc.)
// If so, try to extract everything between first line and "Error:" or "Exit Code:"
if (text.match(/^Command:/)) {
const lines = text.split('\n');
const outputLines: string[] = [];
let inOutput = false;
for (const line of lines) {
// Stop at metadata lines
if (
line.startsWith('Error:') ||
line.startsWith('Exit Code:') ||
line.startsWith('Signal:') ||
line.startsWith('Background PIDs:') ||
line.startsWith('Process Group PGID:')
) {
break;
}
// Skip header lines
if (line.startsWith('Command:') || line.startsWith('Directory:')) {
continue;
}
// Start collecting after "Output:" label
if (line.startsWith('Output:')) {
inOutput = true;
const content = line.substring('Output:'.length).trim();
if (content && content !== '(none)') {
outputLines.push(content);
}
continue;
}
// Collect output lines
if (
inOutput ||
(!line.startsWith('Command:') && !line.startsWith('Directory:'))
) {
outputLines.push(line);
}
}
if (outputLines.length > 0) {
const result = outputLines.join('\n').trim();
if (result && result !== '(none)') {
return result;
}
}
}
// Fallback: Return original text
return text;
};
} from './types.js';
/**
* Format any value to a string for display
@@ -114,8 +20,13 @@ export const formatValue = (value: unknown): string => {
return '';
}
if (typeof value === 'string') {
// Extract command output from structured text
return extractCommandOutput(value);
// TODO: Trying to take out the Output part from the string
try {
value = (JSON.parse(value) as { output?: unknown }).output ?? value;
} catch (_error) {
// ignore JSON parse errors
}
return value as string;
}
// Handle Error objects specially
if (value instanceof Error) {
@@ -161,7 +72,9 @@ export const shouldShowToolCall = (kind: string): boolean =>
* Check if a tool call has actual output to display
* Returns false for tool calls that completed successfully but have no visible output
*/
export const hasToolCallOutput = (toolCall: ToolCallData): boolean => {
export const hasToolCallOutput = (
toolCall: import('./types.js').ToolCallData,
): boolean => {
// Always show failed tool calls (even without content)
if (toolCall.status === 'failed') {
return true;

View File

@@ -5,14 +5,12 @@
*/
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { BaseMessageHandler } from './BaseMessageHandler.js';
import { getFileName } from '../utils/webviewUtils.js';
import { showDiffCommand } from '../../commands/index.js';
import {
findLeftGroupOfChatWebview,
ensureLeftGroupOfChatWebview,
} from '../../utils/editorGroupUtils.js';
import { ReadonlyFileSystemProvider } from '../../services/readonlyFileSystemProvider.js';
/**
* File message handler
@@ -398,7 +396,7 @@ export class FileMessageHandler extends BaseMessageHandler {
}
/**
* Create and open temporary readonly file
* Create and open temporary file
*/
private async handleCreateAndOpenTempFile(
data: Record<string, unknown> | undefined,
@@ -413,78 +411,26 @@ export class FileMessageHandler extends BaseMessageHandler {
try {
const content = (data.content as string) || '';
const fileName = (data.fileName as string) || 'temp';
const fileExtension = (data.fileExtension as string) || '.txt';
// Get readonly file system provider from global singleton
const readonlyProvider = ReadonlyFileSystemProvider.getInstance();
if (!readonlyProvider) {
const errorMessage = 'Readonly file system provider not initialized';
console.error('[FileMessageHandler]', errorMessage);
this.sendToWebView({
type: 'error',
data: { message: errorMessage },
});
return;
}
// Create temporary file path
const tempDir = os.tmpdir();
const tempFileName = `${fileName}-${Date.now()}${fileExtension}`;
const tempFilePath = path.join(tempDir, tempFileName);
// Create readonly URI (without timestamp to ensure consistency)
const uri = readonlyProvider.createUri(fileName, content);
readonlyProvider.setContent(uri, content);
// Write content to temporary file
await fs.promises.writeFile(tempFilePath, content, 'utf8');
// If the document already has an open tab, focus that same tab instead of opening a new one.
let foundExistingTab = false;
let existingViewColumn: vscode.ViewColumn | undefined;
for (const tabGroup of vscode.window.tabGroups.all) {
for (const tab of tabGroup.tabs) {
const input = tab.input as { uri?: vscode.Uri } | undefined;
if (input?.uri && input.uri.toString() === uri.toString()) {
foundExistingTab = true;
existingViewColumn = tabGroup.viewColumn;
break;
}
}
if (foundExistingTab) {
break;
}
}
if (foundExistingTab) {
const document = await vscode.workspace.openTextDocument(uri);
const showOptions: vscode.TextDocumentShowOptions = {
preview: false,
preserveFocus: false,
};
if (existingViewColumn !== undefined) {
showOptions.viewColumn = existingViewColumn;
}
await vscode.window.showTextDocument(document, showOptions);
console.log(
'[FileMessageHandler] Focused on existing readonly file:',
uri.toString(),
'in viewColumn:',
existingViewColumn,
);
return;
}
// Find or ensure left group of chat webview
let targetViewColumn = findLeftGroupOfChatWebview();
if (targetViewColumn === undefined) {
targetViewColumn = await ensureLeftGroupOfChatWebview();
}
// Open as readonly document in the left group and focus it (single click should be enough)
const document = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(document, {
viewColumn: targetViewColumn ?? vscode.ViewColumn.Beside,
// Open the temporary file in VS Code
const uri = vscode.Uri.file(tempFilePath);
await vscode.window.showTextDocument(uri, {
preview: false,
preserveFocus: false,
});
console.log(
'[FileMessageHandler] Created and opened readonly file:',
uri.toString(),
'in viewColumn:',
targetViewColumn ?? 'Beside',
'[FileMessageHandler] Created and opened temporary file:',
tempFilePath,
);
} catch (error) {
console.error(

View File

@@ -11,13 +11,9 @@ import type {
PermissionOption,
ToolCall as PermissionToolCall,
} from '../components/PermissionDrawer/PermissionRequest.js';
import type {
ToolCallUpdate,
UsageStatsPayload,
} from '../../types/chatTypes.js';
import type { ToolCallUpdate } from '../../types/chatTypes.js';
import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js';
import type { PlanEntry } from '../../types/chatTypes.js';
import type { ModelInfo } from '../../types/acpTypes.js';
const FORCE_CLEAR_STREAM_END_REASONS = new Set([
'user_cancelled',
@@ -123,10 +119,6 @@ interface UseWebViewMessagesProps {
setEditMode?: (mode: ApprovalModeValue) => void;
// Authentication state setter
setIsAuthenticated?: (authenticated: boolean | null) => void;
// Usage stats setter
setUsageStats?: (stats: UsageStatsPayload | undefined) => void;
// Model info setter
setModelInfo?: (info: ModelInfo | null) => void;
}
/**
@@ -145,15 +137,12 @@ export const useWebViewMessages = ({
setInputText,
setEditMode,
setIsAuthenticated,
setUsageStats,
setModelInfo,
}: UseWebViewMessagesProps) => {
// VS Code API for posting messages back to the extension host
const vscode = useVSCode();
// 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<Set<string>>(new Set());
const modelInfoRef = useRef<ModelInfo | null>(null);
// Use ref to store callbacks to avoid useEffect dependency issues
const handlersRef = useRef({
sessionManagement,
@@ -164,8 +153,6 @@ export const useWebViewMessages = ({
setPlanEntries,
handlePermissionRequest,
setIsAuthenticated,
setUsageStats,
setModelInfo,
});
// Track last "Updated Plan" snapshot toolcall to support merge/dedupe
@@ -211,8 +198,6 @@ export const useWebViewMessages = ({
setPlanEntries,
handlePermissionRequest,
setIsAuthenticated,
setUsageStats,
setModelInfo,
};
});
@@ -245,42 +230,6 @@ export const useWebViewMessages = ({
break;
}
case 'usageStats': {
const stats = message.data as UsageStatsPayload | undefined;
handlers.setUsageStats?.(stats);
break;
}
case 'modelInfo': {
const info = message.data as Partial<ModelInfo> | undefined;
if (
info &&
typeof info.name === 'string' &&
info.name.trim().length > 0
) {
const modelId =
typeof info.modelId === 'string' && info.modelId.trim().length > 0
? info.modelId.trim()
: info.name.trim();
const normalized: ModelInfo = {
modelId,
name: info.name.trim(),
...(typeof info.description !== 'undefined'
? { description: info.description ?? null }
: {}),
...(typeof info._meta !== 'undefined'
? { _meta: info._meta }
: {}),
};
modelInfoRef.current = normalized;
handlers.setModelInfo?.(normalized);
} else {
modelInfoRef.current = null;
handlers.setModelInfo?.(null);
}
break;
}
case 'loginSuccess': {
// Clear loading state and show a short assistant notice
handlers.messageHandling.clearWaitingForResponse();

View File

@@ -151,28 +151,6 @@
fill: var(--app-qwen-ivory);
}
.context-indicator {
@apply inline-flex items-center gap-1 px-1 py-0.5 rounded-small text-[0.8em] select-none;
color: var(--app-secondary-foreground);
}
.context-indicator svg {
width: 20px;
height: 20px;
}
.context-indicator__track,
.context-indicator__progress {
fill: none;
stroke-width: 2.5;
}
.context-indicator__track {
stroke: var(--app-secondary-foreground);
opacity: 0.35;
}
.context-indicator__progress {
stroke: var(--app-secondary-foreground);
stroke-linecap: round;
}
.composer-overlay {
@apply absolute inset-0 rounded-large z-0;
background: var(--app-input-background);

View File

@@ -28,22 +28,3 @@ export const handleOpenDiff = (
});
}
};
/**
* Creates a temporary readonly file with the given content and opens it in VS Code
* @param content The content to write to the temporary file
* @param fileName File name (will be auto-generated with timestamp)
*/
export const createAndOpenTempFile = (
vscode: VSCodeAPI,
content: string,
fileName: string = 'temp',
): void => {
vscode.postMessage({
type: 'createAndOpenTempFile',
data: {
content,
fileName,
},
});
};

View File

@@ -0,0 +1,33 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Temporary file manager for creating and opening temporary files in webview
*/
/**
* Creates a temporary file with the given content and opens it in VS Code
* @param content The content to write to the temporary file
* @param fileName Optional file name (without extension)
* @param fileExtension Optional file extension (defaults to .txt)
*/
export async function createAndOpenTempFile(
postMessage: (message: {
type: string;
data: Record<string, unknown>;
}) => void,
content: string,
fileName: string = 'temp',
fileExtension: string = '.txt',
): Promise<void> {
// Send message to VS Code extension to create and open temp file
postMessage({
type: 'createAndOpenTempFile',
data: {
content,
fileName,
fileExtension,
},
});
}

View File

@@ -1,134 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Unit tests for toolcall utility functions
*/
import { describe, it, expect } from 'vitest';
import { extractCommandOutput, formatValue } from './utils.js';
describe('extractCommandOutput', () => {
it('should extract output from JSON format', () => {
const input = JSON.stringify({ output: 'Hello World' });
expect(extractCommandOutput(input)).toBe('Hello World');
});
it('should handle uppercase Output in JSON', () => {
const input = JSON.stringify({ Output: 'Test Output' });
expect(extractCommandOutput(input)).toBe('Test Output');
});
it('should extract output from structured text format', () => {
const input = `Command: lsof -i :5173
Directory: (root)
Output: COMMAND PID USER FD TYPE
node 59117 jinjing 17u IPv6
Error: (none)
Exit Code: 0`;
const output = extractCommandOutput(input);
expect(output).toContain('COMMAND PID USER');
expect(output).toContain('node 59117 jinjing');
expect(output).not.toContain('Command:');
expect(output).not.toContain('Error:');
});
it('should handle multiline output correctly', () => {
const input = `Command: ps aux
Directory: /home/user
Output: USER PID %CPU %MEM
root 1 0.0 0.1
user 1234 1.5 2.3
Error: (none)
Exit Code: 0`;
const output = extractCommandOutput(input);
expect(output).toContain('USER PID %CPU %MEM');
expect(output).toContain('root 1');
expect(output).toContain('user 1234');
});
it('should skip (none) output', () => {
const input = `Command: test
Output: (none)
Error: (none)`;
const output = extractCommandOutput(input);
expect(output).toBe(input); // Should return original if output is (none)
});
it('should return original text if no structured format found', () => {
const input = 'Just some random text';
expect(extractCommandOutput(input)).toBe(input);
});
it('should handle empty output gracefully', () => {
const input = `Command: test
Output:
Error: (none)`;
const output = extractCommandOutput(input);
// Should return original since output is empty
expect(output).toBe(input);
});
it('should extract from regex match when Output: is present', () => {
const input = `Some text before
Output: This is the output
Error: Some error`;
expect(extractCommandOutput(input)).toBe('This is the output');
});
it('should handle JSON objects in output field', () => {
const input = JSON.stringify({
output: { key: 'value', nested: { data: 'test' } },
});
const output = extractCommandOutput(input);
expect(output).toContain('"key"');
expect(output).toContain('"value"');
});
});
describe('formatValue', () => {
it('should return empty string for null or undefined', () => {
expect(formatValue(null)).toBe('');
expect(formatValue(undefined)).toBe('');
});
it('should extract output from string using extractCommandOutput', () => {
const input = `Command: test
Output: Hello World
Error: (none)`;
const output = formatValue(input);
expect(output).toContain('Hello World');
});
it('should handle Error objects', () => {
const error = new Error('Test error message');
expect(formatValue(error)).toBe('Test error message');
});
it('should handle error-like objects', () => {
const errorObj = { message: 'Custom error', stack: 'stack trace' };
expect(formatValue(errorObj)).toBe('Custom error');
});
it('should stringify objects', () => {
const obj = { key: 'value', number: 42 };
const output = formatValue(obj);
expect(output).toContain('"key"');
expect(output).toContain('"value"');
expect(output).toContain('42');
});
it('should convert primitives to string', () => {
expect(formatValue(123)).toBe('123');
expect(formatValue(true)).toBe('true');
expect(formatValue(false)).toBe('false');
});
});

View File

@@ -37,8 +37,7 @@ if (!versionType) {
run(`npm version ${versionType} --no-git-tag-version --allow-same-version`);
// 3. Get all workspaces and filter out the one we don't want to version.
// We intend to maintain sdk version independently.
const workspacesToExclude = ['@qwen-code/sdk'];
const workspacesToExclude = [];
let lsOutput;
try {
lsOutput = JSON.parse(