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
45 changed files with 222 additions and 1541 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

@@ -124,38 +124,12 @@ Settings are organized into categories. All settings should be placed within the
"temperature": 0.2,
"top_p": 0.8,
"max_tokens": 1024
},
"reasoning": {
"effort": "medium"
}
}
}
}
```
**Reasoning Configuration:**
The `reasoning` field controls reasoning behavior for models that support it:
- Set to `false` to disable reasoning entirely
- Set to an object with `effort` field to enable reasoning with a specific effort level:
- `"low"`: Minimal reasoning effort
- `"medium"`: Balanced reasoning effort (default)
- `"high"`: Maximum reasoning effort
- Optionally include `budget_tokens` to limit reasoning token usage
Example to disable reasoning:
```json
{
"model": {
"generationConfig": {
"reasoning": false
}
}
}
```
**model.openAILoggingDir examples:**
- `"~/qwen-logs"` - Logs to `~/qwen-logs` directory

View File

@@ -19,7 +19,6 @@ import {
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';
@@ -166,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,
},
};
}

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

@@ -675,45 +675,6 @@ const SETTINGS_SCHEMA = {
{ value: 'openapi_30', label: 'OpenAPI 3.0 Strict' },
],
},
samplingParams: {
type: 'object',
label: 'Sampling Parameters',
category: 'Generation Configuration',
requiresRestart: false,
default: undefined as
| {
top_p?: number;
top_k?: number;
repetition_penalty?: number;
presence_penalty?: number;
frequency_penalty?: number;
temperature?: number;
max_tokens?: number;
}
| undefined,
description: 'Sampling parameters for content generation.',
parentKey: 'generationConfig',
childKey: 'samplingParams',
showInDialog: false,
},
reasoning: {
type: 'object',
label: 'Reasoning Configuration',
category: 'Generation Configuration',
requiresRestart: false,
default: undefined as
| false
| {
effort?: 'low' | 'medium' | 'high';
budget_tokens?: number;
}
| undefined,
description:
'Reasoning configuration for models that support reasoning. Set to false to disable reasoning, or provide an object with effort level and optional token budget.',
parentKey: 'generationConfig',
childKey: 'reasoning',
showInDialog: false,
},
},
},
},

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

@@ -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(