mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-29 21:19:14 +00:00
Compare commits
18 Commits
fix/integr
...
mingholy/d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea287fae98 | ||
|
|
cebe0448d0 | ||
|
|
919560e3a4 | ||
|
|
26bd4f882d | ||
|
|
3787e95572 | ||
|
|
7233d37bd1 | ||
|
|
93dcca5147 | ||
|
|
f7d04323f3 | ||
|
|
9a27857f10 | ||
|
|
452f4f3c0e | ||
|
|
5cc01e5e09 | ||
|
|
ac0be9fb84 | ||
|
|
257c6705e1 | ||
|
|
c81c24d45d | ||
|
|
4407597794 | ||
|
|
90bf101040 | ||
|
|
660901e1fd | ||
|
|
8e64c5acaf |
87
.github/workflows/release-sdk.yml
vendored
87
.github/workflows/release-sdk.yml
vendored
@@ -91,6 +91,8 @@ jobs:
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
scope: '@qwen-code'
|
||||
|
||||
- name: 'Install Dependencies'
|
||||
run: |-
|
||||
@@ -126,6 +128,14 @@ 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
|
||||
@@ -158,7 +168,21 @@ 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 }}'
|
||||
@@ -167,50 +191,22 @@ jobs:
|
||||
git switch -c "${BRANCH_NAME}"
|
||||
echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- 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'
|
||||
- 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' }}
|
||||
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
|
||||
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 }}'
|
||||
echo "Pushing release branch to remote..."
|
||||
git push --set-upstream origin "${BRANCH_NAME}" --follow-tags
|
||||
|
||||
- name: 'Create GitHub Release and Tag'
|
||||
if: |-
|
||||
@@ -220,16 +216,29 @@ 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 "$RELEASE_BRANCH" \
|
||||
--target "${TARGET}" \
|
||||
--title "SDK TypeScript Release ${RELEASE_TAG}" \
|
||||
--notes-start-tag "sdk-typescript-${PREVIOUS_RELEASE_TAG}" \
|
||||
--generate-notes
|
||||
--generate-notes \
|
||||
${PRERELEASE_FLAG}
|
||||
|
||||
- name: 'Create PR to merge release branch into main'
|
||||
if: |-
|
||||
${{ steps.vars.outputs.is_dry_run == 'false' }}
|
||||
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
|
||||
id: 'pr'
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
@@ -251,7 +260,7 @@ jobs:
|
||||
|
||||
- name: 'Wait for CI checks to complete'
|
||||
if: |-
|
||||
${{ steps.vars.outputs.is_dry_run == 'false' }}
|
||||
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
PR_URL: '${{ steps.pr.outputs.PR_URL }}'
|
||||
@@ -262,7 +271,7 @@ jobs:
|
||||
|
||||
- name: 'Enable auto-merge for release PR'
|
||||
if: |-
|
||||
${{ steps.vars.outputs.is_dry_run == 'false' }}
|
||||
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
PR_URL: '${{ steps.pr.outputs.PR_URL }}'
|
||||
|
||||
@@ -124,12 +124,38 @@ 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
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
# Qwen Code overview
|
||||
[](https://npm-compare.com/@qwen-code/qwen-code)
|
||||
[](https://www.npmjs.com/package/@qwen-code/qwen-code)
|
||||
|
||||
> Learn about Qwen Code, Qwen's agentic coding tool that lives in your terminal and helps you turn ideas into code faster than ever before.
|
||||
|
||||
@@ -46,7 +48,7 @@ You'll be prompted to log in on first use. That's it! [Continue with Quickstart
|
||||
|
||||
> [!note]
|
||||
>
|
||||
> **New VS Code Extension (Beta)**: Prefer a graphical interface? Our new **VS Code extension** provides an easy-to-use native IDE experience without requiring terminal familiarity. Simply install from the marketplace and start coding with Qwen Code directly in your sidebar. You can search for **Qwen Code** in the VS Code Marketplace and download it.
|
||||
> **New VS Code Extension (Beta)**: Prefer a graphical interface? Our new **VS Code extension** provides an easy-to-use native IDE experience without requiring terminal familiarity. Simply install from the marketplace and start coding with Qwen Code directly in your sidebar. Download and install the [Qwen Code Companion](https://marketplace.visualstudio.com/items?itemName=qwenlm.qwen-code-vscode-ide-companion) now.
|
||||
|
||||
## What Qwen Code does for you
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { existsSync } from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
|
||||
|
||||
describe('file-system', () => {
|
||||
@@ -245,12 +243,5 @@ describe('file-system', () => {
|
||||
successfulReplace,
|
||||
'A successful replace should not have occurred',
|
||||
).toBeUndefined();
|
||||
|
||||
// Final verification: ensure the file was not created.
|
||||
const filePath = path.join(rig.testDir!, fileName);
|
||||
const fileExists = existsSync(filePath);
|
||||
expect(fileExists, 'The non-existent file should not be created').toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ 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';
|
||||
@@ -165,9 +166,30 @@ 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -93,6 +93,7 @@ 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>;
|
||||
|
||||
@@ -254,8 +255,26 @@ 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();
|
||||
@@ -514,6 +533,13 @@ 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,
|
||||
@@ -555,6 +581,7 @@ export const sessionUpdateSchema = z.union([
|
||||
sessionUpdate: z.literal('plan'),
|
||||
}),
|
||||
currentModeUpdateSchema,
|
||||
currentModelUpdateSchema,
|
||||
availableCommandsUpdateSchema,
|
||||
]);
|
||||
|
||||
|
||||
@@ -675,6 +675,45 @@ 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -50,6 +50,9 @@ vi.mock('vscode', () => ({
|
||||
registerTextDocumentContentProvider: vi.fn(),
|
||||
onDidChangeWorkspaceFolders: vi.fn(),
|
||||
onDidGrantWorkspaceTrust: vi.fn(),
|
||||
registerFileSystemProvider: vi.fn(() => ({
|
||||
dispose: vi.fn(),
|
||||
})),
|
||||
},
|
||||
commands: {
|
||||
registerCommand: vi.fn(),
|
||||
|
||||
@@ -16,6 +16,7 @@ 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';
|
||||
@@ -110,6 +111,19 @@ 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,
|
||||
|
||||
@@ -38,6 +38,10 @@ 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: [
|
||||
|
||||
@@ -146,6 +146,8 @@ 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
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
AcpSessionUpdate,
|
||||
AcpPermissionRequest,
|
||||
AuthenticateUpdateNotification,
|
||||
ModelInfo,
|
||||
} from '../types/acpTypes.js';
|
||||
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
|
||||
import { QwenSessionReader, type QwenSession } from './qwenSessionReader.js';
|
||||
@@ -17,6 +18,7 @@ import type {
|
||||
PlanEntry,
|
||||
ToolCallUpdateData,
|
||||
QwenAgentCallbacks,
|
||||
UsageStatsPayload,
|
||||
} from '../types/chatTypes.js';
|
||||
import {
|
||||
QwenConnectionHandler,
|
||||
@@ -24,6 +26,7 @@ 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';
|
||||
|
||||
@@ -195,12 +198,16 @@ export class QwenAgentManager {
|
||||
options?: AgentConnectOptions,
|
||||
): Promise<QwenConnectionResult> {
|
||||
this.currentWorkingDir = workingDir;
|
||||
return this.connectionHandler.connect(
|
||||
const res = await this.connectionHandler.connect(
|
||||
this.connection,
|
||||
workingDir,
|
||||
cliEntryPath,
|
||||
options,
|
||||
);
|
||||
if (res.modelInfo && this.callbacks.onModelInfo) {
|
||||
this.callbacks.onModelInfo(res.modelInfo);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1091,9 +1098,10 @@ 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 {
|
||||
await this.connection.newSession(workingDir);
|
||||
newSessionResult = await this.connection.newSession(workingDir);
|
||||
} catch (err) {
|
||||
const requiresAuth = isAuthenticationRequiredError(err);
|
||||
|
||||
@@ -1115,7 +1123,7 @@ export class QwenAgentManager {
|
||||
);
|
||||
// Add a slight delay to ensure auth state is settled
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
await this.connection.newSession(workingDir);
|
||||
newSessionResult = await this.connection.newSession(workingDir);
|
||||
} catch (reauthErr) {
|
||||
console.error(
|
||||
'[QwenAgentManager] Re-authentication failed:',
|
||||
@@ -1127,6 +1135,13 @@ 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:',
|
||||
@@ -1257,6 +1272,22 @@ 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
|
||||
*/
|
||||
|
||||
@@ -13,10 +13,13 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,6 +47,7 @@ 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[] = [];
|
||||
@@ -66,13 +70,15 @@ export class QwenConnectionHandler {
|
||||
console.log(
|
||||
'[QwenAgentManager] Creating new session (letting CLI handle authentication)...',
|
||||
);
|
||||
await this.newSessionWithRetry(
|
||||
const newSessionResult = await this.newSessionWithRetry(
|
||||
connection,
|
||||
workingDir,
|
||||
3,
|
||||
authMethod,
|
||||
autoAuthenticate,
|
||||
);
|
||||
modelInfo =
|
||||
extractModelInfoFromNewSessionResult(newSessionResult) || undefined;
|
||||
console.log('[QwenAgentManager] New session created successfully');
|
||||
sessionCreated = true;
|
||||
} catch (sessionError) {
|
||||
@@ -99,7 +105,7 @@ export class QwenConnectionHandler {
|
||||
console.log(`\n========================================`);
|
||||
console.log(`[QwenAgentManager] ✅ CONNECT() COMPLETED SUCCESSFULLY`);
|
||||
console.log(`========================================\n`);
|
||||
return { sessionCreated, requiresAuth };
|
||||
return { sessionCreated, requiresAuth, modelInfo };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,15 +121,15 @@ export class QwenConnectionHandler {
|
||||
maxRetries: number,
|
||||
authMethod: string,
|
||||
autoAuthenticate: boolean,
|
||||
): Promise<void> {
|
||||
): Promise<unknown> {
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
console.log(
|
||||
`[QwenAgentManager] Creating session (attempt ${attempt}/${maxRetries})...`,
|
||||
);
|
||||
await connection.newSession(workingDir);
|
||||
const res = await connection.newSession(workingDir);
|
||||
console.log('[QwenAgentManager] Session created successfully');
|
||||
return;
|
||||
return res;
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
@@ -155,11 +161,11 @@ export class QwenConnectionHandler {
|
||||
'[QwenAgentManager] newSessionWithRetry Authentication successful',
|
||||
);
|
||||
// Retry immediately after successful auth
|
||||
await connection.newSession(workingDir);
|
||||
const res = await connection.newSession(workingDir);
|
||||
console.log(
|
||||
'[QwenAgentManager] Session created successfully after auth',
|
||||
);
|
||||
return;
|
||||
return res;
|
||||
} catch (authErr) {
|
||||
console.error(
|
||||
'[QwenAgentManager] Re-authentication failed:',
|
||||
@@ -180,5 +186,7 @@ export class QwenConnectionHandler {
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Session creation failed unexpectedly');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,12 @@
|
||||
* Handles session updates from ACP and dispatches them to appropriate callbacks
|
||||
*/
|
||||
|
||||
import type { AcpSessionUpdate } from '../types/acpTypes.js';
|
||||
import type { AcpSessionUpdate, SessionUpdateMeta } from '../types/acpTypes.js';
|
||||
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
|
||||
import type { QwenAgentCallbacks } from '../types/chatTypes.js';
|
||||
import type {
|
||||
QwenAgentCallbacks,
|
||||
UsageStatsPayload,
|
||||
} from '../types/chatTypes.js';
|
||||
|
||||
/**
|
||||
* Qwen Session Update Handler class
|
||||
@@ -57,6 +60,7 @@ 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':
|
||||
@@ -71,6 +75,7 @@ export class QwenSessionUpdateHandler {
|
||||
this.callbacks.onStreamChunk(update.content.text);
|
||||
}
|
||||
}
|
||||
this.emitUsageMeta(update._meta);
|
||||
break;
|
||||
|
||||
case 'tool_call': {
|
||||
@@ -160,4 +165,17 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,35 @@ 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';
|
||||
@@ -59,6 +88,7 @@ export interface AgentMessageChunkUpdate extends BaseSessionUpdate {
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk';
|
||||
content: ContentBlock;
|
||||
_meta?: SessionUpdateMeta;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -66,6 +96,7 @@ export interface AgentThoughtChunkUpdate extends BaseSessionUpdate {
|
||||
update: {
|
||||
sessionUpdate: 'agent_thought_chunk';
|
||||
content: ContentBlock;
|
||||
_meta?: SessionUpdateMeta;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { AcpPermissionRequest } from './acpTypes.js';
|
||||
import type { AcpPermissionRequest, ModelInfo } from './acpTypes.js';
|
||||
import type { ApprovalModeValue } from './approvalModeValueTypes.js';
|
||||
|
||||
export interface ChatMessage {
|
||||
@@ -28,6 +28,18 @@ 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;
|
||||
@@ -45,6 +57,8 @@ export interface QwenAgentCallbacks {
|
||||
}>;
|
||||
}) => void;
|
||||
onModeChanged?: (modeId: ApprovalModeValue) => void;
|
||||
onUsageUpdate?: (stats: UsageStatsPayload) => void;
|
||||
onModelInfo?: (info: ModelInfo) => void;
|
||||
}
|
||||
|
||||
export interface ToolCallUpdate {
|
||||
|
||||
77
packages/vscode-ide-companion/src/utils/acpModelInfo.test.ts
Normal file
77
packages/vscode-ide-companion/src/utils/acpModelInfo.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* @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();
|
||||
});
|
||||
});
|
||||
135
packages/vscode-ide-companion/src/utils/acpModelInfo.ts
Normal file
135
packages/vscode-ide-companion/src/utils/acpModelInfo.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* @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;
|
||||
};
|
||||
@@ -53,11 +53,40 @@ 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 (which equals the chat's previous column).
|
||||
* then return the new group's ViewColumn.
|
||||
* - If the chat webview cannot be located, returns undefined.
|
||||
*/
|
||||
export async function ensureLeftGroupOfChatWebview(): Promise<
|
||||
@@ -87,7 +116,7 @@ export async function ensureLeftGroupOfChatWebview(): Promise<
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const previousChatColumn = webviewGroup.viewColumn;
|
||||
const initialGroupCount = vscode.window.tabGroups.all.length;
|
||||
|
||||
// Make the chat group active by revealing the panel
|
||||
try {
|
||||
@@ -104,6 +133,22 @@ 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);
|
||||
@@ -111,6 +156,7 @@ export async function ensureLeftGroupOfChatWebview(): Promise<
|
||||
// Ignore
|
||||
}
|
||||
|
||||
// The new left group's column equals the chat's previous column
|
||||
return previousChatColumn;
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -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 './components/messages/toolcalls/shared/utils.js';
|
||||
import { hasToolCallOutput } from './utils/utils.js';
|
||||
import { EmptyState } from './components/layout/EmptyState.js';
|
||||
import { Onboarding } from './components/layout/Onboarding.js';
|
||||
import { type CompletionItem } from '../types/completionItemTypes.js';
|
||||
@@ -45,7 +45,12 @@ 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 } from '../types/chatTypes.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';
|
||||
|
||||
export const App: React.FC = () => {
|
||||
const vscode = useVSCode();
|
||||
@@ -70,6 +75,8 @@ 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>;
|
||||
@@ -160,6 +167,48 @@ 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(
|
||||
() =>
|
||||
@@ -248,6 +297,10 @@ 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,
|
||||
@@ -760,6 +813,7 @@ export const App: React.FC = () => {
|
||||
activeFileName={fileContext.activeFileName}
|
||||
activeSelection={fileContext.activeSelection}
|
||||
skipAutoActiveContext={skipAutoActiveContext}
|
||||
contextUsage={contextUsage}
|
||||
onInputChange={setInputText}
|
||||
onCompositionStart={() => setIsComposing(true)}
|
||||
onCompositionEnd={() => setIsComposing(false)}
|
||||
|
||||
@@ -118,6 +118,20 @@ 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
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* @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>
|
||||
);
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* @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>
|
||||
);
|
||||
};
|
||||
@@ -21,6 +21,7 @@ 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;
|
||||
@@ -36,6 +37,11 @@ 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;
|
||||
@@ -96,6 +102,7 @@ export const InputForm: React.FC<InputFormProps> = ({
|
||||
activeFileName,
|
||||
activeSelection,
|
||||
skipAutoActiveContext,
|
||||
contextUsage,
|
||||
onInputChange,
|
||||
onCompositionStart,
|
||||
onCompositionEnd,
|
||||
@@ -240,6 +247,9 @@ 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
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Execute tool call styles - Enhanced styling with semantic class names
|
||||
* Bash tool call styles - Enhanced styling with semantic class names
|
||||
*/
|
||||
|
||||
/* Root container for execute tool call output */
|
||||
/* Root container for bash tool call output */
|
||||
.bash-toolcall-card {
|
||||
border: 0.5px solid var(--app-input-border);
|
||||
border-radius: 5px;
|
||||
@@ -100,3 +100,9 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
import type React from 'react';
|
||||
import type { BaseToolCallProps } from '../shared/types.js';
|
||||
import { ToolCallContainer } from '../shared/LayoutComponents.js';
|
||||
import { safeTitle, groupContent } from '../shared/utils.js';
|
||||
import { safeTitle, groupContent } from '../../../../utils/utils.js';
|
||||
import { useVSCode } from '../../../../hooks/useVSCode.js';
|
||||
import { createAndOpenTempFile } from '../../../../utils/tempFileManager.js';
|
||||
import { createAndOpenTempFile } from '../../../../utils/diffUtils.js';
|
||||
import { CopyButton } from '../shared/copyUtils.js';
|
||||
import './Bash.css';
|
||||
|
||||
/**
|
||||
@@ -37,19 +38,14 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
|
||||
// Handle click on IN section
|
||||
const handleInClick = () => {
|
||||
createAndOpenTempFile(
|
||||
vscode.postMessage,
|
||||
inputCommand,
|
||||
'bash-input',
|
||||
'.sh',
|
||||
);
|
||||
createAndOpenTempFile(vscode, inputCommand, `bash-input-${toolCallId}`);
|
||||
};
|
||||
|
||||
// Handle click on OUT section
|
||||
const handleOutClick = () => {
|
||||
if (textOutputs.length > 0) {
|
||||
const output = textOutputs.join('\n');
|
||||
createAndOpenTempFile(vscode.postMessage, output, 'bash-output', '.txt');
|
||||
createAndOpenTempFile(vscode, output, `bash-output-${toolCallId}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -84,7 +80,7 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
<div className="bash-toolcall-content">
|
||||
{/* IN row */}
|
||||
<div
|
||||
className="bash-toolcall-row"
|
||||
className="bash-toolcall-row bash-toolcall-row-with-copy group"
|
||||
onClick={handleInClick}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
@@ -92,6 +88,7 @@ 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 */}
|
||||
@@ -131,7 +128,7 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
<div className="bash-toolcall-content">
|
||||
{/* IN row */}
|
||||
<div
|
||||
className="bash-toolcall-row"
|
||||
className="bash-toolcall-row bash-toolcall-row-with-copy group"
|
||||
onClick={handleInClick}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
@@ -139,6 +136,7 @@ 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 */}
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { BaseToolCallProps } from '../shared/types.js';
|
||||
import {
|
||||
groupContent,
|
||||
mapToolStatusToContainerStatus,
|
||||
} from '../shared/utils.js';
|
||||
} from '../../../../utils/utils.js';
|
||||
import { FileLink } from '../../../layout/FileLink.js';
|
||||
import type { ToolCallContainerProps } from '../shared/LayoutComponents.js';
|
||||
|
||||
|
||||
@@ -61,11 +61,7 @@
|
||||
/* 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;
|
||||
}
|
||||
|
||||
@@ -87,7 +83,6 @@
|
||||
|
||||
/* Output content with subtle styling */
|
||||
.execute-toolcall-output-subtle {
|
||||
background-color: var(--app-code-background);
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
@@ -100,3 +95,9 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -8,9 +8,12 @@
|
||||
|
||||
import type React from 'react';
|
||||
import type { BaseToolCallProps } from '../shared/types.js';
|
||||
import { safeTitle, groupContent } from '../shared/utils.js';
|
||||
import { safeTitle, groupContent } from '../../../../utils/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,
|
||||
@@ -48,6 +51,7 @@ 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);
|
||||
@@ -61,6 +65,19 @@ 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'
|
||||
@@ -92,11 +109,16 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
<div className="execute-toolcall-card">
|
||||
<div className="execute-toolcall-content">
|
||||
{/* IN row */}
|
||||
<div className="execute-toolcall-row">
|
||||
<div
|
||||
className="execute-toolcall-row execute-toolcall-row-with-copy group"
|
||||
onClick={handleInClick}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<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 */}
|
||||
@@ -135,15 +157,24 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
<div className="execute-toolcall-card">
|
||||
<div className="execute-toolcall-content">
|
||||
{/* IN row */}
|
||||
<div className="execute-toolcall-row">
|
||||
<div
|
||||
className="execute-toolcall-row execute-toolcall-row-with-copy group"
|
||||
onClick={handleInClick}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<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">
|
||||
<div
|
||||
className="execute-toolcall-row"
|
||||
onClick={handleOutClick}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<div className="execute-toolcall-label">OUT</div>
|
||||
<div className="execute-toolcall-row-content">
|
||||
<div className="execute-toolcall-output-subtle">
|
||||
@@ -164,7 +195,11 @@ 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">
|
||||
<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' }}
|
||||
>
|
||||
<span className="flex-shrink-0 relative top-[-0.1em]">⎿</span>
|
||||
<span className="flex-shrink-0 w-full">{commandText}</span>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
ToolCallRow,
|
||||
LocationsList,
|
||||
} from './shared/LayoutComponents.js';
|
||||
import { safeTitle, groupContent } from './shared/utils.js';
|
||||
import { safeTitle, groupContent } from '../../../utils/utils.js';
|
||||
|
||||
/**
|
||||
* Generic tool call component that can display any tool call type
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { BaseToolCallProps } from '../shared/types.js';
|
||||
import {
|
||||
groupContent,
|
||||
mapToolStatusToContainerStatus,
|
||||
} from '../shared/utils.js';
|
||||
} from '../../../../utils/utils.js';
|
||||
import { FileLink } from '../../../layout/FileLink.js';
|
||||
import { useVSCode } from '../../../../hooks/useVSCode.js';
|
||||
import { handleOpenDiff } from '../../../../utils/diffUtils.js';
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
safeTitle,
|
||||
groupContent,
|
||||
mapToolStatusToContainerStatus,
|
||||
} from '../shared/utils.js';
|
||||
} from '../../../../utils/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, index) => (
|
||||
{textOutputs.map((text: string, index: number) => (
|
||||
<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"
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
ToolCallCard,
|
||||
ToolCallRow,
|
||||
} from '../shared/LayoutComponents.js';
|
||||
import { groupContent } from '../shared/utils.js';
|
||||
import { groupContent } from '../../../../utils/utils.js';
|
||||
|
||||
/**
|
||||
* Specialized component for Think tool calls
|
||||
|
||||
@@ -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 '../shared/utils.js';
|
||||
import { groupContent, safeTitle } from '../../../../utils/utils.js';
|
||||
import { CheckboxDisplay } from './CheckboxDisplay.js';
|
||||
import type { PlanEntry } from '../../../../../types/chatTypes.js';
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ToolCallContainer } from '../shared/LayoutComponents.js';
|
||||
import {
|
||||
groupContent,
|
||||
mapToolStatusToContainerStatus,
|
||||
} from '../shared/utils.js';
|
||||
} from '../../../../utils/utils.js';
|
||||
import { FileLink } from '../../../layout/FileLink.js';
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import type React from 'react';
|
||||
import type { BaseToolCallProps } from './shared/types.js';
|
||||
import { shouldShowToolCall } from './shared/utils.js';
|
||||
import { shouldShowToolCall } from '../../../utils/utils.js';
|
||||
import { GenericToolCall } from './GenericToolCall.js';
|
||||
import { ReadToolCall } from './Read/ReadToolCall.js';
|
||||
import { WriteToolCall } from './Write/WriteToolCall.js';
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @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>
|
||||
);
|
||||
};
|
||||
@@ -5,12 +5,14 @@
|
||||
*/
|
||||
|
||||
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
|
||||
@@ -396,7 +398,7 @@ export class FileMessageHandler extends BaseMessageHandler {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and open temporary file
|
||||
* Create and open temporary readonly file
|
||||
*/
|
||||
private async handleCreateAndOpenTempFile(
|
||||
data: Record<string, unknown> | undefined,
|
||||
@@ -411,26 +413,78 @@ 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';
|
||||
|
||||
// Create temporary file path
|
||||
const tempDir = os.tmpdir();
|
||||
const tempFileName = `${fileName}-${Date.now()}${fileExtension}`;
|
||||
const tempFilePath = path.join(tempDir, tempFileName);
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Write content to temporary file
|
||||
await fs.promises.writeFile(tempFilePath, content, 'utf8');
|
||||
// Create readonly URI (without timestamp to ensure consistency)
|
||||
const uri = readonlyProvider.createUri(fileName, content);
|
||||
readonlyProvider.setContent(uri, content);
|
||||
|
||||
// Open the temporary file in VS Code
|
||||
const uri = vscode.Uri.file(tempFilePath);
|
||||
await vscode.window.showTextDocument(uri, {
|
||||
// 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,
|
||||
preview: false,
|
||||
preserveFocus: false,
|
||||
});
|
||||
|
||||
console.log(
|
||||
'[FileMessageHandler] Created and opened temporary file:',
|
||||
tempFilePath,
|
||||
'[FileMessageHandler] Created and opened readonly file:',
|
||||
uri.toString(),
|
||||
'in viewColumn:',
|
||||
targetViewColumn ?? 'Beside',
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
|
||||
@@ -11,9 +11,13 @@ import type {
|
||||
PermissionOption,
|
||||
ToolCall as PermissionToolCall,
|
||||
} from '../components/PermissionDrawer/PermissionRequest.js';
|
||||
import type { ToolCallUpdate } from '../../types/chatTypes.js';
|
||||
import type {
|
||||
ToolCallUpdate,
|
||||
UsageStatsPayload,
|
||||
} 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',
|
||||
@@ -119,6 +123,10 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,12 +145,15 @@ 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,
|
||||
@@ -153,6 +164,8 @@ export const useWebViewMessages = ({
|
||||
setPlanEntries,
|
||||
handlePermissionRequest,
|
||||
setIsAuthenticated,
|
||||
setUsageStats,
|
||||
setModelInfo,
|
||||
});
|
||||
|
||||
// Track last "Updated Plan" snapshot toolcall to support merge/dedupe
|
||||
@@ -198,6 +211,8 @@ export const useWebViewMessages = ({
|
||||
setPlanEntries,
|
||||
handlePermissionRequest,
|
||||
setIsAuthenticated,
|
||||
setUsageStats,
|
||||
setModelInfo,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -230,6 +245,42 @@ 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();
|
||||
|
||||
@@ -151,6 +151,28 @@
|
||||
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);
|
||||
|
||||
@@ -28,3 +28,22 @@ 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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* @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,
|
||||
},
|
||||
});
|
||||
}
|
||||
134
packages/vscode-ide-companion/src/webview/utils/utils.test.ts
Normal file
134
packages/vscode-ide-companion/src/webview/utils/utils.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* @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');
|
||||
});
|
||||
});
|
||||
@@ -9,8 +9,102 @@
|
||||
import type {
|
||||
ToolCallContent,
|
||||
GroupedContent,
|
||||
ToolCallData,
|
||||
ToolCallStatus,
|
||||
} from './types.js';
|
||||
} 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;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format any value to a string for display
|
||||
@@ -20,13 +114,8 @@ export const formatValue = (value: unknown): string => {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
// 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;
|
||||
// Extract command output from structured text
|
||||
return extractCommandOutput(value);
|
||||
}
|
||||
// Handle Error objects specially
|
||||
if (value instanceof Error) {
|
||||
@@ -72,9 +161,7 @@ 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: import('./types.js').ToolCallData,
|
||||
): boolean => {
|
||||
export const hasToolCallOutput = (toolCall: ToolCallData): boolean => {
|
||||
// Always show failed tool calls (even without content)
|
||||
if (toolCall.status === 'failed') {
|
||||
return true;
|
||||
@@ -37,7 +37,8 @@ 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.
|
||||
const workspacesToExclude = [];
|
||||
// We intend to maintain sdk version independently.
|
||||
const workspacesToExclude = ['@qwen-code/sdk'];
|
||||
let lsOutput;
|
||||
try {
|
||||
lsOutput = JSON.parse(
|
||||
|
||||
Reference in New Issue
Block a user